ai-bw template refinements: remove page-2 header, add project spacing, add levit profile
Some checks failed
CI / core (push) Failing after 10m33s
Some checks failed
CI / core (push) Failing after 10m33s
- Remove page-2 running header (was overlapping EARLIER CAREER heading) - Add inter-entry spacing to Projects section to match Earlier Career rhythm - Add levit.yml resume profile - Add export scripts for levit in package.json - Add work arrangement legend text-icon support in options.js Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
234a8866b1
commit
9a530f941c
@ -15,6 +15,7 @@
|
|||||||
"start": "node --openssl-legacy-provider build/dev-server.js",
|
"start": "node --openssl-legacy-provider build/dev-server.js",
|
||||||
"dev:dobkin": "RESUME_NAME=dobkin node --openssl-legacy-provider build/dev-server.js",
|
"dev:dobkin": "RESUME_NAME=dobkin node --openssl-legacy-provider build/dev-server.js",
|
||||||
"dev:cherepaha": "RESUME_NAME=cherepaha node --openssl-legacy-provider build/dev-server.js",
|
"dev:cherepaha": "RESUME_NAME=cherepaha node --openssl-legacy-provider build/dev-server.js",
|
||||||
|
"dev:levit": "RESUME_NAME=levit node --openssl-legacy-provider build/dev-server.js",
|
||||||
"pdf": "node scripts/export.js",
|
"pdf": "node scripts/export.js",
|
||||||
"pdf:green": "node scripts/export.js green",
|
"pdf:green": "node scripts/export.js green",
|
||||||
"pdf:ai-bw": "node scripts/export.js ai-bw",
|
"pdf:ai-bw": "node scripts/export.js ai-bw",
|
||||||
@ -33,6 +34,8 @@
|
|||||||
"export:cherepaha:ai-bw": "RESUME_NAME=cherepaha concurrently \"npm run dev\" \"node scripts/export.js ai-bw\" --success first --kill-others --raw",
|
"export:cherepaha:ai-bw": "RESUME_NAME=cherepaha concurrently \"npm run dev\" \"node scripts/export.js ai-bw\" --success first --kill-others --raw",
|
||||||
"export:bw": "node scripts/export-cli.js",
|
"export:bw": "node scripts/export-cli.js",
|
||||||
"export:cherepaha": "RESUME_NAME=cherepaha concurrently \"npm run dev\" \"npm run pdf\" --success first --kill-others --raw",
|
"export:cherepaha": "RESUME_NAME=cherepaha concurrently \"npm run dev\" \"npm run pdf\" --success first --kill-others --raw",
|
||||||
|
"export:levit": "RESUME_NAME=levit concurrently \"npm run dev\" \"npm run pdf\" --success first --kill-others --raw",
|
||||||
|
"export:levit:ai-bw": "RESUME_NAME=levit concurrently \"npm run dev\" \"node scripts/export.js ai-bw\" --success first --kill-others --raw",
|
||||||
"lint": "eslint --ext .js,.vue src scripts",
|
"lint": "eslint --ext .js,.vue src scripts",
|
||||||
"lint:fix": "eslint --ext .js,.vue src scripts --fix"
|
"lint:fix": "eslint --ext .js,.vue src scripts --fix"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -2,165 +2,169 @@
|
|||||||
# Experience: timeperiod = dates only; location = separate line. description: | = one bullet per line.
|
# Experience: timeperiod = dates only; location = separate line. description: | = one bullet per line.
|
||||||
# Optional: remote: true | false | hybrid; employment: contract | full-time | co-op (icons + legend on green/purple).
|
# Optional: remote: true | false | hybrid; employment: contract | full-time | co-op (icons + legend on green/purple).
|
||||||
# Optional contact: github_profile = public GitHub.com username (add if you use both Forge + GitHub).
|
# Optional contact: github_profile = public GitHub.com username (add if you use both Forge + GitHub).
|
||||||
|
#
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# STACK TOGGLE
|
||||||
|
# Flip show_stack below to hide/show ALL per-role "Stack:" lines at once.
|
||||||
|
# Set to false to hide all Stack: lines across the resume. Default: true (show).
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
show_stack: true
|
||||||
|
|
||||||
name:
|
name:
|
||||||
first: ILIA
|
first: ILIA
|
||||||
middle:
|
middle:
|
||||||
last: DOBKIN
|
last: DOBKIN
|
||||||
about: "Senior Software Development Engineer in Test with 20+ years across audit/financial platforms and modern regulated web, including real-money iGaming. Experienced owning Playwright/Cypress/Selenium automation across UI, API, and performance, plus CI/CD pipelines and DevOps tooling. Known for test-pyramid and shift-left practices, stabilizing flaky suites, and improving pipeline reliability in high-availability environments. Proactively eliminates repetitive work across teams through scripting, scheduled jobs, and workflow automation—freeing colleagues to focus on higher-value tasks. Improved end-to-end regression cycle time by ~40% by expanding automated coverage and parallelizing suites in CI pipelines."
|
about: "Senior SDET with 20+ years in audit/financial software and regulated web, including real-money iGaming. Deep across Playwright, Swagger/OpenAPI contract testing, and performance baselines integrated into CI/CD. I treat automation as a personal discipline as much as a job: scripts, shortcuts, and agents that streamline my day so engineering effort goes where it matters. Strong instinct for stabilizing flaky suites, tightening quality gates, and removing manual regression effort wherever it lives."
|
||||||
core_strengths: ""
|
|
||||||
position: Senior Software Development Engineer in Test (SDET)
|
position: Senior Software Development Engineer in Test (SDET)
|
||||||
|
|
||||||
# Shown next to the map pin in the header (green / ai-bw). Other themes use it as a location line; leave year empty to show "Based in …" instead of "Born … in …".
|
# Shown next to the map pin in the header (green / ai-bw). Other themes use it as a location line; leave year empty to show "Based in …" instead of "Born … in …".
|
||||||
|
work_auth: "Canadian citizen"
|
||||||
|
|
||||||
birth:
|
birth:
|
||||||
year:
|
year:
|
||||||
location: Toronto, Ontario, Canada
|
location: "Remote (ET)"
|
||||||
|
|
||||||
experience:
|
experience:
|
||||||
- company: Niyasoft Canada Inc.
|
- company: Niyasoft
|
||||||
position: Senior Quality Assurance Automation Engineer
|
position: Senior Quality Assurance Automation Engineer
|
||||||
timeperiod: August 2023 - April 2026
|
timeperiod: August 2023 – April 2026
|
||||||
remote: true
|
remote: true
|
||||||
employment: full-time
|
employment: full-time
|
||||||
location: Vaughan, Ontario, Canada
|
location: Vaughan, Ontario, Canada
|
||||||
|
stack: "Playwright, TypeScript, GitHub Actions, PostgreSQL, GCP"
|
||||||
description: |
|
description: |
|
||||||
• Built and maintained 300+ Playwright E2E tests and 250+ API/integration tests (Swagger/OpenAPI) plus Artillery performance suites for a regulated online-casino platform; coverage spanned happy-path, negative, workflow, page-navigation, and network request/response checks across payments, wallet/cashier, game/lobby, and back-office flows—cutting manual regression effort by ~50% and catching regressions earlier in the pipeline.
|
• Built and maintained **300+ Playwright E2E tests** and 250+ API/integration tests plus performance suites for a regulated online-casino platform; coverage spanned happy-path, negative, workflow, page-navigation, and network request/response checks across payments, wallet/cashier, game/lobby, and back-office flows—cutting manual regression effort by 50% and catching regressions earlier in the pipeline.
|
||||||
• Stabilized the Playwright suite by replacing brittle waits with deterministic patterns and improving environment readiness, cutting flaky-test noise and keeping daily pass rates consistently above ~95% across parallel CI stages.
|
• Stabilized the **Playwright suite** by replacing brittle waits with deterministic patterns and improving environment readiness; reduced flaky-test noise and maintained daily pass rates above 90% across parallel CI stages.
|
||||||
• Validated responsible gaming and player protection end-to-end—deposit/loss/session limits, self-exclusion, cooling-off, reality checks—supporting successful compliance posture across licensed wagering markets.
|
• Validated **responsible gaming and player protection** end-to-end—deposit/loss/session limits, self-exclusion, cooling-off, reality checks—supporting successful compliance posture across licensed wagering markets.
|
||||||
• Ran compliance-sensitive scenarios for geo-eligibility, age-gating, and restricted jurisdictions with audit-friendly logging; traceability artifacts available for licensing reviews on demand.
|
• Ran compliance-sensitive scenarios for **geo-eligibility** with audit-friendly logging; traceability artifacts available for licensing reviews on demand.
|
||||||
• Optimized GitHub Actions pipelines (regression, functional, component, smoke) with parallelized stages and daily PR/review cadence, keeping feedback time short on a high-availability real-money stack.
|
• Optimized **GitHub Actions pipelines** (regression, functional, component, smoke) with parallelized stages and daily PR/review cadence, keeping feedback time short on a high-availability real-money stack.
|
||||||
• Monitored API reliability via GCP, Prometheus, and alerting; validated PostgreSQL-backed data integrity and prevented sev-1 incidents by catching performance regressions with Artillery baselines before release.
|
• Monitored GCP metrics and alerts for API reliability; validated **PostgreSQL-backed data integrity** and prevented sev-1 incidents by catching performance regressions before release.
|
||||||
|
• Authored and enforced **Swagger/OpenAPI contract tests** against backend microservices, catching breaking schema changes before they reached downstream consumers.
|
||||||
|
|
||||||
- company: RIOS Canada
|
- company: RIOS Canada
|
||||||
position: Software Development Engineer in Test (SDET)
|
position: Software Development Engineer in Test (SDET)
|
||||||
timeperiod: June 2022 - July 2023
|
timeperiod: June 2022 – July 2023
|
||||||
remote: true
|
remote: true
|
||||||
employment: contract
|
employment: contract
|
||||||
location: Toronto, Ontario, Canada
|
location: Toronto, Ontario, Canada
|
||||||
|
stack: "Cypress, JavaScript, Bitbucket CI, Swagger/OpenAPI, Ansible"
|
||||||
description: |
|
description: |
|
||||||
• Built Cypress E2E and API suites (Swagger/OpenAPI) from scratch across core product flows; owned patterns, fixtures, and stability, enabling every-commit checks and cutting manual regression time by ~40% per release.
|
• Built **Cypress E2E and API suites** (Swagger/OpenAPI) from scratch across core product flows, including shared test data builders and fixture libraries—enabling every-commit CI checks and cutting manual regression time by 40% per release.
|
||||||
• Introduced AODA/WCAG accessibility checks (alt text, keyboard nav, contrast) into Bitbucket CI gates, preventing accessibility regressions across web and mobile releases.
|
• Introduced **AODA/WCAG accessibility checks** (alt text, keyboard nav, contrast) into Bitbucket CI gates, preventing accessibility regressions across web and mobile releases.
|
||||||
• Automated test-environment provisioning with Ansible, producing disposable, repeatable setups that shortened spin-up time and eliminated pre-regression drift.
|
• Automated test-environment provisioning with **Ansible**, producing disposable, repeatable setups that shortened spin-up time and eliminated pre-regression drift.
|
||||||
• Partnered with engineering and product on defect triage, risk-based prioritization, and pragmatic quality gates without blocking incremental delivery.
|
• Partnered with engineering and product on **defect triage**, risk-based prioritization, and pragmatic quality gates without blocking incremental delivery.
|
||||||
|
|
||||||
- company: Attabotics
|
- company: Attabotics
|
||||||
position: QA Automation Developer
|
position: QA Automation Developer
|
||||||
timeperiod: September 2021 - May 2022
|
timeperiod: September 2021 – May 2022
|
||||||
remote: true
|
remote: true
|
||||||
employment: contract
|
employment: contract
|
||||||
location: Calgary, Alberta, Canada
|
location: Calgary, Alberta, Canada
|
||||||
|
stack: "SpecFlow, Gherkin, C#, .NET, Azure, Docker, SQL Server"
|
||||||
description: |
|
description: |
|
||||||
• Maintained 3,500+ SpecFlow/Gherkin scenarios with C# in .NET/Azure, owning flaky-test triage and keeping daily build stability above ~90% across the suite.
|
• **Mentored developers** on **testable design** and BDD best practices, improving scenario quality and reducing review churn on test PRs.
|
||||||
• Practiced left-shift QA in a large Agile team: co-authored scenarios with developers early in sprint, tightened Given/When/Then clarity, and shortened feedback from story to green build.
|
• Maintained **3,500+ SpecFlow/Gherkin scenarios** with C# in .NET/Azure, owning flaky-test triage and keeping daily build stability above 90% across the suite.
|
||||||
• Stood up Docker-based local and CI-aligned test environments; used SQL Server for data setup, assertions, and traceability across integrated warehouse-automation workflows.
|
• Caught defects earlier and tightened **Given/When/Then** clarity by co-authoring scenarios with developers early in the sprint, shortening feedback from story to green build in a large Agile team (**left-shift QA**).
|
||||||
|
• Stood up **Docker-based local and CI-aligned test environments**; used SQL Server for data setup, assertions, and traceability across integrated warehouse-automation workflows.
|
||||||
|
|
||||||
- company: Levkin Inc.
|
- company: Levkin
|
||||||
position: Senior Software Developer
|
position: Senior Software Developer
|
||||||
timeperiod: October 2020 - August 2021
|
timeperiod: October 2020 – August 2021
|
||||||
remote: true
|
remote: true
|
||||||
employment: contract
|
employment: contract
|
||||||
location: Vaughan, Ontario, Canada
|
location: Vaughan, Ontario, Canada
|
||||||
|
stack: "Playwright, GitLab CI/CD, Terraform, AWS S3, Grafana"
|
||||||
description: |
|
description: |
|
||||||
• Built reusable Playwright building blocks with deterministic patterns, eliminating arbitrary waits and measurably reducing suite flakiness.
|
• Built reusable **Playwright** patterns that replaced arbitrary waits, measurably reducing suite flakiness and stabilizing CI runs.
|
||||||
• Audited and refactored legacy test and UI code; documented testing strategy and shared patterns across the team.
|
• Audited and refactored legacy test and UI code; documented testing strategy and shared patterns across the team.
|
||||||
• Optimized GitLab CI/CD pipelines for speed and reliability; piped test and pipeline metrics into Grafana dashboards for release visibility.
|
• Optimized **GitLab CI/CD** pipelines for speed and reliability; piped test and pipeline metrics into Grafana dashboards for release visibility.
|
||||||
• Provisioned AWS (S3) environments with Terraform, validated end-to-end, and promoted to dev via the team's release procedure.
|
• Provisioned AWS (S3) environments with **Terraform**, validated end-to-end, and promoted to dev via the team's release procedure.
|
||||||
• Ongoing: self-hosted infrastructure lab and local-GPU AI assistant under the Levkin brand—see Projects section.
|
• Introduced **page object patterns and shared utility layer** that cut new-test authoring time and improved cross-team consistency.
|
||||||
|
• Ongoing: self-hosted infrastructure lab and local-GPU AI projects—see Projects section.
|
||||||
|
|
||||||
- company: Accountants Templates Inc.
|
- company: EARLIER CAREER
|
||||||
position: Senior Software Developer
|
timeperiod: 2009 – 2020
|
||||||
timeperiod: August 2019 - August 2020
|
|
||||||
remote: true
|
|
||||||
employment: contract
|
|
||||||
location: Calgary, Alberta, Canada
|
|
||||||
description: |
|
|
||||||
• Owned CaseWare/CaseView template delivery including compliance updates, standards-driven releases, and documentation for internal and client use.
|
|
||||||
• Reviewed software for improvements and implemented recommendations and collaborated with support on reported issues.
|
|
||||||
• Automated build and packaging workflows with scripting, compressing roughly eight hours of manual release effort down to under two minutes per cycle.
|
|
||||||
|
|
||||||
- company: MNP LLP
|
- company: CaseWare International
|
||||||
position: Senior Application Developer 2
|
|
||||||
timeperiod: August 2017 - June 2019
|
|
||||||
remote: true
|
|
||||||
employment: full-time
|
|
||||||
location: Toronto, Ontario, Canada
|
|
||||||
description: |
|
|
||||||
• Developed and maintained C# / .NET / .NET Core applications integrating with CaseWare/CaseView; extended with JavaScript where specs required.
|
|
||||||
• Contributed automation strategy and hands-on Selenium/Cucumber work; managed Jenkins triggers, Cucumber reporting, and Azure DevOps pipelines.
|
|
||||||
|
|
||||||
- company: CaseWare International Inc.
|
|
||||||
position: Software Developer
|
|
||||||
timeperiod: August 2006 - June 2017
|
|
||||||
remote: hybrid
|
remote: hybrid
|
||||||
employment: full-time
|
employment: full-time
|
||||||
location: Toronto, Ontario, Canada
|
stack: "C#, .NET, SQL Server, SilkTest, Agile/Scrum"
|
||||||
description: |
|
description: |
|
||||||
• Delivered features, defect fixes, and client templates (JavaScript, HTML, YUI, jQuery, CSS) for global financial/audit systems; automated validation with SilkTest.
|
11 years of feature development, client templates, and SilkTest automation for global audit/financial systems; mentored juniors.
|
||||||
• Mentored junior developers on conventions, debugging, and code review; built reusable JS libraries; Agile Scrum, Jira, Git.
|
|
||||||
|
- company: MNP
|
||||||
|
remote: true
|
||||||
|
employment: full-time
|
||||||
|
stack: "C#, .NET Core, Selenium, Cucumber, Jenkins, Azure DevOps"
|
||||||
|
description: |
|
||||||
|
.NET development on CaseWare/CaseView with Selenium/Cucumber automation across Jenkins and Azure DevOps pipelines.
|
||||||
|
|
||||||
|
- company: Accountants Templates
|
||||||
|
remote: true
|
||||||
|
employment: contract
|
||||||
|
stack: "CaseWare/CaseView, build automation, scripting"
|
||||||
|
description: |
|
||||||
|
CaseWare/CaseView template delivery with build/packaging scripts that cut release effort from ~8 hours to under 2 minutes.
|
||||||
|
|
||||||
- company: ROLI Consulting
|
- company: ROLI Consulting
|
||||||
position: Web/Application Developer
|
|
||||||
timeperiod: January 2001 - July 2012
|
|
||||||
remote: true
|
remote: true
|
||||||
employment: part-time
|
employment: contract
|
||||||
location: Vaughan, Ontario, Canada
|
stack: "Python, Twilio API, multi-stack web"
|
||||||
description: |
|
description: |
|
||||||
• Voice broadcasting and SMS service (Python, Twilio API); websites across multiple stacks; technical consulting for nonprofits and SMBs.
|
Voice/SMS broadcasting service plus multi-stack web and technical consulting for nonprofits and SMBs.
|
||||||
|
|
||||||
- company: Earlier Career
|
|
||||||
timeperiod: May 2005 - August 2006
|
|
||||||
sub_companies:
|
|
||||||
- name: Kaboose Inc.
|
|
||||||
remote: false
|
|
||||||
employment: contract
|
|
||||||
- name: Coutts Information Services
|
|
||||||
remote: false
|
|
||||||
employment: full-time
|
|
||||||
- name: EDS / Scotiabank
|
|
||||||
remote: false
|
|
||||||
employment: co-op
|
|
||||||
description: |
|
|
||||||
• QA automation with QTP/Quality Center, cross-browser and end-to-end testing, and UAT support (Kaboose); Java/J2EE development (Coutts); Informatica ETL co-op on AIX/DB2 (EDS/Scotiabank).
|
|
||||||
|
|
||||||
education: []
|
education: []
|
||||||
|
|
||||||
# Skills: grouped, deduped, ATS-friendly. Two-column grid in green template.
|
# Skills: grouped, deduped, ATS-friendly. Two-column grid in green template.
|
||||||
skills:
|
skills:
|
||||||
- name: "Test automation: Playwright, Cypress, Selenium, SilkTest; UI, API, mobile, cross-browser; page object model, BDD"
|
- name: "**Test automation**: Playwright, Cypress, Selenium, SilkTest; UI, API, mobile, cross-browser; page object model, BDD"
|
||||||
level: 96
|
level: 96
|
||||||
- name: "Languages & frameworks: TypeScript, JavaScript, C#, .NET, Python, Java, Bash/Shell, Node.js, ASP.NET, Spring Boot, HTML/CSS"
|
- name: "**Domains**: regulated iGaming (real-money), audit & financial software, warehouse automation, accessibility-compliant web (AODA/WCAG)"
|
||||||
|
level: 88
|
||||||
|
- name: "**Languages & frameworks**: TypeScript, JavaScript, C#, .NET, Python, Java, Bash/Shell, Node.js, ASP.NET, Spring Boot, HTML/CSS"
|
||||||
level: 92
|
level: 92
|
||||||
- name: "CI/CD & DevOps: GitHub Actions, GitLab, Bitbucket, Jenkins, Azure DevOps; Git, Terraform, Ansible, Docker, SonarQube, self-hosted runners"
|
- name: "**CI/CD & DevOps**: GitHub Actions, GitLab, Bitbucket, Jenkins, Azure DevOps; Git, Terraform, Ansible, Docker, SonarQube, self-hosted runners"
|
||||||
level: 92
|
level: 92
|
||||||
- name: "Cloud & infra: AWS (Lambda, S3), Azure, GCP; Linux administration, Proxmox, Caddy, TrueNAS, Vaultwarden"
|
- name: "**Cloud & infra**: AWS (Lambda, S3), Azure, GCP; Linux administration, Proxmox, Caddy, TrueNAS, Vaultwarden"
|
||||||
level: 84
|
level: 84
|
||||||
- name: "Observability & performance: Grafana, Prometheus, Sentry, DataDog, Artillery, k6, JMeter, metrics & logging"
|
- name: "**Observability & performance**: Grafana, Prometheus, Sentry, DataDog, Artillery, k6, JMeter, metrics & logging"
|
||||||
level: 86
|
level: 86
|
||||||
- name: "Data & domain: PostgreSQL, SQL Server, MySQL, DB2, Informatica/ETL; CaseWare/CaseView, audit & financial software"
|
- name: "**Data & domain**: PostgreSQL, SQL Server, MySQL, DB2, Informatica/ETL"
|
||||||
level: 78
|
level: 78
|
||||||
- name: "QA practices: BDD (SpecFlow, Cucumber, Gherkin), API testing (Postman, Swagger/OpenAPI), accessibility (AODA/WCAG), risk-based testing and prioritization, defect triage, quality gates, flaky-suite stabilization, test data and fixtures, Agile/Scrum, Jira, shift-left QA"
|
- name: "**QA testing types**: unit, integration, regression, smoke, exploratory, load, stress, end-to-end; API testing (Postman); accessibility (AODA/WCAG)"
|
||||||
level: 90
|
level: 90
|
||||||
- name: "Leadership & collaboration: mentoring developers, test strategy and documentation, partnering with product and engineering on quality planning, release risk, and pragmatic gates without blocking delivery"
|
- name: "**QA process**: BDD, risk-based prioritization, defect triage, quality gates, flaky-suite stabilization, shift-left QA, Agile/Scrum, Jira"
|
||||||
level: 84
|
level: 88
|
||||||
- name: "AI & LLM tooling: AI-assisted engineering with Cursor, GitHub Copilot, Perplexity, Claude Code, ChatGPT, Windsurf, and Amazon Q Developer; GenAI-assisted test design and refactors; prompt and workflow validation; privacy-first local LLM usage; MCP servers and agent-based automation"
|
- name: "**AI & LLM tooling**: AI-assisted engineering with Cursor and Claude Code; privacy-first local LLM usage; MCP servers and agent-based automation; GenAI-assisted test design and refactors."
|
||||||
level: 82
|
level: 82
|
||||||
|
|
||||||
knowledge:
|
knowledge:
|
||||||
|
|
||||||
projects:
|
projects:
|
||||||
- name: Levkin — Self-Hosted Infrastructure Lab
|
- name: "Self-Hosted Infrastructure Lab"
|
||||||
|
stack: "Proxmox, Ansible, Caddy, TrueNAS, Gitea, SonarQube"
|
||||||
description: |
|
description: |
|
||||||
• Proxmox-based homelab (VMs/LXC): Gitea + CI runners, Vaultwarden, Vikunja, Uptime Kuma, Mailcow, Listmonk, n8n, SonarQube—provisioned via Ansible, Caddy edge TLS, TrueNAS backups.
|
• Proxmox homelab (VMs/LXC) with Gitea, CI runners, Vaultwarden, Uptime Kuma, Mailcow, SonarQube—**Ansible**-provisioned, Caddy TLS, TrueNAS backups; patterns directly informed production DevOps decisions.
|
||||||
• Full Linux admin: multi-domain DNS, firewall hardening, monitoring, and repeatable deploys; patterns from this lab directly informed production DevOps decisions in later roles.
|
|
||||||
|
|
||||||
- name: Levkin — Privacy-First Local AI Assistant
|
- name: "sdetProfile — Portfolio as Playwright Report"
|
||||||
|
stack: "HTML, CSS, vanilla JS, Playwright (tests), ESLint, Stylelint"
|
||||||
description: |
|
description: |
|
||||||
• Tool-using assistant wired into mail and calendars (triage, drafts, scheduling) on local GPU inference—prompts and context stay on-LAN, no SaaS LLMs.
|
• Zero-framework personal portfolio styled as a **Playwright** test runner—sidebar explorer, editor tabs, trace/network/source panels, tag filtering, keyboard shortcuts, and theme cycling (dark/light/WCAG AAA); 37 real Playwright specs verify the live site.
|
||||||
• Composable with homelab identity, TLS, secrets, and automation so event-driven workflows hand off without third-party model APIs; used daily for personal productivity.
|
|
||||||
|
|
||||||
- name: Levkin — Playwright MCP server
|
- name: "Atlas — Local Voice Agent"
|
||||||
|
stack: "Python, MCP, ASR/TTS, local LLM (RTX), Playwright"
|
||||||
description: |
|
description: |
|
||||||
• Built an MCP server for developers to use from Cursor and other MCP-capable assistants while writing Playwright tests—surfacing selectors, fixtures, and in-repo conventions so generated specs stay aligned with team patterns.
|
• Privacy-focused home voice assistant with on-device AI transcription and tool use—**Python**, local GPU inference, no third-party model APIs; wired into calendar and home automation.
|
||||||
|
|
||||||
|
- name: "AtAnyRate — Event-Driven Pricing"
|
||||||
|
stack: "Python, Playwright, Telegram Bot API, Ticketmaster/SeatGeek APIs, Docker"
|
||||||
|
description: |
|
||||||
|
• **Python** app that identifies Toronto events likely to spike Airbnb demand, sends Telegram alerts, and optionally adjusts nightly prices via **Playwright** browser automation.
|
||||||
|
|
||||||
|
- name: "LLM Council — Multi-Model Chat UI"
|
||||||
|
stack: "Python, FastAPI, httpx, React, Vite, Ollama/vLLM"
|
||||||
|
description: |
|
||||||
|
• Local web UI that fans each prompt to multiple LLMs and presents side-by-side responses—**Python** backend, diverse model voting for higher-confidence answers.
|
||||||
|
|
||||||
hobbies: []
|
hobbies: []
|
||||||
|
|
||||||
@ -168,9 +172,9 @@ contributions: []
|
|||||||
|
|
||||||
contact:
|
contact:
|
||||||
email: idobkin@gmail.com
|
email: idobkin@gmail.com
|
||||||
phone: +1 (647) 987-2792
|
phone:
|
||||||
street:
|
street:
|
||||||
city: Toronto, Ontario, Canada
|
city:
|
||||||
website: https://www.linkedin.com/in/idobkin/
|
website: https://www.linkedin.com/in/idobkin/
|
||||||
website_label: LinkedIn
|
website_label: LinkedIn
|
||||||
github: https://git.levkin.ca
|
github: https://git.levkin.ca
|
||||||
|
|||||||
144
resume/levit.yml
Normal file
144
resume/levit.yml
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
/* #*/ export const PERSON = `
|
||||||
|
# experience: timeperiod = dates; location = separate line; description: | = one bullet per line.
|
||||||
|
# Optional: remote: true | false | hybrid; employment: contract | full-time | co-op
|
||||||
|
# Build: RESUME_NAME=levit npm run dev | PDF: RESUME_NAME=levit npm run export
|
||||||
|
|
||||||
|
name:
|
||||||
|
first: BORIS
|
||||||
|
middle:
|
||||||
|
last: LEVIT
|
||||||
|
|
||||||
|
position: Enterprise / Security / Data / AI Architect (CISO Track)
|
||||||
|
|
||||||
|
about: "CISSP-certified Enterprise Security Architect and DevSecOps/MLSecOps leader with 20+ years delivering security modernization for public sector and regulated enterprises. Designs and operationalizes zero-trust and cloud-native security platforms across AWS/Azure/GCP, integrating SIEM/SOAR, EDR/XDR, identity governance/PAM, and data/AI security. Strong track record producing actionable TRAs/PIAs, executive-ready roadmaps, and operating models that connect NIST/ISO frameworks to measurable controls, telemetry, and incident readiness."
|
||||||
|
|
||||||
|
core_strengths: "Zero Trust & SSE/SASE; cloud security architecture; SIEM/SOAR modernization; DevSecOps & secure CI/CD; IAM/PAM & IGA; AI/LLM security & governance; threat modeling & risk assessments; public-sector security standards."
|
||||||
|
|
||||||
|
birth:
|
||||||
|
year:
|
||||||
|
location: Toronto, Ontario, Canada
|
||||||
|
|
||||||
|
experience:
|
||||||
|
- company: InTunnel Monitor
|
||||||
|
position: Enterprise / Security / Data / AI Architect, DevSecOps Lead
|
||||||
|
timeperiod: September 2017 - Current
|
||||||
|
remote: hybrid
|
||||||
|
employment: contract
|
||||||
|
location: Toronto, ON, Canada
|
||||||
|
description: |
|
||||||
|
• Led architecture and delivery for cybersecurity modernization engagements across federal/provincial clients and regulated enterprises (including OSFI, GC DND, and Ontario Securities Commission).
|
||||||
|
• Designed and operationalized zero-trust and SSE/SASE solutions across hybrid and multi-cloud (AWS/Azure/GCP), including ZTNA, CASB, SWG, FWaaS, and SaaS posture management patterns.
|
||||||
|
• Modernized SecOps by integrating SIEM + SOAR with EDR/XDR/DLP and threat intelligence (ArcSight/Splunk/Sentinel + SOAR patterns), enabling automated playbooks for phishing, identity events, and vulnerability response.
|
||||||
|
• Delivered TRAs/PIAs, security roadmaps, and capability assessments aligned to NIST CSF v2, CIS Controls v8, ISO 27001/27701, ITSG-33, and MITRE ATT&CK; advised on data residency, privacy-by-design, and GRC.
|
||||||
|
• Implemented DevSecOps and continuous compliance practices: secure CI/CD, IaC (Terraform/CloudFormation), container/Kubernetes security, SAST/DAST/SCA, and security KPI/telemetry reporting.
|
||||||
|
• Architected AI/LLM security posture and delivery approach: AI footprint mapping, AI-specific threat modeling, guardrails/safety, governance, and observability—supporting secure RAG and agentic patterns and aligning to NIST AI RMF.
|
||||||
|
• Designed data security and governance for modern analytics platforms (Purview, Databricks, Snowflake/BigQuery patterns), including cataloging, access control, and sensitive-data handling across pipelines.
|
||||||
|
• Delivered IAM/PAM and identity governance work (CyberArk, Okta/Entra ID patterns, access reviews), supporting least privilege and privileged session controls.
|
||||||
|
• Supported co-managed SOC / MSSP transitions by defining operating models, SLAs, metrics, reporting, and Tier-3 incident investigation processes.
|
||||||
|
• Communicated risk and architecture decisions to executives and technical teams via briefings, risk registers, and hands-on enablement workshops.
|
||||||
|
|
||||||
|
- company: HPE / DXC (SOC / MSSP)
|
||||||
|
position: Security Incident Analyst (Tier 2/3)
|
||||||
|
timeperiod: June 2015 - August 2017
|
||||||
|
remote: false
|
||||||
|
employment: full-time
|
||||||
|
location: Toronto, ON, Canada
|
||||||
|
description: |
|
||||||
|
• Led incident triage, investigation, and remediation across financial and public-sector environments using ArcSight and network/app security controls (IDS/WAF).
|
||||||
|
• Developed SIEM content (queries, dashboards, correlation use cases) and incident playbooks; delivered RCA reporting and executive recommendations.
|
||||||
|
• Performed threat hunting and forensics (pcap analysis, IOC enrichment, CTI workflows) and automated analysis tasks with scripting.
|
||||||
|
|
||||||
|
- company: Metsuke
|
||||||
|
position: Security Consultant / Architect
|
||||||
|
timeperiod: February 2012 - May 2015
|
||||||
|
remote: hybrid
|
||||||
|
employment: contract
|
||||||
|
location: Toronto, ON, Canada
|
||||||
|
description: |
|
||||||
|
• Delivered security architecture, IAM/PAM remediation, and risk/vulnerability assessments for enterprise and public-sector clients.
|
||||||
|
• Implemented secure CI/CD practices and integration hardening across infrastructure and applications; produced TRAs/PIAs and compliance-aligned documentation.
|
||||||
|
• Designed and supported SIEM/IAM programs and logging/monitoring improvements for operational resilience; authored SOPs and technical documentation for SOC/hosting teams.
|
||||||
|
|
||||||
|
- company: TD Bank
|
||||||
|
position: Senior Security Specialist
|
||||||
|
timeperiod: August 2010 - September 2011
|
||||||
|
remote: false
|
||||||
|
employment: full-time
|
||||||
|
location: Toronto, ON, Canada
|
||||||
|
description: |
|
||||||
|
• Led remediation after SOX/PCI audits across access control, privileged access, logging/monitoring, and secure system design for legacy enterprise platforms.
|
||||||
|
• Supported SIEM deployment and compliance reporting; improved audit trails and incident readiness across on-prem environments.
|
||||||
|
• Partnered with audit, infrastructure, and application teams to translate requirements into sustainable controls and operating procedures.
|
||||||
|
|
||||||
|
- company: Earlier Career (Selected)
|
||||||
|
position: Security Architect / Developer / Manager (multiple roles)
|
||||||
|
timeperiod: 1998 - 2010
|
||||||
|
description: |
|
||||||
|
• Security architecture, consulting, and leadership roles across ISPs, financial services, and technology firms (including Research In Motion/BlackBerry, Q1 Labs/IBM QRadar early work, N-Dimension SCADA security integration, and others).
|
||||||
|
• Led/owned projects spanning incident response, vulnerability assessment, secure platform engineering, IAM, and governance artifacts aligned with ISO/NIST/OWASP and regulated environments.
|
||||||
|
• IT/OT and SCADA security integration work (Modbus/DNP3, ruggedized platforms, secure gateways, and monitoring) supporting critical infrastructure and utility contexts.
|
||||||
|
|
||||||
|
education:
|
||||||
|
- degree: MS Diploma (evaluated by York University, 2002)
|
||||||
|
description: Moscow Institute of Electronic Techniques
|
||||||
|
|
||||||
|
certifications:
|
||||||
|
- name: CISSP
|
||||||
|
- name: "Cloud Security Alliance — Agentic AI Security Summit (2025)"
|
||||||
|
- name: "Cloud Security Alliance — Virtual Cloud Non-Human Identities (NHI) Summit (2025)"
|
||||||
|
- name: "LangChain Academy — Introduction to LangGraph (2025)"
|
||||||
|
- name: "DND/CAF Architecture Framework (DNDAF) & QualiWare EA Toolset (2023)"
|
||||||
|
- name: "ISC2 — Incident Management: Preparation and Response (2020)"
|
||||||
|
- name: "ISC2 — DevSecOps: Integrating Security into DevOps (2018)"
|
||||||
|
- name: "ArcSight ESM / SmartConnector Foundations & Administration (2016)"
|
||||||
|
- name: "Ongoing professional training (LinkedIn Learning; see LinkedIn profile for full list)"
|
||||||
|
|
||||||
|
skills:
|
||||||
|
- name: "Security architecture: Zero Trust, SSE/SASE, network & endpoint security, cloud-native security, security reference architectures"
|
||||||
|
level: 96
|
||||||
|
- name: "Cloud platforms: AWS, Azure, GCP (IAM, network segmentation, logging, workload protection, KMS/secrets, cloud posture management)"
|
||||||
|
level: 92
|
||||||
|
- name: "SecOps & tooling: SIEM/SOAR, detection engineering, incident response, threat hunting, TI integration (ArcSight, Splunk, Sentinel/KQL, QRadar, Elastic, LogRhythm, Datadog; Cortex/XSIAM)"
|
||||||
|
level: 94
|
||||||
|
- name: "DevSecOps & security testing: secure CI/CD (Jenkins/GitLab/GitHub), IaC (Terraform/CloudFormation), Kubernetes/Docker security, SAST/DAST/SCA, SBOM, WAF/API testing (Burp/Postman/Nmap/Nessus/Qualys/gotestwaf), policy-as-code (OPA)"
|
||||||
|
level: 92
|
||||||
|
- name: "Identity & privileged access: IGA, RBAC/ABAC, SSO (SAML/OIDC/OAuth2), directory services (AD/LDAP), PAM (CyberArk), access reviews and lifecycle controls"
|
||||||
|
level: 90
|
||||||
|
- name: "Risk, privacy & compliance: NIST CSF/800-53, CIS Controls, ISO 27001/27701/42001, ITSG-33, STRIDE/PASTA, MAESTRO, MITRE ATT&CK/ATLAS/D3FEND, privacy-by-design, TRA/PIA"
|
||||||
|
level: 92
|
||||||
|
- name: "AI / LLM security: NIST AI RMF, OWASP Top 10 for LLM/Agentic AI, secure RAG/agentic patterns, model/data protection, guardrails, evals/observability, jailbreak prevention and red teaming"
|
||||||
|
level: 88
|
||||||
|
- name: "AI governance & regulation: EU AI Act, Bill C-27, GDPR; model risk management, data residency, and compliance-by-design for AI/data platforms"
|
||||||
|
level: 84
|
||||||
|
- name: "Enterprise architecture: TOGAF, SABSA, Zachman; public-sector architecture practices (DNDAF familiarity) and traceable architecture decisions"
|
||||||
|
level: 82
|
||||||
|
- name: "Data & platforms: Snowflake, Databricks, Microsoft Purview, BigQuery, Synapse/Fabric patterns; lakehouse/medallion concepts; governance, cataloging, and access control"
|
||||||
|
level: 82
|
||||||
|
- name: "Programming & automation: Python, Bash, Go; JSON/YAML; scripting for log processing, reporting, and security telemetry"
|
||||||
|
level: 82
|
||||||
|
|
||||||
|
projects:
|
||||||
|
- name: "Selected Case Studies (SOC / SecOps / Architecture)"
|
||||||
|
description: |
|
||||||
|
• Web-attack “false positive” triage (banking): validated “blocked” actions end-to-end across ArcSight + security controls, performed packet/header analysis to attribute traffic to an authorized third-party test; closed ticket with evidence and trained analysts to repeat the method.
|
||||||
|
• Web-attack “true positive” validation (banking): confirmed distributed hostile sources, verified exploit applicability against the client’s patched stack, and supported containment by identifying/closing an exposed infrastructure path while documenting risk and residual exposure.
|
||||||
|
• SSH brute-force investigation: traced an internal-source alert to a perimeter exposure by resolving NAT/logging ambiguity; escalated with clear evidence and drove closure of an internet-exposed SSH tunnel through stakeholder follow-ups.
|
||||||
|
• Detection engineering QA: diagnosed missing alerts to a subtle filter mismatch (leading whitespace) by comparing connector/base-event fields to use-case logic; handed off a precise fix to the content engineering team.
|
||||||
|
• Critical-infrastructure signature validation (SCADA/Modbus): recreated malicious traffic in a lab, identified errors in 3 IDS signatures that the industry had accepted, corrected them, and reported upstream—improving detection accuracy and establishing a repeatable validation approach.
|
||||||
|
|
||||||
|
knowledge: []
|
||||||
|
hobbies: []
|
||||||
|
contributions: []
|
||||||
|
|
||||||
|
contact:
|
||||||
|
email: boris.levit@yahoo.com
|
||||||
|
phone: +1 (416) 804-7520
|
||||||
|
street:
|
||||||
|
city: Toronto, ON M2R 3N8, Canada
|
||||||
|
website: "https://www.linkedin.com/in/boris-levit-025a88"
|
||||||
|
website_label: LinkedIn
|
||||||
|
github: ""
|
||||||
|
github_label: ""
|
||||||
|
|
||||||
|
lang: en
|
||||||
|
`
|
||||||
@ -1,18 +1,22 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="resume" id="template">
|
<div class="resume ai-bw-tpl" :id="rootId" :style="bodyFontStyle">
|
||||||
<div id="resume-header">
|
<div id="resume-header">
|
||||||
<div id="header-left">
|
<div id="header-left">
|
||||||
<h2 id="position">{{person.position}}</h2>
|
<h2 id="position">{{person.position}}</h2>
|
||||||
<h1 id="name">{{person.name.first}} {{person.name.last}}</h1>
|
<h1 id="name">{{person.name.first}} {{person.name.last}}</h1>
|
||||||
<div id="info-flex">
|
<div id="info-flex" aria-label="Contact information">
|
||||||
<span id="email"><a :href='"mailto:" + person.contact.email'>
|
<span
|
||||||
<i class="fa fa-envelope" aria-hidden="true"></i>{{ person.contact.email }}</a></span>
|
v-for="(item, idx) in headerInfoItems()"
|
||||||
<span id="phone"><i class="fa fa-phone-square" aria-hidden="true"></i> {{person.contact.phone}}</span>
|
:key="item.key"
|
||||||
<span v-if="person.contact.website" id="website"><a :href="contactLinks.website"><i class="fa fa-linkedin" aria-hidden="true"></i>{{ person.contact.website_label || person.contact.website }}</a></span>
|
class="header-info-item">
|
||||||
<span v-if="person.contact.github" id="github"><a :href="contactLinks.github"><i class="fa fa-code-fork" aria-hidden="true"></i>{{ person.contact.github_label || 'Git' }}</a></span>
|
<!-- no separator -->
|
||||||
<span v-if="person.contact.github_profile && contactLinks.github_profile" id="github-profile"><a :href="contactLinks.github_profile"><i class="fa fa-github" aria-hidden="true"></i>{{ person.contact.github_profile_label || ('@' + String(person.contact.github_profile).replace(/^@/, '')) }}</a></span>
|
<a v-if="item.href" :href="item.href">
|
||||||
<span v-if="person.contact.personal_site" id="personal-site"><a :href="contactLinks.personal_site"><i class="fa fa-globe" aria-hidden="true"></i>{{ person.contact.personal_site_label || person.contact.personal_site }}</a></span>
|
<i :class="item.icon" aria-hidden="true"></i>{{ item.text }}
|
||||||
<span v-if="person.birth && person.birth.location" id="location"><i class="fa fa-map-marker" aria-hidden="true"></i> {{person.birth.location}}</span>
|
</a>
|
||||||
|
<span v-else>
|
||||||
|
<i :class="item.icon" aria-hidden="true"></i>{{ item.text }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="header-right">
|
<div id="header-right">
|
||||||
@ -20,9 +24,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="resume-about" v-if="person.about">
|
<div id="resume-about" v-if="person.about">
|
||||||
<h2>{{ lang.about }}</h2>
|
<h2 id="profile-title">Profile</h2>
|
||||||
<p>{{person.about}}</p>
|
<div class="spacer"></div>
|
||||||
<p v-if="person.core_strengths" class="core-strengths"><span class="core-strengths-label">Core strengths:</span> {{person.core_strengths}}</p>
|
<p v-html="renderBold(person.about)"></p>
|
||||||
|
<p v-if="person.core_strengths" class="core-strengths">
|
||||||
|
<span class="core-strengths-label">Core strengths:</span>
|
||||||
|
<span v-html="renderBold(person.core_strengths)"></span>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div id="resume-body">
|
<div id="resume-body">
|
||||||
<div id="experience-container">
|
<div id="experience-container">
|
||||||
@ -36,77 +44,121 @@
|
|||||||
v-for="item in workArrangementLegendItems()"
|
v-for="item in workArrangementLegendItems()"
|
||||||
:key="item.key"
|
:key="item.key"
|
||||||
class="experience-legend-item">
|
class="experience-legend-item">
|
||||||
<i :class="item.icon" aria-hidden="true"></i>
|
<i v-if="item.icon" :class="item.icon" aria-hidden="true"></i>
|
||||||
|
<span v-if="item.text" class="experience-legend-text-icon" aria-hidden="true">{{ item.text }}</span>
|
||||||
<span class="experience-legend-text">{{ item.label }}</span>
|
<span class="experience-legend-text">{{ item.label }}</span>
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
<div class="spacer"></div>
|
<div class="spacer"></div>
|
||||||
<div class="experience" v-for="experience in person.experience" :key="experience.company">
|
<div :class="['experience', { 'experience-section': !experience.position && !experience.location && !experience.stack, 'experience-compact': !experience.position && (experience.location || experience.stack) }]" v-for="experience in person.experience" :key="experience.company">
|
||||||
<h2 class="company-row">
|
<template v-if="!experience.position && !experience.location && !experience.stack">
|
||||||
<span class="company-primary">
|
<h2 class="section-subheading">
|
||||||
<span :class="['company', experience.sub_companies && 'company-section-label']">{{experience.company}}</span><!--
|
{{experience.company}}<span v-if="experienceDateRange(experience)" class="section-subheading-date"> · {{experienceDateRange(experience)}}</span>
|
||||||
--><span
|
</h2>
|
||||||
v-if="experienceWorkBadges(experience).length"
|
<div class="spacer"></div>
|
||||||
class="exp-name-icon-gutter"
|
</template>
|
||||||
aria-hidden="true"></span><!--
|
<template v-else-if="!experience.position">
|
||||||
--><span
|
<h2 class="company-row">
|
||||||
v-if="experienceWorkBadges(experience).length"
|
<span class="company-primary">
|
||||||
class="experience-icons"
|
<span class="company">{{experience.company}}</span><!--
|
||||||
aria-hidden="true">
|
|
||||||
<span
|
|
||||||
v-for="b in experienceWorkBadges(experience)"
|
|
||||||
:key="b.key"
|
|
||||||
class="exp-icon-cell">
|
|
||||||
<i
|
|
||||||
:class="b.icon"
|
|
||||||
:title="b.label"></i>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<span v-if="experienceLocation(experience)" class="company-location">{{ experienceLocation(experience) }}</span>
|
|
||||||
</h2>
|
|
||||||
<p class="job-info" v-if="!experience.sub_companies">
|
|
||||||
<span class="job-title">{{experience.position}}</span>
|
|
||||||
<span class="experience-timeperiod">{{ experienceDateRange(experience) }}</span>
|
|
||||||
</p>
|
|
||||||
<p class="job-info sub-companies-line" v-if="experience.sub_companies && experience.sub_companies.length">
|
|
||||||
<span class="sub-companies-list">
|
|
||||||
<span
|
|
||||||
v-for="(sub, si) in experience.sub_companies"
|
|
||||||
:key="si"
|
|
||||||
class="sub-company-entry">
|
|
||||||
<span class="sub-company-name">{{ sub.name }}</span><!--
|
|
||||||
--><span
|
--><span
|
||||||
v-if="experienceWorkBadges(sub).length"
|
v-if="experienceWorkBadges(experience).length"
|
||||||
class="sub-icon-gutter"
|
class="exp-name-icon-gutter"
|
||||||
aria-hidden="true"></span><!--
|
aria-hidden="true"></span><!--
|
||||||
--><span
|
--><span
|
||||||
v-for="b in experienceWorkBadges(sub)"
|
v-if="experienceWorkBadges(experience).length"
|
||||||
:key="b.key"
|
class="experience-icons"
|
||||||
class="sub-icon-cell"
|
aria-hidden="true">
|
||||||
aria-hidden="true"><i
|
<span
|
||||||
:class="b.icon"
|
v-for="b in experienceWorkBadges(experience)"
|
||||||
:title="b.label"></i></span>
|
:key="b.key"
|
||||||
<span
|
class="exp-icon-cell">
|
||||||
v-if="si < experience.sub_companies.length - 1"
|
<i
|
||||||
class="sub-company-sep"
|
:class="b.icon"
|
||||||
aria-hidden="true">·</span>
|
:title="b.label"></i>
|
||||||
|
</span>
|
||||||
|
</span><!--
|
||||||
|
--><span
|
||||||
|
v-if="person.show_stack !== false && experience.stack"
|
||||||
|
class="inline-stack"><span class="stack-brace">{</span> <span v-html="renderBold(experience.stack)"></span> <span class="stack-brace">}</span></span>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</h2>
|
||||||
<span class="experience-timeperiod">{{ experienceDateRange(experience) }}</span>
|
<p
|
||||||
</p>
|
v-if="experienceDescriptionLines(experience).length"
|
||||||
|
class="compact-description"><span v-html="renderBold(stripLeadingBullet(experienceDescriptionLines(experience)[0]))"></span></p>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<h2 class="company-row">
|
||||||
|
<span class="company-primary">
|
||||||
|
<span :class="['company', experience.sub_companies && 'company-section-label']">{{experience.company}}</span><!--
|
||||||
|
--><span
|
||||||
|
v-if="experienceWorkBadges(experience).length"
|
||||||
|
class="exp-name-icon-gutter"
|
||||||
|
aria-hidden="true"></span><!--
|
||||||
|
--><span
|
||||||
|
v-if="experienceWorkBadges(experience).length"
|
||||||
|
class="experience-icons"
|
||||||
|
aria-hidden="true">
|
||||||
|
<span
|
||||||
|
v-for="b in experienceWorkBadges(experience)"
|
||||||
|
:key="b.key"
|
||||||
|
class="exp-icon-cell">
|
||||||
|
<i
|
||||||
|
:class="b.icon"
|
||||||
|
:title="b.label"></i>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span v-if="experienceLocation(experience)" class="company-location">{{ experienceLocation(experience) }}</span>
|
||||||
|
</h2>
|
||||||
|
<p class="job-info" v-if="!experience.sub_companies">
|
||||||
|
<span class="job-title">{{experience.position}}</span>
|
||||||
|
<span class="experience-timeperiod">{{ experienceDateRange(experience) }}</span>
|
||||||
|
</p>
|
||||||
|
<p class="job-info sub-companies-line" v-if="experience.sub_companies && experience.sub_companies.length">
|
||||||
|
<span class="sub-companies-list">
|
||||||
|
<span
|
||||||
|
v-for="(sub, si) in experience.sub_companies"
|
||||||
|
:key="si"
|
||||||
|
class="sub-company-entry">
|
||||||
|
<span class="sub-company-name">{{ sub.name }}</span><!--
|
||||||
|
--><span
|
||||||
|
v-if="experienceWorkBadges(sub).length"
|
||||||
|
class="sub-icon-gutter"
|
||||||
|
aria-hidden="true"></span><!--
|
||||||
|
--><span
|
||||||
|
v-for="b in experienceWorkBadges(sub)"
|
||||||
|
:key="b.key"
|
||||||
|
class="sub-icon-cell"
|
||||||
|
aria-hidden="true"><i
|
||||||
|
:class="b.icon"
|
||||||
|
:title="b.label"></i></span>
|
||||||
|
<span
|
||||||
|
v-if="si < experience.sub_companies.length - 1"
|
||||||
|
class="sub-company-sep"
|
||||||
|
aria-hidden="true">·</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span class="experience-timeperiod">{{ experienceDateRange(experience) }}</span>
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-if="person.show_stack !== false && experience.stack"
|
||||||
|
class="stack-line">
|
||||||
|
<span class="stack-brace">{</span> <span v-html="renderBold(experience.stack)"></span> <span class="stack-brace">}</span>
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
<ul
|
<ul
|
||||||
v-if="experienceDescriptionLines(experience).length"
|
v-if="experience.position && experienceDescriptionLines(experience).length"
|
||||||
class="job-description-list">
|
class="job-description-list">
|
||||||
<li
|
<li
|
||||||
v-for="(line, idx) in experienceDescriptionLines(experience)"
|
v-for="(line, idx) in experienceDescriptionLines(experience)"
|
||||||
:key="idx"
|
:key="idx"
|
||||||
class="list-item-black">{{ stripLeadingBullet(line) }}</li>
|
:class="['list-item-black', { 'bullet-aside': stripLeadingBullet(line).indexOf('Ongoing:') === 0 }]"><span v-html="renderBold(stripLeadingBullet(line))"></span></li>
|
||||||
</ul>
|
</ul>
|
||||||
<ul v-if="experience.list" >
|
<ul v-if="experience.list" >
|
||||||
<li v-for="(item, index) in experience.list" :key="index">
|
<li v-for="(item, index) in experience.list" :key="index">
|
||||||
<span class="list-item-black">
|
<span class="list-item-black">
|
||||||
{{item}}
|
<span v-html="renderBold(item)"></span>
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@ -116,23 +168,21 @@
|
|||||||
<h2 id="projects-title">{{ lang.projects }}</h2>
|
<h2 id="projects-title">{{ lang.projects }}</h2>
|
||||||
<div class="spacer"></div>
|
<div class="spacer"></div>
|
||||||
<div class="project" v-for="project in person.projects" :key="project.name">
|
<div class="project" v-for="project in person.projects" :key="project.name">
|
||||||
<h2 class="project-name">{{ project.name }}</h2>
|
<h2 class="project-name"><span v-html="renderBold(project.name)"></span><!--
|
||||||
<ul
|
--><span
|
||||||
|
v-if="person.show_stack !== false && project.stack"
|
||||||
|
class="inline-stack"><span class="stack-brace">{</span> <span v-html="renderBold(project.stack)"></span> <span class="stack-brace">}</span></span></h2>
|
||||||
|
<p
|
||||||
v-if="experienceDescriptionLines(project).length"
|
v-if="experienceDescriptionLines(project).length"
|
||||||
class="job-description-list">
|
class="compact-description"><span v-html="renderBold(stripLeadingBullet(experienceDescriptionLines(project)[0]))"></span></p>
|
||||||
<li
|
|
||||||
v-for="(line, idx) in experienceDescriptionLines(project)"
|
|
||||||
:key="idx"
|
|
||||||
class="list-item-black">{{ stripLeadingBullet(line) }}</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="education-container" v-if="person.education && person.education.length">
|
<div id="education-container" v-if="person.education && person.education.length">
|
||||||
<h2 id="education-title">{{ lang.education }}</h2>
|
<h2 id="education-title">{{ lang.education }}</h2>
|
||||||
<div class="spacer"></div>
|
<div class="spacer"></div>
|
||||||
<div class="education" v-for="education in person.education" :key="education.degree">
|
<div class="education" v-for="education in person.education" :key="education.degree">
|
||||||
<h2 class="education-description">{{education.description}}</h2>
|
<h2 class="education-description"><span v-html="renderBold(education.description)"></span></h2>
|
||||||
<p><span class="degree">{{education.degree}} | </span><span class="education-timeperiod">{{education.timeperiod}}</span></p>
|
<p><span class="degree"><span v-html="renderBold(education.degree)"></span> | </span><span class="education-timeperiod">{{education.timeperiod}}</span></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="certifications-container" v-if="person.certifications && person.certifications.length">
|
<div id="certifications-container" v-if="person.certifications && person.certifications.length">
|
||||||
@ -142,7 +192,7 @@
|
|||||||
<li
|
<li
|
||||||
v-for="(c, cidx) in person.certifications"
|
v-for="(c, cidx) in person.certifications"
|
||||||
:key="cidx"
|
:key="cidx"
|
||||||
class="list-item-black">{{ certificationLabel(c) }}</li>
|
class="list-item-black"><span v-html="renderBold(certificationLabel(c))"></span></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div id="skills-container" v-if="person.skills && person.skills.length">
|
<div id="skills-container" v-if="person.skills && person.skills.length">
|
||||||
@ -155,10 +205,19 @@
|
|||||||
<div
|
<div
|
||||||
v-for="skill in person.skills"
|
v-for="skill in person.skills"
|
||||||
:key="skill.name"
|
:key="skill.name"
|
||||||
class="skill-cell">{{ skill.name }}</div>
|
class="skill-cell">
|
||||||
|
<template v-if="skillParts(skill.name).label">
|
||||||
|
<span class="skill-label"><span v-html="renderBold(skillParts(skill.name).label)"></span>:</span>
|
||||||
|
<span class="skill-text"> <span v-html="renderBold(skillParts(skill.name).text)"></span></span>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<span v-html="renderBold(skill.name)"></span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -169,7 +228,78 @@ import { getVueOptions } from './options';
|
|||||||
const name = 'ai-bw';
|
const name = 'ai-bw';
|
||||||
const baseOptions = getVueOptions(name);
|
const baseOptions = getVueOptions(name);
|
||||||
export default Vue.component(name, Object.assign({}, baseOptions, {
|
export default Vue.component(name, Object.assign({}, baseOptions, {
|
||||||
|
props: {
|
||||||
|
bodyFont: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
rootId: {
|
||||||
|
type: String,
|
||||||
|
default: 'template'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: Object.assign({}, baseOptions.computed || {}, {
|
||||||
|
bodyFontStyle () {
|
||||||
|
if (!this.bodyFont) return {};
|
||||||
|
return { fontFamily: this.bodyFont };
|
||||||
|
}
|
||||||
|
}),
|
||||||
methods: Object.assign({}, baseOptions.methods || {}, {
|
methods: Object.assign({}, baseOptions.methods || {}, {
|
||||||
|
headerInfoItems () {
|
||||||
|
const items = [];
|
||||||
|
const push = (key, icon, text, href) => {
|
||||||
|
const t = (text === undefined || text === null) ? '' : String(text).trim();
|
||||||
|
if (!t) return;
|
||||||
|
items.push({
|
||||||
|
key,
|
||||||
|
icon,
|
||||||
|
text: t,
|
||||||
|
href: href || ''
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const c = (this.person && this.person.contact) ? this.person.contact : {};
|
||||||
|
const links = this.contactLinks || {};
|
||||||
|
|
||||||
|
push('email', 'fa fa-envelope', c.email, links.email);
|
||||||
|
push('phone', 'fa fa-phone-square', c.phone, links.phone);
|
||||||
|
if (c.website && links.website) {
|
||||||
|
push('website', 'fa fa-linkedin', c.website_label || c.website, links.website);
|
||||||
|
}
|
||||||
|
if (c.github && links.github) {
|
||||||
|
push('github', 'fa fa-code-fork', c.github_label || 'Git', links.github);
|
||||||
|
}
|
||||||
|
if (c.github_profile && links.github_profile) {
|
||||||
|
const u = String(c.github_profile).trim().replace(/^@/, '');
|
||||||
|
push('github_profile', 'fa fa-github', c.github_profile_label || ('@' + u), links.github_profile);
|
||||||
|
}
|
||||||
|
if (c.personal_site && links.personal_site) {
|
||||||
|
push('personal_site', 'fa fa-globe', c.personal_site_label || c.personal_site, links.personal_site);
|
||||||
|
}
|
||||||
|
if (this.person && this.person.birth && this.person.birth.location) {
|
||||||
|
push('location', 'fa fa-map-marker', this.person.birth.location, '');
|
||||||
|
}
|
||||||
|
if (this.person && this.person.work_auth) {
|
||||||
|
push('work_auth', 'header-emoji-flag', this.person.work_auth, '');
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
},
|
||||||
|
escapeHtml (value) {
|
||||||
|
const s = (value === undefined || value === null) ? '' : String(value);
|
||||||
|
return s
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
},
|
||||||
|
renderBold (value) {
|
||||||
|
const base = this.escapeHtml(value);
|
||||||
|
if (!base) return '';
|
||||||
|
let out = base.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
||||||
|
out = out.replace(/\*(.+?)\*/g, '<em>$1</em>');
|
||||||
|
return out;
|
||||||
|
},
|
||||||
experienceLocation (exp) {
|
experienceLocation (exp) {
|
||||||
const loc = exp && exp.location;
|
const loc = exp && exp.location;
|
||||||
return (loc !== undefined && loc !== null && String(loc).trim()) ? String(loc).trim() : '';
|
return (loc !== undefined && loc !== null && String(loc).trim()) ? String(loc).trim() : '';
|
||||||
@ -192,13 +322,35 @@ export default Vue.component(name, Object.assign({}, baseOptions, {
|
|||||||
return lines;
|
return lines;
|
||||||
},
|
},
|
||||||
stripLeadingBullet (line) {
|
stripLeadingBullet (line) {
|
||||||
return String(line).replace(/^[•*-]\s*/, '').trim();
|
return String(line).replace(/^(?:[•-]|\*(?!\*))\s*/, '').trim();
|
||||||
},
|
},
|
||||||
certificationLabel (c) {
|
certificationLabel (c) {
|
||||||
if (c === undefined || c === null) return '';
|
if (c === undefined || c === null) return '';
|
||||||
if (typeof c === 'string') return String(c).trim();
|
if (typeof c === 'string') return String(c).trim();
|
||||||
const n = c.name;
|
const n = c.name;
|
||||||
return (n !== undefined && n !== null && String(n).trim()) ? String(n).trim() : '';
|
return (n !== undefined && n !== null && String(n).trim()) ? String(n).trim() : '';
|
||||||
|
},
|
||||||
|
skillParts (name) {
|
||||||
|
const raw = (name === undefined || name === null) ? '' : String(name);
|
||||||
|
const idx = raw.indexOf(':');
|
||||||
|
if (idx <= 0) {
|
||||||
|
return {
|
||||||
|
label: '',
|
||||||
|
text: raw.trim()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const label = raw.slice(0, idx).trim();
|
||||||
|
const text = raw.slice(idx + 1).trim();
|
||||||
|
if (!label || !text) {
|
||||||
|
return {
|
||||||
|
label: '',
|
||||||
|
text: raw.trim()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
label,
|
||||||
|
text
|
||||||
|
};
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}));
|
}));
|
||||||
@ -209,9 +361,14 @@ export default Vue.component(name, Object.assign({}, baseOptions, {
|
|||||||
@ink: #111;
|
@ink: #111;
|
||||||
@muted: #444;
|
@muted: #444;
|
||||||
@pad-x: 24px;
|
@pad-x: 24px;
|
||||||
#template {
|
.ai-bw-tpl {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
font-family: "Source Sans Pro", "Helvetica Neue", Arial, sans-serif;
|
||||||
|
/* Disable ligatures so PDF rendering doesn't drop ti/ff/fi glyphs
|
||||||
|
(was producing "So ware" / "le -shi QA" / "dra s" in print). */
|
||||||
|
font-variant-ligatures: none;
|
||||||
|
font-feature-settings: "liga" 0, "clig" 0, "dlig" 0, "calt" 0;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@ -229,12 +386,12 @@ export default Vue.component(name, Object.assign({}, baseOptions, {
|
|||||||
|
|
||||||
p {
|
p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 11px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
ul li {
|
ul li {
|
||||||
color: @ink;
|
color: @ink;
|
||||||
font-size: 11px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
@ -251,6 +408,8 @@ export default Vue.component(name, Object.assign({}, baseOptions, {
|
|||||||
background: #fff;
|
background: #fff;
|
||||||
border-bottom: 2px solid @ink;
|
border-bottom: 2px solid @ink;
|
||||||
padding: 14px @pad-x 10px;
|
padding: 14px @pad-x 10px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
|
||||||
#header-left {
|
#header-left {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@ -263,7 +422,7 @@ export default Vue.component(name, Object.assign({}, baseOptions, {
|
|||||||
line-height: 1.15;
|
line-height: 1.15;
|
||||||
}
|
}
|
||||||
h2 {
|
h2 {
|
||||||
font-size: 13px;
|
font-size: 14px;
|
||||||
color: @muted;
|
color: @muted;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
@ -271,7 +430,7 @@ export default Vue.component(name, Object.assign({}, baseOptions, {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
font-size: 10px;
|
font-size: 11px;
|
||||||
gap: 4px 12px;
|
gap: 4px 12px;
|
||||||
|
|
||||||
span {
|
span {
|
||||||
@ -280,40 +439,50 @@ export default Vue.component(name, Object.assign({}, baseOptions, {
|
|||||||
|
|
||||||
i {
|
i {
|
||||||
color: @ink;
|
color: @ink;
|
||||||
font-size: 12px;
|
font-size: 13px;
|
||||||
margin-right: 5px;
|
margin-right: 5px;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
|
.header-emoji-flag {
|
||||||
|
font-style: normal;
|
||||||
|
filter: grayscale(1) contrast(3);
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: middle;
|
||||||
|
line-height: 1;
|
||||||
|
&::before {
|
||||||
|
content: '🇨🇦';
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#resume-about {
|
#resume-about {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
padding: 6px @pad-x 8px;
|
padding: 6px @pad-x 4px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-bottom: 1px solid #ccc;
|
border-bottom: none;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
h2 {
|
#profile-title {
|
||||||
font-size: 12px;
|
font-size: 15px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
margin-bottom: 4px;
|
margin-bottom: 6px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
h2, p {
|
h2, p {
|
||||||
color: @ink;
|
color: @ink;
|
||||||
}
|
}
|
||||||
p {
|
p {
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
line-height: 1.38;
|
line-height: 1.38;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
white-space: pre-line;
|
white-space: pre-line;
|
||||||
}
|
}
|
||||||
.core-strengths {
|
.core-strengths {
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
line-height: 1.38;
|
line-height: 1.38;
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
}
|
}
|
||||||
@ -328,17 +497,20 @@ export default Vue.component(name, Object.assign({}, baseOptions, {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding: 8px @pad-x 8px;
|
padding: 6px @pad-x 6px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
|
|
||||||
#experience-title, #education-title, #certifications-title, #skills-title, #projects-title {
|
#experience-title, #education-title, #certifications-title, #skills-title, #projects-title {
|
||||||
font-size: 12px;
|
font-size: 15px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
#experience-container {
|
#experience-container {
|
||||||
|
border-top: 1px solid #999;
|
||||||
|
padding-top: 6px;
|
||||||
|
margin-top: 8px;
|
||||||
padding-left: 0;
|
padding-left: 0;
|
||||||
padding-right: 0;
|
padding-right: 0;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@ -352,7 +524,7 @@ export default Vue.component(name, Object.assign({}, baseOptions, {
|
|||||||
display: block;
|
display: block;
|
||||||
margin: 0 0 2px 0;
|
margin: 0 0 2px 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
font-size: 9px;
|
font-size: 11px;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
color: @muted;
|
color: @muted;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
@ -378,7 +550,7 @@ export default Vue.component(name, Object.assign({}, baseOptions, {
|
|||||||
i {
|
i {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
color: @ink;
|
color: @ink;
|
||||||
font-size: 11px;
|
font-size: 13px;
|
||||||
width: 1.25em;
|
width: 1.25em;
|
||||||
min-width: 1.25em;
|
min-width: 1.25em;
|
||||||
margin-right: 5px;
|
margin-right: 5px;
|
||||||
@ -393,25 +565,107 @@ export default Vue.component(name, Object.assign({}, baseOptions, {
|
|||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.experience-legend-text-icon {
|
||||||
|
display: inline-block;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 13px;
|
||||||
|
color: @ink;
|
||||||
|
width: 1.25em;
|
||||||
|
min-width: 1.25em;
|
||||||
|
text-align: center;
|
||||||
|
margin-right: 5px;
|
||||||
|
vertical-align: baseline;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
.experience {
|
.experience {
|
||||||
margin: 0 0 9px 0;
|
margin: 0 0 7px 0;
|
||||||
break-inside: avoid;
|
break-inside: avoid;
|
||||||
page-break-inside: avoid;
|
page-break-inside: avoid;
|
||||||
|
& + .experience {
|
||||||
|
border-top: 1px solid #ddd;
|
||||||
|
padding-top: 6px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
&:first-child {
|
||||||
|
border-top: none;
|
||||||
|
}
|
||||||
&:last-child {
|
&:last-child {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
|
border-top: none;
|
||||||
|
}
|
||||||
|
&.experience-section {
|
||||||
|
border-top: none;
|
||||||
|
padding-top: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
break-after: avoid;
|
||||||
|
page-break-after: avoid;
|
||||||
|
.spacer {
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.job-description-list {
|
||||||
|
list-style-type: none;
|
||||||
|
padding-left: 0;
|
||||||
|
li {
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
li:nth-child(even) {
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
li:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
ul {
|
ul {
|
||||||
margin: 3px 0 0 0;
|
margin: 3px 0 0 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.experience.experience-compact {
|
||||||
|
margin: 0;
|
||||||
|
break-inside: avoid;
|
||||||
|
page-break-inside: avoid;
|
||||||
|
.company-row {
|
||||||
|
margin-bottom: 0;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.experience.experience-compact + .experience.experience-compact {
|
||||||
|
border-top: none;
|
||||||
|
padding-top: 0;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
.experience.experience-section + .experience.experience-compact {
|
||||||
|
border-top: none;
|
||||||
|
padding-top: 0;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-stack {
|
||||||
|
font-size: 11px;
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 400;
|
||||||
|
color: @muted;
|
||||||
|
margin-left: 8px;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-brace {
|
||||||
|
font-weight: 700;
|
||||||
|
font-style: normal;
|
||||||
|
color: @ink;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
/* Grid keeps location in the right column; flex-wrap was sending it to a full-width row under bullets */
|
/* Grid keeps location in the right column; flex-wrap was sending it to a full-width row under bullets */
|
||||||
.company-row {
|
.company-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) minmax(min-content, max-content);
|
grid-template-columns: minmax(0, 1fr) minmax(min-content, max-content);
|
||||||
column-gap: 10px;
|
column-gap: 10px;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
margin: 0 0 5px 0;
|
margin: 0 0 3px 0;
|
||||||
line-height: 1.25;
|
line-height: 1.25;
|
||||||
.company-primary {
|
.company-primary {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@ -421,7 +675,7 @@ export default Vue.component(name, Object.assign({}, baseOptions, {
|
|||||||
}
|
}
|
||||||
.company {
|
.company {
|
||||||
display: inline;
|
display: inline;
|
||||||
font-size: 13px;
|
font-size: 14px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: @ink;
|
color: @ink;
|
||||||
}
|
}
|
||||||
@ -445,7 +699,7 @@ export default Vue.component(name, Object.assign({}, baseOptions, {
|
|||||||
}
|
}
|
||||||
i {
|
i {
|
||||||
color: @ink;
|
color: @ink;
|
||||||
font-size: 12px;
|
font-size: 13px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
vertical-align: baseline;
|
vertical-align: baseline;
|
||||||
opacity: 0.88;
|
opacity: 0.88;
|
||||||
@ -454,7 +708,7 @@ export default Vue.component(name, Object.assign({}, baseOptions, {
|
|||||||
.company-location {
|
.company-location {
|
||||||
justify-self: end;
|
justify-self: end;
|
||||||
align-self: baseline;
|
align-self: baseline;
|
||||||
font-size: 10px;
|
font-size: 10.5px;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color: @muted;
|
color: @muted;
|
||||||
line-height: 1.25;
|
line-height: 1.25;
|
||||||
@ -475,7 +729,7 @@ export default Vue.component(name, Object.assign({}, baseOptions, {
|
|||||||
grid-template-columns: minmax(0, 1fr) minmax(min-content, max-content);
|
grid-template-columns: minmax(0, 1fr) minmax(min-content, max-content);
|
||||||
column-gap: 10px;
|
column-gap: 10px;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
margin: 0 0 5px 0;
|
margin: 0 0 3px 0;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -485,9 +739,9 @@ export default Vue.component(name, Object.assign({}, baseOptions, {
|
|||||||
list-style-position: outside;
|
list-style-position: outside;
|
||||||
list-style-type: disc;
|
list-style-type: disc;
|
||||||
li {
|
li {
|
||||||
font-size: 10px;
|
font-size: 11.5px;
|
||||||
line-height: 1.32;
|
line-height: 1.28;
|
||||||
margin: 0 0 1px 0;
|
margin: 0;
|
||||||
padding-left: 1px;
|
padding-left: 1px;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
@ -497,8 +751,42 @@ export default Vue.component(name, Object.assign({}, baseOptions, {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.job-title, .degree {
|
.stack-line {
|
||||||
|
margin: 0 0 3px 0;
|
||||||
|
font-size: 11.5px;
|
||||||
|
line-height: 1.3;
|
||||||
|
color: @muted;
|
||||||
|
font-style: italic;
|
||||||
|
white-space: normal;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.section-subheading {
|
||||||
|
font-size: 15px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.25;
|
||||||
|
color: @ink;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-subheading-date {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: @muted;
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bullet-aside {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-title {
|
||||||
|
font-weight: 500;
|
||||||
color: @ink;
|
color: @ink;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@ -507,12 +795,22 @@ export default Vue.component(name, Object.assign({}, baseOptions, {
|
|||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.degree {
|
||||||
|
font-weight: 700;
|
||||||
|
color: @ink;
|
||||||
|
font-size: 12px;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
.experience-timeperiod {
|
.experience-timeperiod {
|
||||||
justify-self: end;
|
justify-self: end;
|
||||||
align-self: baseline;
|
align-self: baseline;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color: @muted;
|
color: @muted;
|
||||||
font-size: 10px;
|
font-size: 10.5px;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@ -532,7 +830,7 @@ export default Vue.component(name, Object.assign({}, baseOptions, {
|
|||||||
.sub-company-name {
|
.sub-company-name {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: @ink;
|
color: @ink;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sub-icon-gutter {
|
.sub-icon-gutter {
|
||||||
@ -545,7 +843,7 @@ export default Vue.component(name, Object.assign({}, baseOptions, {
|
|||||||
margin-right: 5px;
|
margin-right: 5px;
|
||||||
i {
|
i {
|
||||||
color: @ink;
|
color: @ink;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
vertical-align: baseline;
|
vertical-align: baseline;
|
||||||
opacity: 0.88;
|
opacity: 0.88;
|
||||||
@ -563,7 +861,7 @@ export default Vue.component(name, Object.assign({}, baseOptions, {
|
|||||||
.education-timeperiod {
|
.education-timeperiod {
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color: @muted;
|
color: @muted;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.education {
|
.education {
|
||||||
@ -571,7 +869,7 @@ export default Vue.component(name, Object.assign({}, baseOptions, {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#skills-container {
|
#skills-container {
|
||||||
margin-top: 14px;
|
margin-top: 16px;
|
||||||
padding-left: 0;
|
padding-left: 0;
|
||||||
padding-right: 0;
|
padding-right: 0;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@ -581,7 +879,7 @@ export default Vue.component(name, Object.assign({}, baseOptions, {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#skill-description {
|
#skill-description {
|
||||||
font-size: 9px;
|
font-size: 11px;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
color: @muted;
|
color: @muted;
|
||||||
margin: 0 0 2px 0;
|
margin: 0 0 2px 0;
|
||||||
@ -594,8 +892,8 @@ export default Vue.component(name, Object.assign({}, baseOptions, {
|
|||||||
|
|
||||||
#skill-grid {
|
#skill-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
column-gap: 0;
|
column-gap: 14px;
|
||||||
row-gap: 3px;
|
row-gap: 3px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@ -604,7 +902,7 @@ export default Vue.component(name, Object.assign({}, baseOptions, {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.skill-cell {
|
.skill-cell {
|
||||||
font-size: 9px;
|
font-size: 11.5px;
|
||||||
line-height: 1.28;
|
line-height: 1.28;
|
||||||
color: @ink;
|
color: @ink;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@ -617,8 +915,18 @@ export default Vue.component(name, Object.assign({}, baseOptions, {
|
|||||||
break-inside: avoid;
|
break-inside: avoid;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.skill-label {
|
||||||
|
font-weight: 700;
|
||||||
|
color: @ink;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-text {
|
||||||
|
font-weight: 400;
|
||||||
|
color: @ink;
|
||||||
|
}
|
||||||
|
|
||||||
#projects-container {
|
#projects-container {
|
||||||
margin-top: 14px;
|
margin-top: 16px;
|
||||||
padding-left: 0;
|
padding-left: 0;
|
||||||
padding-right: 0;
|
padding-right: 0;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@ -628,7 +936,18 @@ export default Vue.component(name, Object.assign({}, baseOptions, {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.project {
|
.project {
|
||||||
margin: 0 0 4px 0;
|
margin: 0;
|
||||||
|
break-inside: avoid;
|
||||||
|
page-break-inside: avoid;
|
||||||
|
& + .project {
|
||||||
|
margin-top: 6px;
|
||||||
|
padding-top: 0;
|
||||||
|
border-top: none;
|
||||||
|
}
|
||||||
|
&:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
&:last-child {
|
&:last-child {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
@ -637,21 +956,32 @@ export default Vue.component(name, Object.assign({}, baseOptions, {
|
|||||||
.company-section-label {
|
.company-section-label {
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.03em;
|
letter-spacing: 0.03em;
|
||||||
color: @muted;
|
color: @muted;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.compact-description {
|
||||||
|
font-size: 11.5px;
|
||||||
|
line-height: 1.15;
|
||||||
|
color: @ink;
|
||||||
|
margin: 0 0 -1px 0;
|
||||||
|
padding-left: 1.1em;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
.project-name {
|
.project-name {
|
||||||
font-size: 12px;
|
font-size: 13px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: @ink;
|
color: @ink;
|
||||||
margin: 0 0 2px 0;
|
margin: 0;
|
||||||
|
line-height: 1.1;
|
||||||
}
|
}
|
||||||
|
|
||||||
#education-container {
|
#education-container {
|
||||||
margin-top: 14px;
|
margin-top: 16px;
|
||||||
padding-left: 0;
|
padding-left: 0;
|
||||||
padding-right: 0;
|
padding-right: 0;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@ -661,7 +991,7 @@ export default Vue.component(name, Object.assign({}, baseOptions, {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#certifications-container {
|
#certifications-container {
|
||||||
margin-top: 14px;
|
margin-top: 16px;
|
||||||
padding-left: 0;
|
padding-left: 0;
|
||||||
padding-right: 0;
|
padding-right: 0;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@ -676,7 +1006,7 @@ export default Vue.component(name, Object.assign({}, baseOptions, {
|
|||||||
list-style-position: outside;
|
list-style-position: outside;
|
||||||
list-style-type: disc;
|
list-style-type: disc;
|
||||||
li {
|
li {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
margin: 0 0 2px 0;
|
margin: 0 0 2px 0;
|
||||||
padding-left: 2px;
|
padding-left: 2px;
|
||||||
@ -691,7 +1021,7 @@ export default Vue.component(name, Object.assign({}, baseOptions, {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@page {
|
@page {
|
||||||
margin: 4mm 5mm 5mm 5mm;
|
margin: 7mm 5mm 5mm 5mm;
|
||||||
}
|
}
|
||||||
|
|
||||||
@page :first {
|
@page :first {
|
||||||
@ -702,7 +1032,7 @@ export default Vue.component(name, Object.assign({}, baseOptions, {
|
|||||||
.spacer {
|
.spacer {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-bottom: 1px solid #999;
|
border-bottom: 1px solid #999;
|
||||||
margin: 4px 0 8px;
|
margin: 3px 0 6px;
|
||||||
padding-top: 0;
|
padding-top: 0;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -199,6 +199,12 @@ function getVueOptions (name) {
|
|||||||
key: 'coop',
|
key: 'coop',
|
||||||
icon: 'fa fa-graduation-cap',
|
icon: 'fa fa-graduation-cap',
|
||||||
label: 'Co-op'
|
label: 'Co-op'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'stack',
|
||||||
|
icon: '',
|
||||||
|
text: '{ }',
|
||||||
|
label: 'Stack'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user