Editor strip, Network/Console/Trace tabs, real Playwright tests, HC theme

UI:
- Editor tab strip above the report with per-spec scoping (sidebar tree,
  results, source, status counts, hero copy); cookie-persisted active spec
- Status pill + workers / headed overflow menu moved from topbar into the
  editor bar to slim the global chrome
- Summary stripe and pending-preview body so the report never lands empty
- Tag bar with first-6 + "+N more" + clear; auto-run first idle test on load
- Mobile drawer for the test explorer; keyboard shortcuts overlay (?)
- Skipped + failed sample tests with proper icons / step rendering

Tabs:
- Network tab: public git.levkin.ca repos rendered as Playwright-style
  GET ... 200 rows with expandable JSON bodies and repo links
- Trace tab: career timeline as a Gantt-style waterfall
- Console tab: live run events; Source tab regenerates per active spec

Theming:
- Wire up high-contrast (WCAG AAA) theme: cycle dark → light → hc → dark,
  widen theme cookie regex to accept "hc", add HC overrides for syntax
  tokens and a few hardcoded "text-on-accent" sites in app.css

Testing:
- Add @playwright/test dev dependency + playwright.config.ts on port 3173
- tests/portfolio.spec.ts: 37 specs across 12 describe blocks
- scripts/fetch-gitea-repos.mjs to refresh giteaRepos from the Gitea API

Docs / housekeeping:
- README rewritten to reflect editor strip, network tab, HC theme, test
  runner, and updated project structure
- IDEAS.md trimmed to remaining roadmap; shipped items removed
- .gitignore ignores stray PDFs at repo root (canonical resume in assets/)
- Add assets/ilia-dobkin-resume.pdf as the canonical resume binary

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Builder 2026-05-11 22:55:48 -04:00
parent 7564148c3c
commit b596b2d608
14 changed files with 3415 additions and 240 deletions

7
.gitignore vendored
View File

@ -18,6 +18,13 @@ qa_*.png
test-results/ test-results/
playwright-report/ playwright-report/
# Dependencies (test-only)
node_modules/
# Local server / scratch # Local server / scratch
.cache/ .cache/
tmp/ tmp/
# Stray resume copies (canonical lives in assets/)
/*.pdf
!assets/*.pdf

View File

@ -6,101 +6,61 @@ Future enhancements, ranked by **payoff ÷ effort**. Quick wins on top.
## Quick wins (an evening or less) ## Quick wins (an evening or less)
### 1. Add a deliberate "failure" or "skipped" test ### 1. Persist filter + tab state in URL
Set one test (e.g. `should accept inbound contact`) to status `skipped` with an amber ⊘ icon, or have a `should match expected response time` test that fails with a stack trace. Adds visual variety + shows you understand real test output.
- **Why**: A test suite that's 100% green looks fake. Mixed outcomes feel authentic and let you show error-rendering chops.
- **Where**: `data.js` → add `status: 'skipped' | 'failed'` field on tests; extend `statusIcon()` / `statusClass()` in `app.js`.
### 2. Workers indicator (`--workers=4`)
Add a dropdown next to `--headed` that picks 1, 2, or 4 workers. Run-all then runs N tests in parallel visually, with N progress bars filling concurrently.
- **Why**: Shows off parallelization (your daily reality), looks cool.
- **Where**: `app.js` → rewrite `runAll()` to pull from a queue with `Promise.all` of N workers.
### 3. Keyboard shortcuts overlay
- `R` runs all, `Esc` stops, `T` toggles theme, `/` focuses grep, `1``9` runs that test, `?` opens a help overlay.
- **Why**: Power-user keybindings feel native to VS Code / terminal users.
- **Where**: `app.js` → add `document.addEventListener('keydown', ...)` near `init()`.
### 4. Persist filter + tab state in URL
Add `?grep=@playwright&tab=trace` so links into specific views work. Mirror to / from `location.hash`. Add `?grep=@playwright&tab=trace` so links into specific views work. Mirror to / from `location.hash`.
- **Why**: Shareable deep links to "what I want a recruiter to see." - **Why**: Shareable deep links to "what I want a recruiter to see."
- **Where**: `app.js` → wrap `applyFilter` and tab clicks to call `history.replaceState`. - **Where**: `app.js` → wrap `applyFilter` and tab clicks to call `history.replaceState`.
### 5. Footer "playback speed" slider ### 2. Footer "playback speed" slider
1×, 2×, 5×, 10× — multiply the `duration` of every test. Pairs well with the `--headed` toggle. 1×, 2×, 5×, 10× — multiply the `duration` of every test. Pairs well with the `--headed` toggle.
- **Where**: `app.js` → make `headed()` return a numeric multiplier, not a boolean. - **Where**: `app.js` → make `headed()` return a numeric multiplier, not a boolean.
### 6. Real Playwright HTML report download button ### 3. Real Playwright HTML report download button
A "View report" button that bundles the current run state into an actual `playwright-report/index.html`-style page and downloads it. Bonus points: include screenshots of the current page. A "View report" button that bundles the current run state into an actual `playwright-report/index.html`-style page and downloads it. Bonus points: include screenshots of the current page.
### 4. Network tab filter + copy-as-cURL
Add a filter input above the repo list and a "Copy cURL" button per row.
--- ---
## Medium lift (a weekend) ## Medium lift (a weekend)
### 7. Trace tab drill-down ### 5. Trace tab drill-down
Click a bar on the career timeline → scroll the Report tab to that company and highlight it. Add a hover tooltip with role + bullet count + key tech. Click a bar on the career timeline → scroll the Report tab to that company and highlight it. Add a hover tooltip with role + bullet count + key tech.
- **Where**: `app.js` → bind `click` handlers in `renderTrace()`, animate `scrollIntoView`. - **Where**: `app.js` → bind `click` handlers in `renderTrace()`, animate `scrollIntoView`.
### 8. Network tab ### 6. Pinned-repo / activity feed
A new tab modeled after Playwright's network panel. Each project entry becomes a fake XHR — `GET git.levkin.ca/api/repos/playwright-mcp 200 142ms` — clicking expands request/response detail. Same visual language as Source. On Projects or Network, pull **live** data from Gitea: latest commit, last push, star count — without baking `data.js`. Render with the same green-check / red-x vocabulary.
- **Why**: Plays directly into your "I see networks every day" identity. - **Approach**: small backend (Cloudflare Workers / Vercel function) that proxies `GET /api/v1/repos/{owner}/{repo}` and caches for 510 minutes (CORS-friendly). Static snapshot in `giteaRepos` already covers descriptions offline.
### 9. Pinned-repo / activity feed ### 7. Real resume PDF generation
On Projects, pull live data from your Gitea (`git.levkin.ca`) or GitHub: latest commit, last build status, star count. Render with the same green-check / red-x vocabulary.
- **Approach**: small backend (Cloudflare Workers / Vercel function) that proxies a couple of API calls and caches for 510 minutes.
### 10. Real resume PDF generation
The current `download resume.pdf` opens a print-styled HTML page. Replace with a true PDF generated server-side (Puppeteer) or client-side (`pdf-lib`, `jsPDF`). Multi-page, properly hyphenated, with embedded fonts. The current `download resume.pdf` opens a print-styled HTML page. Replace with a true PDF generated server-side (Puppeteer) or client-side (`pdf-lib`, `jsPDF`). Multi-page, properly hyphenated, with embedded fonts.
### 11. Search-runner: open-command style ### 8. Custom domain on `iliadobkin.com`
`Ctrl+P` opens a command palette with fuzzy matching across test names, sections, and tags — VS Code-style.
### 12. Code-coverage strip
A small bar at the top of the report saying `coverage: 9 / 9 tests · 100%`. When tests are filtered out, it shows the current selection's coverage. Tiny detail, big SDET energy.
### 13. Custom domain on `iliadobkin.com`
- Buy / point domain → static host (S3 + CloudFront, or your Proxmox box behind Caddy) - Buy / point domain → static host (S3 + CloudFront, or your Proxmox box behind Caddy)
- Set up Caddy site block with auto-TLS - Set up Caddy site block with auto-TLS
- Add `og:image` (a screenshot of the runner) and `og:title` for nice link previews - Add `og:image` (a screenshot of the runner) and `og:title` for nice link previews
- Plumb a tiny analytics pixel (Plausible or self-hosted Umami) — bonus: render a private `/admin` route that shows live visitor data in a Console-tab style - Plumb a tiny analytics pixel (Plausible or self-hosted Umami)
### 14. Dark+ light high-contrast variant
Third theme — high-contrast accessible mode (WCAG AAA). Toggle cycles dark → light → high-contrast. Reinforces your AODA/WCAG expertise.
--- ---
## Ambitious experiments (a project on its own) ## Ambitious experiments (a project on its own)
### 15. Real Playwright tests of the portfolio ### 9. MCP integration showcase
Ship a `tests/` directory with actual Playwright specs that exercise the site (`page.click('#run-all')`, asserts that 9 tests pass). Include the report in CI. The site that looks like a Playwright report is itself tested by Playwright. Self-referential, beautiful. Make a tiny live demo of your Playwright MCP server — embed a Cursor-style sidebar showing fake assistant chat where an LLM "writes a test" against the site and you watch the test appear and run.
### 16. MCP integration showcase ### 10. Live "watch mode"
Make a tiny live demo of your Playwright MCP server — embed a Cursor-style sidebar showing fake assistant chat where an LLM "writes a test" against the site and you watch the test appear and run. Pure flex. Add a fake file-tree on the left (`portfolio.spec.ts`, `fixtures/`, `playwright.config.ts`). Clicking a file opens it in a code-editor view (Monaco editor, full syntax highlighting). Editing reruns the affected tests.
### 17. Live "watch mode" ### 11. Tag-driven "narrative mode"
Add a fake file-tree on the left (`portfolio.spec.ts`, `fixtures/`, `playwright.config.ts`). Clicking a file opens it in a code-editor view (Monaco editor, full syntax highlighting). Editing reruns the affected tests. Becomes more "interactive IDE" than portfolio.
### 18. Multi-spec navigation
Split content across multiple "spec files":
- `portfolio.spec.ts` — about, experience, contact
- `projects.spec.ts` — homelab, MCP server, local AI
- `skills.spec.ts` — tag-driven
- `playground.spec.ts` — interactive demos, mini-games, fun stuff
Each gets its own tab in the editor strip at the top.
### 19. Tag-driven "narrative mode"
Click a tag and the site replays as a story: it runs only tests with that tag, in a deliberate order, with auto-scroll between sections. Great for recruiters with 30 seconds. "Show me the iGaming story" → runs experience@iGaming, skills@playwright, metrics tests in sequence. Click a tag and the site replays as a story: it runs only tests with that tag, in a deliberate order, with auto-scroll between sections. Great for recruiters with 30 seconds. "Show me the iGaming story" → runs experience@iGaming, skills@playwright, metrics tests in sequence.
### 20. Recording mode (literal demo videos) ### 12. Recording mode (literal demo videos)
Use the MediaRecorder API to capture a 20-second video of a Run All cycle and offer it as a download — recruiters can embed in Slack. Closing the loop on "you're a tester, prove the site works" with video evidence. Use the MediaRecorder API to capture a 20-second video of a Run All cycle and offer it as a download — recruiters can embed in Slack.
--- ---
@ -108,12 +68,12 @@ Use the MediaRecorder API to capture a 20-second video of a Run All cycle and of
- [ ] Add 12 GIFs or screenshots in this README - [ ] Add 12 GIFs or screenshots in this README
- [ ] Generate `og:image` social card (screenshot the dark hero) - [ ] Generate `og:image` social card (screenshot the dark hero)
- [ ] Write a short blog post on `iliadobkin.com/blog` titled "I built my portfolio as a Playwright test runner" — pair with this repo - [ ] Write a short blog post on `iliadobkin.com/blog` titled "I built my portfolio as a Playwright test runner"
- [ ] Add a "References" test: collapsed quotes from past managers / colleagues, each rendered as a test assertion (`expect(reference.satisfaction).toBeGreaterThan(threshold)`) - [ ] Add a "References" test: collapsed quotes from past managers / colleagues, each rendered as a test assertion
- [ ] Audit color contrast in light theme (WCAG AA minimum) - [ ] Audit color contrast in light theme (WCAG AA minimum)
- [ ] Add ARIA labels to the run buttons (`aria-label="Run test: should introduce Ilia Dobkin"`)
- [ ] Test on iOS Safari + Firefox (currently QA'd in Chromium) - [ ] Test on iOS Safari + Firefox (currently QA'd in Chromium)
- [ ] Add a "print" stylesheet so the whole Report tab prints clean - [ ] Add a "print" stylesheet so the whole Report tab prints clean
- [ ] Split `projects` into three tests (homelab / MCP / AI) so `projects.spec.ts` reads as 3 sibling rows
--- ---
@ -121,7 +81,6 @@ Use the MediaRecorder API to capture a 20-second video of a Run All cycle and of
Things to keep invariant as the project grows: Things to keep invariant as the project grows:
1. **No build step.** The day this needs `npm install` is the day it loses its character. 1. **`data.js` stays human-editable.** No code-gen, no schema validation, no DSL. Just JS objects.
2. **`data.js` stays human-editable.** No code-gen, no schema validation, no DSL. Just JS objects. 2. **Vanilla, framework-free.** If a feature genuinely needs React or a charting lib, weigh the bundle cost — the whole site is ~30 KB unminified.
3. **Vanilla, framework-free.** If a feature genuinely needs React or a charting lib, weigh the bundle cost — the whole site is ~30 KB unminified. 3. **Every interaction should *feel* like a real test runner.** When in doubt, ask: "what would Playwright / Vitest UI do here?"
4. **Every interaction should *feel* like a real test runner.** When in doubt, ask: "what would Playwright / Vitest UI do here?"

144
README.md
View File

@ -2,7 +2,7 @@
> A personal portfolio + resume styled as a **Playwright test runner**. Built by an SDET, for SDETs. > A personal portfolio + resume styled as a **Playwright test runner**. Built by an SDET, for SDETs.
Click the green ▶ next to any test to "run" it — each passing test reveals a portfolio section. Filter by `@tag` chips like a real `--grep`. Includes a career-timeline trace viewer, a Source tab that renders the portfolio as actual-looking Playwright spec code, and a downloadable PDF resume. Click the green ▶ next to any test to "run" it — each passing test reveals a portfolio section. Filter by `@tag` chips like a real `--grep`. Includes a career-timeline trace viewer, a Source tab that renders the portfolio as actual-looking Playwright spec code, a **Network** tab that lists public [git.levkin.ca](https://git.levkin.ca/explore/repos) repos as Playwright-style `GET … 200` rows with expandable JSON (descriptions from the Gitea API or each repo's README), and a downloadable PDF resume.
**Live preview:** deploy locally (see below) or open `index.html` directly. **Live preview:** deploy locally (see below) or open `index.html` directly.
@ -12,15 +12,26 @@ Click the green ▶ next to any test to "run" it — each passing test reveals a
A traditional portfolio doesn't tell a hiring manager you live in test runners all day. This one does — every interaction is a love letter to the tooling SDETs use: A traditional portfolio doesn't tell a hiring manager you live in test runners all day. This one does — every interaction is a love letter to the tooling SDETs use:
- Sidebar **Test Explorer** with collapsible suite + green run arrows - Sidebar **Test Explorer** (400px) with collapsible suites, green run arrows, and tree-view ellipsis for long describe labels
- Pass / fail / skip status pill in the top bar with live counts - **Editor tab strip** (28px, slim) above the report — switch between `portfolio.spec.ts`, `projects.spec.ts`, `skills.spec.ts`, `playground.spec.ts`; each rescopes the explorer, report, source view, and status counts (cookie-persisted)
- **Status pill** merged into the editor bar with live pass/fail/skip counts per active spec
- **Overflow menu** (⋯) on the right of the editor tabs with `--workers=` and `--headed` controls
- **Skipped test** (`should meet performance budget`) with amber ⊘ icon — a 100% green suite looks fake; mixed outcomes feel authentic
- Progress bars under each test that fill in real time - Progress bars under each test that fill in real time
- **Auto-run** first test on page load so the hero arrives with body content rendered (runner animation still plays)
- **Summary stripe** above the results list: `Last run · 1.4s · 4 passed · 1 skipped`
- **Pending preview** — the first idle test's body is shown expanded with a faded look + "click ▶ to run" overlay
- **Tag bar** with 6 visible tags + "+N more" expand chip + a `× clear` button
- **Count bubbles** right-aligned with consistent width, tabular numerals, outlined — reads as "metric" not "tag"
- VS Code dark+ / Playwright trace viewer palette (Inter + JetBrains Mono) - VS Code dark+ / Playwright trace viewer palette (Inter + JetBrains Mono)
- A `Trace` tab that draws your career as a Gantt-style waterfall - A `Trace` tab that draws your career as a Gantt-style waterfall
- A `Source` tab that renders the portfolio as a real-looking `.spec.ts` file - A `Source` tab that renders the active spec as a real-looking `.spec.ts` file
- A `Console` tab that logs test run events live - A `Console` tab that logs test run events live
- A `Network` tab modeled on Playwright's network panel — one row per public Gitea repo (`GET https://git.levkin.ca/api/v1/repos/…`), click to expand the faux JSON body
- `--headed` toggle that slows animations down for demo mode - `--headed` toggle that slows animations down for demo mode
- Cookie-persisted dark / light theme toggle - `--workers=` selector for parallel test execution (1, 2, or 4 workers)
- Cookie-persisted theme cycle: dark → light → high-contrast (WCAG AAA)
- Keyboard shortcuts (`?` to see all): `R` run all, `X` reset, `T` theme, `/` grep, `19` run Nth test
- Mobile drawer for the test explorer, fully responsive - Mobile drawer for the test explorer, fully responsive
--- ---
@ -29,15 +40,16 @@ A traditional portfolio doesn't tell a hiring manager you live in test runners a
Intentionally zero-framework. The whole point is craftsmanship: hand-rolled HTML, CSS variables, and vanilla JS. Easy to read, easy to fork, deploys anywhere static. Intentionally zero-framework. The whole point is craftsmanship: hand-rolled HTML, CSS variables, and vanilla JS. Easy to read, easy to fork, deploys anywhere static.
| Layer | Choice | | Layer | Choice |
| ----------- | ----------------------------------------- | | ------------ | ----------------------------------------- |
| Markup | Single `index.html` | | Markup | Single `index.html` |
| Styling | `css/base.css` (tokens) + `css/app.css` | | Styling | `css/base.css` (tokens) + `css/app.css` |
| Behavior | `js/data.js` (content) + `js/app.js` (UI) | | Behavior | `js/data.js` (content) + `js/app.js` (UI) |
| Type | Inter (sans) + JetBrains Mono (mono) | | Type | Inter (sans) + JetBrains Mono (mono) |
| Icons / Logo| Hand-written inline SVG | | Icons / Logo | Hand-written inline SVG |
| Build | None — open the file | | Build | None — open the file |
| Hosting | Any static host (S3, Netlify, GitHub Pages, your homelab) | | Tests (dev) | `@playwright/test` against `npx serve` |
| Hosting | Any static host (S3, Netlify, GitHub Pages, your homelab) |
--- ---
@ -45,17 +57,24 @@ Intentionally zero-framework. The whole point is craftsmanship: hand-rolled HTML
``` ```
portfolio/ portfolio/
├── index.html # Single-page shell — topbar, sidebar, tabs, statusbar ├── index.html # Single-page shell — topbar, editor bar, sidebar, tabs, statusbar
├── README.md # You are here ├── README.md # You are here
├── IDEAS.md # Future work, ranked by effort/payoff ├── IDEAS.md # Future work, ranked by effort/payoff
├── package.json # Dev-only — Playwright test runner
├── playwright.config.ts # Playwright config — serves site locally on port 3173
├── scripts/
│ └── fetch-gitea-repos.mjs # Optional: regenerate `giteaRepos` from git.levkin.ca API + READMEs
├── tests/
│ └── portfolio.spec.ts # 37 Playwright specs exercising the live site
├── css/ ├── css/
│ ├── base.css # Design tokens: colors, type, spacing, dark/light themes │ ├── base.css # Design tokens: colors, type, spacing, dark/light/hc themes
│ └── app.css # Component styles: tree, tabs, results, trace, source, etc. │ └── app.css # Component styles: tree, tabs, results, trace, network, source, etc.
├── js/ ├── js/
│ ├── data.js # All portfolio content — single source of truth │ ├── data.js # All portfolio content — single source of truth (incl. `giteaRepos`)
│ └── app.js # Test-runner behavior: tree, run engine, tabs, theme, drawer │ └── app.js # Test-runner behavior: tree, run engine, tabs, theme, drawer
└── assets/ └── assets/
└── favicon.svg # Custom mark ├── favicon.svg
└── ilia-dobkin-resume.pdf
``` ```
--- ---
@ -73,6 +92,34 @@ That's it. No build step, no dependencies. Edit a file, refresh the page.
--- ---
## Running the Playwright tests
The site that *looks* like a Playwright report is itself tested by real Playwright. The `tests/` directory ships **37 specs across 12 describe blocks** covering smoke checks, the run engine, theme cycling, tab navigation, editor strip switching, grep/tag filtering, keyboard shortcuts, the network panel, accessibility basics, the overflow menu, mobile responsiveness, and a full lifecycle scenario.
```bash
npm install
npx playwright install # downloads browser binaries
npm test # runs against Chromium by default
```
Useful variants:
```bash
npm run test:headed # watch the browser — great for debugging
npm run test:ui # Playwright's interactive UI mode
npm run report # open the HTML report from the last run
```
By default the config spins up a local static server on port 3173 (via `npx serve`) and runs Chromium only. Firefox and WebKit projects are present in `playwright.config.ts` — uncomment them to fan out. To test against a deployed URL instead of the local server:
```bash
BASE_URL=https://iliadobkin.com npx playwright test
```
> **Note:** `package.json` and `node_modules/` are test-only concerns — the site itself still has zero build step.
---
## Editing content ## Editing content
**All content lives in [`js/data.js`](js/data.js)** under `window.PORTFOLIO`. Change a title, swap a job, retag a skill — the UI updates automatically because every section is rendered from this single object. **All content lives in [`js/data.js`](js/data.js)** under `window.PORTFOLIO`. Change a title, swap a job, retag a skill — the UI updates automatically because every section is rendered from this single object.
@ -81,15 +128,24 @@ That's it. No build step, no dependencies. Edit a file, refresh the page.
window.PORTFOLIO = { window.PORTFOLIO = {
person: { first: 'Ilia', last: 'Dobkin', /* ... */ }, person: { first: 'Ilia', last: 'Dobkin', /* ... */ },
// Master tag palette — drives the filter bar // Master tag palette — drives the filter bar (first 6 shown, rest in "+N more")
tags: ['@playwright', '@cypress', '@api', '@ci', /* ... */], tags: ['@playwright', '@cypress', '@api', '@ci', /* ... */],
// Open .spec.ts files — each becomes a tab in the editor strip
specs: [
{ id: 'portfolio', file: 'portfolio.spec.ts', describe: 'Ilia Dobkin · portfolio' },
{ id: 'projects', file: 'projects.spec.ts', describe: 'Levkin · projects' },
{ id: 'skills', file: 'skills.spec.ts', describe: 'Ilia Dobkin · skills' },
{ id: 'playground', file: 'playground.spec.ts', describe: 'Ilia Dobkin · playground' },
],
// The test suite — each entry maps to one portfolio section // The test suite — each entry maps to one portfolio section
suite: { suite: {
name: 'Ilia Dobkin · portfolio', name: 'Ilia Dobkin · portfolio',
tests: [ tests: [
{ {
id: 'about', id: 'about',
spec: 'portfolio', // ← which file this test lives in
title: 'should introduce Ilia Dobkin', title: 'should introduce Ilia Dobkin',
tags: ['@playwright', '@leadership'], tags: ['@playwright', '@leadership'],
duration: 142, duration: 142,
@ -100,6 +156,14 @@ window.PORTFOLIO = {
], ],
render: renderAbout, // function that returns the section HTML render: renderAbout, // function that returns the section HTML
}, },
{
id: 'perf-budget',
spec: 'portfolio',
title: 'should meet performance budget',
skip: true, // ← amber ⊘ icon, excluded from Run All
skipReason: 'Lighthouse CI not wired — pending infra',
// ...
},
// ... // ...
], ],
}, },
@ -109,28 +173,50 @@ window.PORTFOLIO = {
projects: [ /* name, desc, tags */ ], projects: [ /* name, desc, tags */ ],
stack: { Editors: [...], Languages: [...], /* ... */ }, stack: { Editors: [...], Languages: [...], /* ... */ },
metrics: [ /* label / value pairs for the KPI cards */ ], metrics: [ /* label / value pairs for the KPI cards */ ],
giteaRepos: [ /* full_name, html_url, language, description — Network tab */ ],
}; };
``` ```
### Add a new test (section) ### Add a new test (section)
1. Add an entry to `PORTFOLIO.suite.tests`. 1. Add an entry to `PORTFOLIO.suite.tests` — set `spec` to the spec it belongs in (matching one of `PORTFOLIO.specs[].id`).
2. Write a `render<Name>()` function further down in `data.js` that returns HTML. 2. Write a `render<Name>()` function further down in `data.js` that returns HTML.
3. Reference it as the `render` property. Done — it appears in the sidebar + report. 3. Reference it as the `render` property. Done — it appears in the sidebar + report when its spec tab is active.
### Skip a test
Set `skip: true` on the test entry. It will render with the amber ⊘ icon, be excluded from `Run All`, and display its `skipReason` in the body.
### Add a new spec file (tab)
1. Append an entry to `PORTFOLIO.specs` (`{ id, file, describe }`).
2. Tag any tests into it via the `spec: '<id>'` field.
That's it — a new tab appears in the editor strip with the count badge.
### Add a new tag ### Add a new tag
Append to `PORTFOLIO.tags`. To make it filter anything, also add it to the `tags: []` array on the relevant tests. Append to `PORTFOLIO.tags`. To make it filter anything, also add it to the `tags: []` array on the relevant tests. Only the first 6 tags are shown by default; the rest hide behind a "+N more" chip.
### Add an experience entry ### Add an experience entry
Push to `PORTFOLIO.experience`. The Trace tab parses `when` (`"Aug 2023 Apr 2026"`) automatically and lays it out on the timeline. Push to `PORTFOLIO.experience`. The Trace tab parses `when` (`"Aug 2023 Apr 2026"`) automatically and lays it out on the timeline.
### Refresh Gitea repo descriptions
Public repos are listed at [git.levkin.ca/explore/repos](https://git.levkin.ca/explore/repos); the HTTP UI may split results across pages, but the Gitea API returns **all 19 public repos in one `repos/search` response**. To refresh blurbs from live READMEs:
```bash
node scripts/fetch-gitea-repos.mjs
```
Copy the printed `giteaRepos: [ … ]` block into `js/data.js` (or merge rows by hand). Descriptions prefer the repo's Gitea `description` field, then the first paragraph of `README.md`.
--- ---
## Customizing the look ## Customizing the look
All design tokens live in [`css/base.css`](css/base.css) as CSS variables, scoped to `:root[data-theme='dark']` and `:root[data-theme='light']`. All design tokens live in [`css/base.css`](css/base.css) as CSS variables, scoped to `:root[data-theme='dark']`, `:root[data-theme='light']`, and `:root[data-theme='hc']` (WCAG AAA high-contrast).
**Want a different accent?** Change `--accent` (default `#4ec9b0`, Playwright's signature teal). **Want a different accent?** Change `--accent` (default `#4ec9b0`, Playwright's signature teal).
@ -150,7 +236,7 @@ Any static host works because there's no build:
- **Your homelab (Caddy / nginx)** — just serve the directory. - **Your homelab (Caddy / nginx)** — just serve the directory.
- **Custom domain (e.g. `iliadobkin.com`)** — point an A/CNAME record at your host. - **Custom domain (e.g. `iliadobkin.com`)** — point an A/CNAME record at your host.
> Note: the theme toggle persists via a cookie, which works fine on any normal domain. If you embed in a sandboxed iframe that strips cookies, the theme will reset on reload but otherwise works. > Note: the theme toggle cycles **dark → light → high-contrast (WCAG AAA)** and persists via a cookie, which works fine on any normal domain. If you embed in a sandboxed iframe that strips cookies, the theme will reset on reload but otherwise works.
--- ---
@ -159,9 +245,10 @@ Any static host works because there's no build:
**Render loop is simple and stateless per-test:** **Render loop is simple and stateless per-test:**
``` ```
state[testId] = { status: 'idle' | 'running' | 'passed', runtime: ms } state[testId] = { status: 'idle' | 'running' | 'passed' | 'skipped', runtime: ms }
runTest(id) runTest(id)
├─ if test.skip → log warning, bail
├─ flip state to 'running' ├─ flip state to 'running'
├─ refreshTreeRow + refreshResultRow (update sidebar + main pane) ├─ refreshTreeRow + refreshResultRow (update sidebar + main pane)
├─ animate progress bar via requestAnimationFrame ├─ animate progress bar via requestAnimationFrame
@ -174,8 +261,13 @@ runTest(id)
| -------------------------------- | --------------------------------- | | -------------------------------- | --------------------------------- |
| What a test looks like inside | `data.js` — the matching `render*` fn | | What a test looks like inside | `data.js` — the matching `render*` fn |
| The order or set of tests | `data.js``PORTFOLIO.suite.tests` | | The order or set of tests | `data.js``PORTFOLIO.suite.tests` |
| The set of spec files (tabs) | `data.js``PORTFOLIO.specs` (+ `spec` on each test) |
| The editor bar + overflow menu | `app.js``renderEditorStrip()` / `initOverflowMenu()` |
| The status pill / summary stripe | `app.js``updateStatusbar()` / `updateSummaryStripe()` |
| The tag bar + clear button | `app.js``renderTagBar()` / `syncTagBarUI()` / `clearTagFilter()` |
| The trace tab parsing | `app.js``renderTrace()` + `parseMon()` | | The trace tab parsing | `app.js``renderTrace()` + `parseMon()` |
| The fake source code | `app.js``renderSource()` | | The fake source code | `app.js``renderSource()` |
| The Network / Gitea repo list | `data.js``giteaRepos` + `app.js``renderNetwork()` |
| Run-animation timing | `app.js``runTest()` / `tween()`| | Run-animation timing | `app.js``runTest()` / `tween()`|
| Theme colors | `base.css``:root[data-theme=*]`| | Theme colors | `base.css``:root[data-theme=*]`|
| Mobile breakpoints | `app.css``@media (max-width: 900px)` | | Mobile breakpoints | `app.css``@media (max-width: 900px)` |

Binary file not shown.

View File

@ -7,10 +7,13 @@ body {
overflow: hidden; overflow: hidden;
} }
/* ===================== TOP BAR ===================== */ /* ===================== TOP BAR =====================
* Two-column layout: brand + crumb on the left, run controls + theme/help on
* the right. The status pill + workers/headed toggles moved out of here (down
* into the editor bar) so this strip stays focused on global app chrome. */
.topbar { .topbar {
display: grid; display: grid;
grid-template-columns: 1fr auto 1fr; grid-template-columns: 1fr auto;
align-items: center; align-items: center;
background: var(--topbar-bg); background: var(--topbar-bg);
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--line);
@ -27,11 +30,12 @@ body {
.crumb__sep { color: var(--text-4); padding: 0 6px; } .crumb__sep { color: var(--text-4); padding: 0 6px; }
.crumb strong { color: var(--text); font-weight: 500; } .crumb strong { color: var(--text); font-weight: 500; }
/* Status pill lives in the editor bar now; height matches the slimmer strip. */
.status-pill { .status-pill {
display: inline-flex; align-items: center; gap: 10px; display: inline-flex; align-items: center; gap: 8px;
height: 24px; padding: 0 12px; height: 20px; padding: 0 10px;
background: var(--bg-3); border: 1px solid var(--line); background: var(--bg-3); border: 1px solid var(--line-2);
border-radius: 999px; font-family: var(--font-mono); font-size: var(--text-xs); border-radius: 999px; font-family: var(--font-mono); font-size: 11px;
color: var(--text-2); color: var(--text-2);
} }
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--text-4); display: inline-block; } .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--text-4); display: inline-block; }
@ -41,7 +45,8 @@ body {
.dot--fail { background: var(--fail); } .dot--fail { background: var(--fail); }
@keyframes pulse { 0% { box-shadow: 0 0 0 0 rgba(78,201,176,.55);} 70%{box-shadow:0 0 0 8px rgba(78,201,176,0);} 100%{box-shadow:0 0 0 0 rgba(78,201,176,0);} } @keyframes pulse { 0% { box-shadow: 0 0 0 0 rgba(78,201,176,.55);} 70%{box-shadow:0 0 0 8px rgba(78,201,176,0);} 100%{box-shadow:0 0 0 0 rgba(78,201,176,0);} }
.status-counts { display: inline-flex; gap: 10px; padding-left: 10px; border-left: 1px solid var(--line-2); } .status-counts { display: inline-flex; gap: 8px; padding-left: 8px; border-left: 1px solid var(--line-2); font-variant-numeric: tabular-nums; }
.cnt { display: inline-flex; align-items: baseline; gap: 3px; }
.cnt em { font-style: normal; font-weight: 600; } .cnt em { font-style: normal; font-weight: 600; }
.cnt--pass { color: var(--pass); } .cnt--pass { color: var(--pass); }
.cnt--fail { color: var(--fail); } .cnt--fail { color: var(--fail); }
@ -60,6 +65,7 @@ body {
background: var(--pass); color: #0b1f1a; border-color: transparent; font-weight: 600; background: var(--pass); color: #0b1f1a; border-color: transparent; font-weight: 600;
} }
.btn--run:hover { background: var(--pass-2); } .btn--run:hover { background: var(--pass-2); }
:root[data-theme='hc'] .btn--run { color: #000000; }
.btn--ghost { background: transparent; border-color: transparent; color: var(--text-2); } .btn--ghost { background: transparent; border-color: transparent; color: var(--text-2); }
.btn--ghost:hover { background: var(--hover); color: var(--text); } .btn--ghost:hover { background: var(--hover); color: var(--text); }
.iconbtn { background: transparent; border: 0; color: var(--text-3); cursor: pointer; padding: 4px; border-radius: 3px; } .iconbtn { background: transparent; border: 0; color: var(--text-3); cursor: pointer; padding: 4px; border-radius: 3px; }
@ -67,11 +73,26 @@ body {
.toggle { display: inline-flex; align-items: center; gap: 6px; font-family: var(--font-mono); color: var(--text-3); font-size: var(--text-xs); user-select: none; cursor: pointer; } .toggle { display: inline-flex; align-items: center; gap: 6px; font-family: var(--font-mono); color: var(--text-3); font-size: var(--text-xs); user-select: none; cursor: pointer; }
.toggle input { accent-color: var(--accent); } .toggle input { accent-color: var(--accent); }
.toggle.workers { gap: 0; }
.toggle.workers select {
appearance: none;
background: var(--bg-3); color: var(--text);
border: 1px solid var(--line-2); border-radius: 3px;
font-family: var(--font-mono); font-size: var(--text-xs);
padding: 1px 16px 1px 4px; margin-left: 2px;
cursor: pointer;
background-image: linear-gradient(45deg, transparent 50%, currentColor 50%), linear-gradient(135deg, currentColor 50%, transparent 50%);
background-position: calc(100% - 9px) 50%, calc(100% - 5px) 50%;
background-size: 4px 4px, 4px 4px;
background-repeat: no-repeat;
}
.toggle.workers select:hover { border-color: var(--text-3); color: var(--text); }
.toggle.workers select:focus { outline: 0; border-color: var(--accent); color: var(--text); }
/* ===================== LAYOUT ===================== */ /* ===================== LAYOUT ===================== */
.layout { .layout {
display: grid; display: grid;
grid-template-columns: 320px 1fr; grid-template-columns: 400px 1fr;
min-height: 0; min-height: 0;
overflow: hidden; overflow: hidden;
} }
@ -117,15 +138,107 @@ body {
.tag:hover { background: var(--hover); } .tag:hover { background: var(--hover); }
.tag.is-active { background: var(--accent); color: #0b1f1a; } .tag.is-active { background: var(--accent); color: #0b1f1a; }
:root[data-theme='light'] .tag.is-active { color: #fff; } :root[data-theme='light'] .tag.is-active { color: #fff; }
:root[data-theme='hc'] .tag.is-active { color: #000000; }
/* Tag bar shows the first 6 tags by default; "+N more" expands the rest
* inline so the visual mass stays small until a visitor needs the full list. */
.tag.is-collapsed { display: none; }
.tags.is-expanded .tag.is-collapsed { display: inline-flex; }
.tags-more {
display: inline-flex; align-items: center; gap: 4px;
font-family: var(--font-mono); font-size: 10.5px;
background: transparent; color: var(--text-3);
padding: 1px 7px; border-radius: 3px; cursor: pointer;
border: 1px dashed var(--line-2);
transition: color .12s, border-color .12s, background .12s;
}
.tags-more:hover { color: var(--text); border-color: var(--text-4); background: var(--hover); }
.tags.is-expanded .tags-more { display: none; }
.tags-clear {
display: inline-flex; align-items: center; gap: 3px;
font-family: var(--font-mono); font-size: 10px;
background: transparent; color: var(--text-4);
padding: 1px 6px; border-radius: 3px; cursor: default;
border: 1px solid transparent;
opacity: .4;
pointer-events: none;
transition: opacity .12s, background .12s, color .12s;
}
.tags-clear svg { width: 10px; height: 10px; }
.tags.has-active .tags-clear {
color: var(--fail); opacity: .85; cursor: pointer; pointer-events: auto;
}
.tags.has-active .tags-clear:hover { opacity: 1; background: var(--hover); }
.tree { flex: 1; overflow: auto; padding: 4px 0 16px; font-size: var(--text-sm); } .tree { flex: 1; overflow: auto; padding: 4px 0 16px; font-size: var(--text-sm); }
.suite { padding: 6px 10px 2px; } .suite { padding: 6px 10px 2px; }
.suite__head { display: flex; align-items: center; gap: 6px; color: var(--text-3); font-family: var(--font-mono); font-size: var(--text-xs); } .suite + .suite { margin-top: 4px; padding-top: 8px; border-top: 1px dashed var(--line); }
.suite__caret { width: 12px; display: inline-block; transition: transform .15s; } .suite__head {
.suite.collapsed .suite__caret { transform: rotate(-90deg); } display: flex; align-items: center; gap: 6px;
color: var(--text-3); font-family: var(--font-mono); font-size: var(--text-xs);
cursor: pointer;
padding: 2px 4px; margin: 0 -4px;
border-radius: 3px;
transition: background .12s var(--ease);
}
.suite__head:hover { background: var(--hover); }
.suite.is-active > .suite__head { background: rgba(78,201,176,.06); }
:root[data-theme='light'] .suite.is-active > .suite__head { background: rgba(12,138,111,.07); }
/* Caret defaults to "expanded" ( pointing down). Collapsed rows snap back
* to the natural matches VS Code's Test Explorer and is what visitors
* intuit on first glance. */
.suite__caret {
width: 14px;
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 14px;
color: var(--text-3);
transition: transform .15s var(--ease);
transform: rotate(90deg);
}
.suite__caret svg { width: 12px; height: 12px; display: block; }
.suite.collapsed .suite__caret { transform: rotate(0deg); }
.suite.collapsed .test { display: none; } .suite.collapsed .test { display: none; }
.suite__name { color: var(--text-2); } /* Suite name truncates with ellipsis instead of wrapping a wrapped row in
* a tree view always reads as a layout bug, even when intentional. */
.suite__name {
color: var(--text-2);
flex: 1 1 auto;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.suite__name em { color: var(--accent); font-style: normal; } .suite__name em { color: var(--accent); font-style: normal; }
/* Counts are "metrics" right-aligned, fixed min-width, tabular numerals,
* outlined box rather than the rounded pill we use for @tags. */
.suite__count {
margin-left: auto;
display: inline-flex;
align-items: center;
justify-content: flex-end;
min-width: 22px;
height: 16px;
padding: 0 6px;
font-family: var(--font-mono);
font-size: 10px;
font-variant-numeric: tabular-nums;
color: var(--text-3);
background: rgba(255,255,255,.04);
border: 1px solid var(--line-2);
border-radius: 3px;
line-height: 1;
}
:root[data-theme='light'] .suite__count { background: rgba(0,0,0,.03); }
.suite.is-active .suite__count {
color: var(--text);
background: rgba(78,201,176,.12);
border-color: rgba(78,201,176,.35);
}
:root[data-theme='light'] .suite.is-active .suite__count {
background: rgba(12,138,111,.10);
border-color: rgba(12,138,111,.30);
}
.test { .test {
display: grid; display: grid;
@ -139,6 +252,8 @@ body {
.test:hover { background: var(--hover); } .test:hover { background: var(--hover); }
.test.is-selected { background: var(--active); border-left-color: var(--accent); } .test.is-selected { background: var(--active); border-left-color: var(--accent); }
.test.is-running { background: var(--hover); } .test.is-running { background: var(--hover); }
.test.is-skipped { opacity: .55; }
.test.is-skipped .test__run { visibility: hidden; }
.test__run { .test__run {
width: 18px; height: 18px; border-radius: 3px; width: 18px; height: 18px; border-radius: 3px;
display: inline-flex; align-items: center; justify-content: center; display: inline-flex; align-items: center; justify-content: center;
@ -155,6 +270,7 @@ body {
.icon--run { color: var(--accent); } .icon--run { color: var(--accent); }
.icon--pass { color: var(--pass); } .icon--pass { color: var(--pass); }
.icon--fail { color: var(--fail); } .icon--fail { color: var(--fail); }
.icon--skip { color: var(--skip); }
.spin { animation: spin 1s linear infinite; transform-origin: center; } .spin { animation: spin 1s linear infinite; transform-origin: center; }
@keyframes spin { from{transform:rotate(0)} to{transform:rotate(360deg)} } @keyframes spin { from{transform:rotate(0)} to{transform:rotate(360deg)} }
@ -163,6 +279,7 @@ body {
.test__title span.kw { color: var(--info); } .test__title span.kw { color: var(--info); }
.test__title span.str { color: #ce9178; } .test__title span.str { color: #ce9178; }
:root[data-theme='light'] .test__title span.str { color: #a31515; } :root[data-theme='light'] .test__title span.str { color: #a31515; }
:root[data-theme='hc'] .test__title span.str { color: #ffd700; }
.test__dur { color: var(--text-4); font-family: var(--font-mono); font-size: 10.5px; } .test__dur { color: var(--text-4); font-family: var(--font-mono); font-size: 10.5px; }
@ -175,7 +292,150 @@ body {
} }
/* ===================== MAIN PANE ===================== */ /* ===================== MAIN PANE ===================== */
.main { display: grid; grid-template-rows: 34px 1fr; min-height: 0; background: var(--bg); } .main { display: grid; grid-template-rows: 28px 34px 1fr; min-height: 0; background: var(--bg); }
/* ----- EDITOR BAR (spec tabs + status pill + overflow menu) -----
* The bar itself fits in 28px (slimmer than VS Code's ~35px tab strip to
* compensate for our smaller font size). It hosts:
* 1. .editor-strip scrollable list of open .spec.ts files
* 2. .editor-bar__right sticky status pill + a "⋯" overflow that
* exposes --workers= and --headed (used to live in the top bar). */
.editor-bar {
display: flex;
align-items: stretch;
background: var(--bg-2);
border-bottom: 1px solid var(--line);
min-height: 0;
}
.editor-strip {
display: flex;
align-items: stretch;
flex: 1 1 auto;
min-width: 0;
overflow-x: auto;
scrollbar-width: thin;
}
.editor-strip::-webkit-scrollbar { height: 0; }
.editor-bar__right {
display: flex;
align-items: center;
gap: 8px;
flex: 0 0 auto;
padding: 0 8px 0 10px;
border-left: 1px solid var(--line);
background: var(--bg-2);
}
.espec {
position: relative;
display: inline-flex;
align-items: center;
gap: 7px;
padding: 0 12px;
background: transparent;
border: 0;
border-right: 1px solid var(--line);
color: var(--text-3);
font-family: var(--font-mono);
font-size: 11.5px;
cursor: pointer;
white-space: nowrap;
transition: background .12s var(--ease), color .12s;
}
.espec:hover { background: var(--hover); color: var(--text); }
.espec.is-active { background: var(--bg); color: var(--text); }
.espec.is-active::before {
content: '';
position: absolute;
left: 0; right: 0; top: 0;
height: 2px;
background: var(--accent);
}
/* TS file-type badge 12px tighter than before so the slimmer tabs don't
* end up dominated by the icon. */
.espec__icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px; height: 14px;
background: #3178c6;
color: #fff;
border-radius: 2px;
font-size: 8.5px;
font-weight: 700;
letter-spacing: .04em;
}
:root[data-theme='light'] .espec__icon { background: #2168bd; }
:root[data-theme='hc'] .espec__icon { background: #ffff00; color: #000000; }
.espec__name { /* filename — inherits .espec font */ }
.espec__count {
/* "metric" treatment: tabular numerals, fixed min-width, right-aligned,
* subtle outline so it reads as a number rather than another tag. */
display: inline-flex;
align-items: center;
justify-content: flex-end;
min-width: 18px;
height: 16px;
padding: 0 6px;
font-family: var(--font-mono);
font-size: 10px;
font-variant-numeric: tabular-nums;
color: var(--text-3);
background: rgba(255,255,255,.04);
border: 1px solid var(--line-2);
border-radius: 3px;
line-height: 1;
}
:root[data-theme='light'] .espec__count { background: rgba(0,0,0,.03); }
.espec.is-active .espec__count {
color: var(--text);
background: rgba(78,201,176,.12);
border-color: rgba(78,201,176,.35);
}
:root[data-theme='light'] .espec.is-active .espec__count {
background: rgba(12,138,111,.10);
border-color: rgba(12,138,111,.30);
}
/* ----- OVERFLOW MENU () -----
* Dropdown anchored to the bar; clicks outside close it (handled in app.js).
* Holds --workers= and --headed so the editor bar stays uncluttered. */
.overflow { position: relative; }
.overflow__btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px; height: 22px;
background: transparent;
color: var(--text-3);
border: 1px solid transparent;
border-radius: 3px;
cursor: pointer;
transition: background .12s var(--ease), color .12s, border-color .12s;
}
.overflow__btn:hover { background: var(--hover); color: var(--text); }
.overflow.is-open .overflow__btn {
background: var(--bg);
color: var(--text);
border-color: var(--line-2);
}
.overflow__menu {
position: absolute;
top: calc(100% + 4px);
right: 0;
z-index: 30;
display: grid;
gap: 10px;
padding: 10px 12px;
background: var(--panel);
border: 1px solid var(--line-2);
border-radius: var(--radius);
box-shadow: var(--shadow);
min-width: 180px;
animation: ksAppear .12s var(--ease);
}
.overflow__menu[hidden] { display: none; }
.overflow__menu .toggle { font-size: var(--text-xs); }
.tabs { .tabs {
display: flex; align-items: stretch; display: flex; align-items: stretch;
background: var(--bg-2); background: var(--bg-2);
@ -191,19 +451,51 @@ body {
.tab:hover { color: var(--text); } .tab:hover { color: var(--text); }
.tab.is-active { color: var(--text); background: var(--bg); border-top-color: var(--accent); } .tab.is-active { color: var(--text); background: var(--bg); border-top-color: var(--accent); }
.tabs__spacer { flex: 1; } .tabs__spacer { flex: 1; }
.tabs__meta { display: flex; align-items: center; padding: 0 14px; color: var(--text-4); font-family: var(--font-mono); font-size: 10.5px; }
.pane { display: none; height: 100%; overflow: auto; padding: 0; } .pane { display: none; height: 100%; overflow: auto; padding: 0; }
.pane.is-active { display: block; } .pane.is-active { display: block; }
/* HERO */ /* HERO */
.hero { padding: 36px 36px 18px; border-bottom: 1px solid var(--line); } .hero { padding: 36px 36px 18px; border-bottom: 1px solid var(--line); transition: padding .15s var(--ease); }
.hero__tag { display: inline-block; font-family: var(--font-mono); font-size: 10.5px; letter-spacing: .12em; color: var(--accent); background: rgba(78,201,176,.1); padding: 2px 8px; border-radius: 3px; text-transform: uppercase; margin-bottom: 12px; } .hero__tag { display: inline-block; font-family: var(--font-mono); font-size: 10.5px; letter-spacing: .12em; color: var(--accent); background: rgba(78,201,176,.1); padding: 2px 8px; border-radius: 3px; text-transform: uppercase; margin-bottom: 12px; }
.hero__title { margin: 0; font-size: clamp(34px, 4vw, 52px); font-weight: 700; letter-spacing: -.02em; line-height: 1.05; } .hero__title { margin: 0; font-size: clamp(34px, 4vw, 52px); font-weight: 700; letter-spacing: -.02em; line-height: 1.05; }
.hero__sub { color: var(--text-3); font-family: var(--font-mono); font-size: var(--text-sm); margin: 8px 0 0; } .hero__sub { color: var(--text-3); font-family: var(--font-mono); font-size: var(--text-sm); margin: 8px 0 0; }
.hero__hint { color: var(--text-3); margin: 18px 0 0; font-size: var(--text-sm); } .hero__hint { color: var(--text-3); margin: 18px 0 0; font-size: var(--text-sm); }
/* Slim variant for non-portfolio specs keeps the runner aesthetic but
stops a 52px hero from drowning a 1-test spec like playground.spec.ts. */
.hero--slim { padding: 22px 36px 14px; }
.hero--slim .hero__tag { margin-bottom: 8px; }
.hero--slim .hero__title { font-size: clamp(22px, 2.4vw, 28px); }
.hero--slim .hero__sub { font-size: var(--text-xs); margin-top: 4px; }
.hero--slim .hero__hint { margin-top: 10px; font-size: var(--text-xs); }
.kbd { font-family: var(--font-mono); background: var(--kbd-bg); border: 1px solid var(--line-2); border-bottom-width: 2px; border-radius: 3px; padding: 1px 6px; font-size: 11px; color: var(--text); } .kbd { font-family: var(--font-mono); background: var(--kbd-bg); border: 1px solid var(--line-2); border-bottom-width: 2px; border-radius: 3px; padding: 1px 6px; font-size: 11px; color: var(--text); }
/* Tiny run-history banner above the results list. Pulls the eye to dynamic
* data runtime + counts while taking only one line of vertical space. */
.summary-stripe {
display: flex; align-items: center; flex-wrap: wrap; gap: 6px;
padding: 8px 36px;
font-family: var(--font-mono); font-size: 11px;
color: var(--text-3);
background: linear-gradient(to bottom, rgba(78,201,176,.03), transparent);
border-bottom: 1px solid var(--line);
}
.ss__lbl { color: var(--text-4); letter-spacing: .06em; text-transform: uppercase; font-size: 10px; }
.ss__sep { color: var(--text-4); }
.ss__val { color: var(--text-2); font-variant-numeric: tabular-nums; }
.ss__val--pass { color: var(--pass); }
.ss__val--pending { color: var(--skip); }
.ss__val--skip { color: var(--skip); opacity: .7; }
.ss__val--idle { color: var(--text-4); }
.ss__dot {
width: 6px; height: 6px; border-radius: 50%; display: inline-block;
background: var(--text-4); margin-right: 4px;
}
.ss__dot--pass { background: var(--pass); }
.ss__dot--fail { background: var(--fail); }
.ss__dot--pending { background: var(--skip); }
.ss__dot--running { background: var(--accent); animation: pulse 1.2s infinite; }
/* RESULTS list */ /* RESULTS list */
.results { padding: 12px 0 80px; } .results { padding: 12px 0 80px; }
@ -226,14 +518,58 @@ body {
.result__title .kw { color: var(--info); } .result__title .kw { color: var(--info); }
.result__title .str { color: #ce9178; } .result__title .str { color: #ce9178; }
:root[data-theme='light'] .result__title .str { color: #a31515; } :root[data-theme='light'] .result__title .str { color: #a31515; }
:root[data-theme='hc'] .result__title .str { color: #ffd700; }
.result__dur { color: var(--text-4); font-family: var(--font-mono); font-size: 11px; } .result__dur { color: var(--text-4); font-family: var(--font-mono); font-size: 11px; }
.result__rerun { color: var(--text-3); background: transparent; border: 0; cursor: pointer; padding: 4px; border-radius: 3px; } .result__rerun { color: var(--text-3); background: transparent; border: 0; cursor: pointer; padding: 4px; border-radius: 3px; }
.result__rerun:hover { background: var(--hover); color: var(--text); } .result__rerun:hover { background: var(--hover); color: var(--text); }
.result__body { display: none; padding: 4px 0 24px 46px; animation: slideDown .25s var(--ease); } .result__body { display: none; padding: 4px 0 24px 46px; animation: slideDown .25s var(--ease); }
.result.is-open .result__body { display: block; } .result.is-open .result__body { display: block; }
.result.is-skipped { opacity: .65; }
.result.is-skipped .result__rerun { visibility: hidden; }
@keyframes slideDown { from { opacity: 0; transform: translateY(-4px);} to { opacity: 1; transform: none; } } @keyframes slideDown { from { opacity: 0; transform: translateY(-4px);} to { opacity: 1; transform: none; } }
/* "Pending" preview the first idle test is shown expanded with its body
* pre-rendered so visitors see what they'd get without committing to a run.
* Faded look + a centered "click ▶ to run" overlay reads as "preview, not
* result". Click anywhere on the overlay to kick off the run. */
.result.is-pending {
position: relative;
}
.result.is-pending .result__body {
display: block;
position: relative;
opacity: .42;
filter: saturate(.7);
pointer-events: none;
animation: none;
}
.result__pending-overlay {
position: absolute;
left: 50%;
top: 48%;
transform: translate(-50%, -50%);
display: inline-flex; align-items: center; gap: 8px;
padding: 7px 14px;
font-family: var(--font-mono); font-size: 11.5px;
color: var(--text-2);
background: var(--panel);
border: 1px solid var(--line-2);
border-radius: 999px;
box-shadow: var(--shadow);
cursor: pointer;
pointer-events: auto;
z-index: 2;
white-space: nowrap;
transition: background .12s var(--ease), transform .15s var(--ease);
}
.result__pending-overlay:hover {
background: var(--bg-3);
transform: translate(-50%, -50%) translateY(-1px);
}
.result__pending-overlay .kbd { font-size: 10.5px; padding: 0 5px; }
.result.is-pending .result__caret { opacity: .6; }
.progress { .progress {
height: 2px; width: 100%; background: transparent; overflow: hidden; position: relative; height: 2px; width: 100%; background: transparent; overflow: hidden; position: relative;
margin: -2px 0 0; margin: -2px 0 0;
@ -254,6 +590,8 @@ body {
padding-left: 14px; padding-left: 14px;
} }
.step__icon { color: var(--pass); } .step__icon { color: var(--pass); }
.step__icon.is-fail { color: var(--fail); }
.step__icon.is-pass { color: var(--pass); }
.step__icon.is-skip { color: var(--skip); } .step__icon.is-skip { color: var(--skip); }
.step__icon.is-info { color: var(--info); } .step__icon.is-info { color: var(--info); }
.step__dur { color: var(--text-4); } .step__dur { color: var(--text-4); }
@ -284,6 +622,10 @@ body {
.snippet .code .str { color: #ce9178; } .snippet .code .str { color: #ce9178; }
.snippet .code .num { color: #b5cea8; } .snippet .code .num { color: #b5cea8; }
.snippet .code .cm { color: #6a9955; font-style: italic; } .snippet .code .cm { color: #6a9955; font-style: italic; }
:root[data-theme='hc'] .snippet .code .fn { color: #ffff00; }
:root[data-theme='hc'] .snippet .code .str { color: #ffd700; }
:root[data-theme='hc'] .snippet .code .num { color: #00ff00; }
:root[data-theme='hc'] .snippet .code .cm { color: #7fd5ff; }
.snippet .code .tag { color: var(--accent); } .snippet .code .tag { color: var(--accent); }
:root[data-theme='light'] .snippet .code .str { color: #a31515; } :root[data-theme='light'] .snippet .code .str { color: #a31515; }
:root[data-theme='light'] .snippet .code .fn { color: #795e26; } :root[data-theme='light'] .snippet .code .fn { color: #795e26; }
@ -334,6 +676,16 @@ body {
.cta--ghost { background: transparent; color: var(--text); border: 1px solid var(--line-2); } .cta--ghost { background: transparent; color: var(--text); border: 1px solid var(--line-2); }
:root[data-theme='light'] .cta { color: #ffffff; } :root[data-theme='light'] .cta { color: #ffffff; }
:root[data-theme='light'] .cta--ghost { color: var(--text); } :root[data-theme='light'] .cta--ghost { color: var(--text); }
:root[data-theme='hc'] .cta { color: #000000; }
:root[data-theme='hc'] .cta--ghost { color: var(--text); }
.projects-foot { margin-top: 18px; font-size: 12px; color: var(--text-3); line-height: 1.55; max-width: 52rem; }
.projects-foot .tab-link {
background: none; border: 0; padding: 0; margin: 0;
color: var(--info); font-family: inherit; font-size: inherit;
cursor: pointer; text-decoration: underline;
}
.projects-foot .tab-link:hover { color: var(--accent); }
/* ===================== TRACE PANE ===================== */ /* ===================== TRACE PANE ===================== */
.trace-head { display: flex; align-items: center; justify-content: space-between; padding: 14px 24px; border-bottom: 1px solid var(--line); } .trace-head { display: flex; align-items: center; justify-content: space-between; padding: 14px 24px; border-bottom: 1px solid var(--line); }
@ -355,6 +707,54 @@ body {
.trace-axis { display: grid; grid-template-columns: 220px 1fr 90px; gap: 12px; padding: 6px 24px 16px; font-family: var(--font-mono); font-size: 10.5px; color: var(--text-4); } .trace-axis { display: grid; grid-template-columns: 220px 1fr 90px; gap: 12px; padding: 6px 24px 16px; font-family: var(--font-mono); font-size: 10.5px; color: var(--text-4); }
.trace-axis__ticks { display: flex; justify-content: space-between; } .trace-axis__ticks { display: flex; justify-content: space-between; }
/* ===================== NETWORK PANE ===================== */
/* Block layout (not nested flex-shrink) — long repo list stays visible everywhere. */
.network-pane { padding: 0; min-height: 40vh; }
.network-head {
display: flex; align-items: center; justify-content: space-between;
padding: 14px 24px; border-bottom: 1px solid var(--line);
}
.network-head__title { font-family: var(--font-mono); font-size: var(--text-sm); color: var(--text); }
.network-head__meta { display: flex; gap: 14px; font-family: var(--font-mono); font-size: 11px; color: var(--text-3); }
.network-scroll { padding: 0 0 32px; max-height: none; overflow: visible; -webkit-overflow-scrolling: touch; }
.network-empty, .network-err {
padding: 20px 24px; font-size: var(--text-xs); color: var(--text-3); line-height: 1.5;
font-family: var(--font-mono);
}
.network-err { color: var(--skip); }
.network-entry { border-bottom: 1px solid var(--line); }
.network-row {
display: grid;
grid-template-columns: 44px minmax(0, 1fr) 36px 38px 56px 48px 18px;
gap: 8px;
align-items: center;
width: 100%;
padding: 8px 24px;
border: 0;
background: transparent;
color: var(--text-2);
font-family: var(--font-mono);
font-size: 11px;
text-align: left;
cursor: pointer;
}
.network-row:hover { background: var(--hover); }
.network-row__method { color: var(--info); font-weight: 600; }
.network-row__url { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); }
.network-row__status { color: var(--pass); }
.network-row__caret { display: flex; align-items: center; justify-content: center; color: var(--text-4); }
.network-row__caret svg { transition: transform .15s var(--ease); }
.network-entry.is-open .network-row__caret svg { transform: rotate(90deg); }
.network-detail { padding: 0 24px 14px 44px; background: var(--bg-2); border-top: 1px solid var(--line); }
.network-detail__hdr { font-size: 10px; letter-spacing: .12em; text-transform: uppercase; color: var(--text-4); margin: 10px 0 6px; }
.network-detail__body {
margin: 0; padding: 12px 14px;
background: var(--panel); border: 1px solid var(--line-2); border-radius: var(--radius-sm);
font-size: 11px; line-height: 1.45; overflow-x: auto; white-space: pre-wrap; word-break: break-word;
}
.network-detail__link { display: inline-block; margin-top: 10px; font-size: var(--text-xs); color: var(--info); }
.network-detail__link:hover { text-decoration: underline; }
/* ===================== SOURCE PANE ===================== */ /* ===================== SOURCE PANE ===================== */
.source { font-family: var(--font-mono); font-size: var(--text-xs); padding: 14px 0; line-height: 1.65; } .source { font-family: var(--font-mono); font-size: var(--text-xs); padding: 14px 0; line-height: 1.65; }
.source__line { display: grid; grid-template-columns: 50px 1fr; gap: 14px; padding: 0 24px 0 0; } .source__line { display: grid; grid-template-columns: 50px 1fr; gap: 14px; padding: 0 24px 0 0; }
@ -366,6 +766,10 @@ body {
.source .str { color: #ce9178; } .source .str { color: #ce9178; }
.source .num { color: #b5cea8; } .source .num { color: #b5cea8; }
.source .cm { color: #6a9955; font-style: italic; } .source .cm { color: #6a9955; font-style: italic; }
:root[data-theme='hc'] .source .fn { color: #ffff00; }
:root[data-theme='hc'] .source .str { color: #ffd700; }
:root[data-theme='hc'] .source .num { color: #00ff00; }
:root[data-theme='hc'] .source .cm { color: #7fd5ff; }
.source .tag { color: var(--accent); } .source .tag { color: var(--accent); }
:root[data-theme='light'] .source .str { color: #a31515; } :root[data-theme='light'] .source .str { color: #a31515; }
:root[data-theme='light'] .source .fn { color: #795e26; } :root[data-theme='light'] .source .fn { color: #795e26; }
@ -406,9 +810,7 @@ body {
@media (max-width: 900px) { @media (max-width: 900px) {
body { grid-template-rows: 44px 1fr 22px; overflow: auto; } body { grid-template-rows: 44px 1fr 22px; overflow: auto; }
.topbar { grid-template-columns: auto 1fr auto; padding: 0 8px; gap: 6px; } .topbar { grid-template-columns: auto 1fr auto; padding: 0 8px; gap: 6px; }
.topbar__center { display: none; }
.topbar__left .crumb { display: none; } .topbar__left .crumb { display: none; }
.topbar__right .toggle { display: none; }
.topbar__right { gap: 4px; } .topbar__right { gap: 4px; }
.btn--run span { display: none; } .btn--run span { display: none; }
.btn { padding: 0 8px; } .btn { padding: 0 8px; }
@ -417,7 +819,7 @@ body {
.layout { grid-template-columns: 1fr; min-height: 0; } .layout { grid-template-columns: 1fr; min-height: 0; }
.sidebar { .sidebar {
position: fixed; top: 44px; bottom: 22px; left: 0; position: fixed; top: 44px; bottom: 22px; left: 0;
width: 86%; max-width: 320px; z-index: 50; width: 86%; max-width: 400px; z-index: 50;
transform: translateX(-100%); transition: transform .25s var(--ease); transform: translateX(-100%); transition: transform .25s var(--ease);
box-shadow: 4px 0 12px rgba(0,0,0,.4); box-shadow: 4px 0 12px rgba(0,0,0,.4);
} }
@ -429,12 +831,22 @@ body {
.sidebar-scrim.is-open { display: block; } .sidebar-scrim.is-open { display: block; }
.tabs { overflow-x: auto; } .tabs { overflow-x: auto; }
.tabs__meta { display: none; }
.editor-strip { font-size: 10.5px; }
.espec { padding: 0 10px; gap: 6px; }
.espec__count { display: none; }
.espec__icon { width: 14px; height: 12px; font-size: 8px; }
.editor-bar__right { padding: 0 6px; gap: 6px; }
.status-pill { padding: 0 8px; gap: 6px; }
.status-counts { padding-left: 6px; gap: 6px; }
.summary-stripe { padding: 6px 16px; font-size: 10.5px; }
.hero { padding: 22px 18px 14px; } .hero { padding: 22px 18px 14px; }
.hero__title { font-size: 32px; } .hero__title { font-size: 32px; }
.hero__sub { font-size: 12px; word-break: break-word; } .hero__sub { font-size: 12px; word-break: break-word; }
.hero__hint { font-size: 12.5px; } .hero__hint { font-size: 12.5px; }
.hero--slim { padding: 14px 18px 10px; }
.hero--slim .hero__title { font-size: 20px; }
.result { padding: 0 16px; } .result { padding: 0 16px; }
.result__head { grid-template-columns: 16px 16px 1fr auto; gap: 8px; } .result__head { grid-template-columns: 16px 16px 1fr auto; gap: 8px; }
@ -444,6 +856,16 @@ body {
.step { grid-template-columns: 16px 1fr auto; font-size: 11px; } .step { grid-template-columns: 16px 1fr auto; font-size: 11px; }
.step__title { word-break: break-word; white-space: normal; } .step__title { word-break: break-word; white-space: normal; }
.network-row {
grid-template-columns: 38px minmax(0, 1fr) 32px 44px 16px;
gap: 6px;
padding: 8px 16px;
font-size: 10px;
}
.network-row__mime,
.network-row__size { display: none; }
.network-detail { padding-left: 16px; padding-right: 16px; }
.trace-row, .trace-axis { grid-template-columns: 130px 1fr 56px; gap: 8px; font-size: 10.5px; } .trace-row, .trace-axis { grid-template-columns: 130px 1fr 56px; gap: 8px; font-size: 10.5px; }
.trace-row__label small { display: none; } .trace-row__label small { display: none; }
@ -453,3 +875,71 @@ body {
.statusbar { font-size: 10.5px; gap: 8px; } .statusbar { font-size: 10.5px; gap: 8px; }
.statusbar .sb__seg:nth-child(n+6) { display: none; } .statusbar .sb__seg:nth-child(n+6) { display: none; }
} }
/* ===================== KEYBOARD SHORTCUTS OVERLAY ===================== */
.kshelp[hidden] { display: none; }
.kshelp {
position: fixed; inset: 0; z-index: 100;
display: grid; place-items: center;
}
.kshelp__scrim {
position: absolute; inset: 0;
background: rgba(0, 0, 0, .55);
backdrop-filter: blur(2px);
animation: ksFade .12s var(--ease);
}
.kshelp__panel {
position: relative;
width: min(520px, 92vw);
background: var(--panel);
border: 1px solid var(--line-2);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
outline: 0;
animation: ksAppear .15s var(--ease);
}
@keyframes ksFade { from { opacity: 0; } to { opacity: 1; } }
@keyframes ksAppear { from { opacity: 0; transform: translateY(-6px); } to { opacity: 1; transform: none; } }
.kshelp__head {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--line);
}
.kshelp__title {
font-family: var(--font-mono);
font-size: var(--text-xs);
letter-spacing: .12em; text-transform: uppercase;
color: var(--text-2);
}
.kshelp__title::before { content: '// '; color: var(--text-4); }
.kshelp__close {
background: transparent; border: 0; color: var(--text-3);
font-size: 20px; line-height: 1; cursor: pointer;
padding: 2px 8px; border-radius: 3px;
}
.kshelp__close:hover { background: var(--hover); color: var(--text); }
.kshelp__grid { padding: 4px 0; }
.kshelp__row {
display: grid;
grid-template-columns: 130px 1fr;
gap: 14px; align-items: center;
padding: 9px 18px;
font-family: var(--font-mono);
font-size: var(--text-xs);
}
.kshelp__row + .kshelp__row { border-top: 1px dashed var(--line); }
.kshelp__keys { display: inline-flex; gap: 4px; align-items: center; color: var(--text-3); }
.kshelp__desc { color: var(--text-2); }
.kshelp__foot {
padding: 10px 16px;
border-top: 1px solid var(--line);
color: var(--text-3); font-size: 11px;
}
@media (max-width: 900px) {
.kshelp__panel { width: 92vw; }
.kshelp__row { grid-template-columns: 110px 1fr; padding: 8px 14px; }
}

View File

@ -88,6 +88,51 @@
--statusbar-fg: #ffffff; --statusbar-fg: #ffffff;
} }
/* High Contrast WCAG AAA (every text token 7:1 against --bg).
* Colors picked from the Windows / VS Code HC palette: pure black canvas,
* pure-white prose, yellow as the universal accent, lime/red/cyan for
* pass/fail/info so the three are still distinguishable for protanopia. */
:root[data-theme='hc'] {
--bg: #000000;
--bg-2: #000000;
--bg-3: #0a0a0a;
--panel: #000000;
--panel-2: #0a0a0a;
--line: #ffffff;
--line-2: #ffffff;
--text: #ffffff; /* 21:1 */
--text-2: #ffffff; /* 21:1 — no muting; AAA prose */
--text-3: #e6e6e6; /* 18:1 */
--text-4: #c0c0c0; /* 12.6:1 */
--accent: #ffff00; /* 19.6:1 — the one yellow */
--pass: #00ff00; /* 15.3:1 */
--pass-2: #00ff00;
--fail: #ff8585; /* 9.5:1 */
--skip: #ffd700; /* 16.4:1 */
--info: #7fd5ff; /* 11.7:1 */
--run: #00ff00;
--selection:#ffff00; /* paired with black text via override below */
--shadow: 0 0 0 1px #ffffff;
--hover: rgba(255,255,255,.18);
--active: rgba(255,255,255,.30);
--kbd-bg: #000000;
--tag-bg: #000000;
--tag-fg: #ffff00;
--green-arrow: #00ff00;
--topbar-bg: #000000;
--statusbar-bg: #ffff00;
--statusbar-fg: #000000;
}
:root[data-theme='hc'] ::selection { background: #ffff00; color: #000000; }
/* AAA also requires every interactive control to expose a visible focus
* indicator; force a 3px yellow outline on every focused element in HC,
* overriding the `outline: 0` declarations sprinkled through app.css. */
:root[data-theme='hc'] :focus-visible {
outline: 3px solid #ffff00 !important;
outline-offset: 2px;
}
* { box-sizing: border-box; } * { box-sizing: border-box; }
html, body { height: 100%; } html, body { height: 100%; }
body { body {

View File

@ -9,8 +9,8 @@
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet" /> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="css/base.css" /> <link rel="stylesheet" href="css/base.css?v=7" />
<link rel="stylesheet" href="css/app.css" /> <link rel="stylesheet" href="css/app.css?v=7" />
</head> </head>
<body> <body>
<!-- ============== TOP BAR ============== --> <!-- ============== TOP BAR ============== -->
@ -32,18 +32,6 @@
<span class="crumb"><span class="crumb__sep">/</span> tests <span class="crumb__sep">/</span> <strong>portfolio.spec.ts</strong></span> <span class="crumb"><span class="crumb__sep">/</span> tests <span class="crumb__sep">/</span> <strong>portfolio.spec.ts</strong></span>
</div> </div>
<div class="topbar__center">
<div class="status-pill" id="status-pill">
<span class="dot dot--idle" id="status-dot"></span>
<span id="status-text">idle</span>
<span class="status-counts">
<span class="cnt cnt--pass" title="passed"><em id="cnt-pass">0</em></span>
<span class="cnt cnt--fail" title="failed"><em id="cnt-fail">0</em></span>
<span class="cnt cnt--skip" title="skipped"><em id="cnt-skip">0</em></span>
</span>
</div>
</div>
<div class="topbar__right"> <div class="topbar__right">
<button class="btn btn--run" id="run-all" title="Run all tests"> <button class="btn btn--run" id="run-all" title="Run all tests">
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><path d="M4 3 L13 8 L4 13 Z" fill="currentColor"/></svg> <svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><path d="M4 3 L13 8 L4 13 Z" fill="currentColor"/></svg>
@ -55,13 +43,16 @@
<button class="btn btn--ghost" id="reset-all" title="Reset"> <button class="btn btn--ghost" id="reset-all" title="Reset">
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><path d="M8 3.5a4.5 4.5 0 1 1-4.39 5.5" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round"/><path d="M8 1 L8 5 L4 5" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg> <svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><path d="M8 3.5a4.5 4.5 0 1 1-4.39 5.5" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round"/><path d="M8 1 L8 5 L4 5" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button> </button>
<label class="toggle" title="Headed mode (slower animations)"> <button class="btn btn--ghost" id="theme-toggle" title="Toggle theme (T)" aria-label="Toggle theme">
<input type="checkbox" id="headed" />
<span>--headed</span>
</label>
<button class="btn btn--ghost" id="theme-toggle" title="Toggle theme" aria-label="Toggle theme">
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><path d="M11 8a3 3 0 1 1-3-3 3 3 0 0 0 3 3z" fill="currentColor"/></svg> <svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><path d="M11 8a3 3 0 1 1-3-3 3 3 0 0 0 3 3z" fill="currentColor"/></svg>
</button> </button>
<button class="btn btn--ghost" id="kshelp-open" title="Keyboard shortcuts (?)" aria-label="Keyboard shortcuts">
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">
<circle cx="8" cy="8" r="6.4" stroke="currentColor" stroke-width="1.4" fill="none"/>
<path d="M6 6.4c.1-1.2 1-2 2.1-2s2 .8 2 1.9c0 .9-.6 1.4-1.4 1.8-.5.3-.7.6-.7 1.1v.3" stroke="currentColor" stroke-width="1.4" fill="none" stroke-linecap="round"/>
<circle cx="8" cy="11.6" r=".75" fill="currentColor"/>
</svg>
</button>
</div> </div>
</header> </header>
@ -85,19 +76,57 @@
<nav class="tree" id="tree" aria-label="Tests"></nav> <nav class="tree" id="tree" aria-label="Tests"></nav>
<div class="sidebar__foot"> <div class="sidebar__foot">
<span>v1.0.0 · 9 tests</span> <span id="sidebar-foot-meta">v1.0.0 · 12 tests</span>
</div> </div>
</aside> </aside>
<!-- Main Pane --> <!-- Main Pane -->
<section class="main" aria-label="Test results"> <section class="main" aria-label="Test results">
<!-- Editor bar: spec tabs on the left, status pill + overflow menu on the right.
The status pill (counts for the active spec) and the small "⋯" menu
(workers / headed) used to live in the top bar; they were merged here
to slim that strip and keep run state visually anchored to the editor. -->
<div class="editor-bar">
<div class="editor-strip" id="editor-strip" role="tablist" aria-label="Open spec files"></div>
<div class="editor-bar__right">
<div class="status-pill" id="status-pill" aria-live="polite">
<span class="dot dot--idle" id="status-dot"></span>
<span id="status-text">idle</span>
<span class="status-counts">
<span class="cnt cnt--pass" title="passed"><em id="cnt-pass">0</em></span>
<span class="cnt cnt--fail" title="failed"><em id="cnt-fail">0</em></span>
<span class="cnt cnt--skip" title="skipped"><em id="cnt-skip">0</em></span>
</span>
</div>
<div class="overflow" id="overflow">
<button class="overflow__btn" id="overflow-btn" type="button" aria-haspopup="menu" aria-expanded="false" aria-label="Run options">
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><circle cx="3.4" cy="8" r="1.3" fill="currentColor"/><circle cx="8" cy="8" r="1.3" fill="currentColor"/><circle cx="12.6" cy="8" r="1.3" fill="currentColor"/></svg>
</button>
<div class="overflow__menu" id="overflow-menu" role="menu" hidden>
<label class="toggle workers" title="Workers — number of tests to run in parallel">
<span>--workers=</span>
<select id="workers" aria-label="Workers">
<option value="1">1</option>
<option value="2">2</option>
<option value="4">4</option>
</select>
</label>
<label class="toggle" title="Headed mode (slower animations)">
<input type="checkbox" id="headed" />
<span>--headed</span>
</label>
</div>
</div>
</div>
</div>
<div class="tabs" role="tablist"> <div class="tabs" role="tablist">
<button class="tab is-active" data-tab="report" role="tab">Report</button> <button class="tab is-active" data-tab="report" role="tab">Report</button>
<button class="tab" data-tab="trace" role="tab">Trace</button> <button class="tab" data-tab="trace" role="tab">Trace</button>
<button class="tab" data-tab="source" role="tab">Source</button> <button class="tab" data-tab="source" role="tab">Source</button>
<button class="tab" data-tab="network" role="tab">Network</button>
<button class="tab" data-tab="console" role="tab">Console</button> <button class="tab" data-tab="console" role="tab">Console</button>
<div class="tabs__spacer"></div> <div class="tabs__spacer"></div>
<span class="tabs__meta" id="tabs-meta">portfolio.spec.ts · 9 tests</span>
</div> </div>
<!-- Report --> <!-- Report -->
@ -108,6 +137,9 @@
<p class="hero__sub">Senior SDET · Toronto, ON · <span class="mono">test.describe("portfolio")</span></p> <p class="hero__sub">Senior SDET · Toronto, ON · <span class="mono">test.describe("portfolio")</span></p>
<p class="hero__hint">Click the green <span class="kbd"></span> next to any test to run it — or press <span class="kbd">Run all</span> above.</p> <p class="hero__hint">Click the green <span class="kbd"></span> next to any test to run it — or press <span class="kbd">Run all</span> above.</p>
</div> </div>
<!-- Tiny run-history banner above the results list. Pulls the eye to
dynamic data (e.g. "Last run · 1.4s · 2 passed · 2 pending"). -->
<div class="summary-stripe" id="summary-stripe" aria-live="polite"></div>
<div class="results" id="results"></div> <div class="results" id="results"></div>
</div> </div>
@ -127,6 +159,17 @@
<div class="source" id="source"></div> <div class="source" id="source"></div>
</div> </div>
<!-- Network (Gitea repos as Playwright-style requests) -->
<div class="pane" id="pane-network" role="tabpanel">
<div class="network-pane">
<div class="network-head">
<div class="network-head__title">Network · git.levkin.ca</div>
<div class="network-head__meta"><span class="legend"><i class="sw sw--pass"></i> 200</span><span class="legend mono">application/json</span></div>
</div>
<div class="network-scroll" id="gitea-network" data-repo-list="1"></div>
</div>
</div>
<!-- Console --> <!-- Console -->
<div class="pane" id="pane-console" role="tabpanel"> <div class="pane" id="pane-console" role="tabpanel">
<div class="console" id="console"></div> <div class="console" id="console"></div>
@ -146,7 +189,20 @@
<span class="sb__seg sb__seg--accent">Playwright 1.49</span> <span class="sb__seg sb__seg--accent">Playwright 1.49</span>
</footer> </footer>
<script src="js/data.js"></script> <!-- ============== KEYBOARD SHORTCUTS OVERLAY ============== -->
<script src="js/app.js"></script> <div class="kshelp" id="kshelp" hidden role="dialog" aria-modal="true" aria-labelledby="kshelp-title">
<div class="kshelp__scrim" id="kshelp-scrim"></div>
<div class="kshelp__panel" tabindex="-1">
<div class="kshelp__head">
<span class="kshelp__title" id="kshelp-title">Keyboard shortcuts</span>
<button class="kshelp__close" id="kshelp-close" type="button" aria-label="Close">×</button>
</div>
<div class="kshelp__grid" id="kshelp-grid"></div>
<div class="kshelp__foot mono"><span class="kbd">?</span> toggles · <span class="kbd">Esc</span> closes</div>
</div>
</div>
<script src="js/data.js?v=7"></script>
<script src="js/app.js?v=7"></script>
</body> </body>
</html> </html>

849
js/app.js

File diff suppressed because it is too large Load Diff

View File

@ -6,7 +6,6 @@ window.PORTFOLIO = {
title: "Senior SDET", title: "Senior SDET",
location: "Toronto, Ontario, Canada", location: "Toronto, Ontario, Canada",
email: "idobkin@gmail.com", email: "idobkin@gmail.com",
phone: "+1 (647) 987-2792",
linkedin: "https://www.linkedin.com/in/idobkin/", linkedin: "https://www.linkedin.com/in/idobkin/",
gitea: "https://git.levkin.ca", gitea: "https://git.levkin.ca",
site: "https://iliadobkin.com", site: "https://iliadobkin.com",
@ -22,12 +21,25 @@ window.PORTFOLIO = {
"@cloud","@a11y","@perf","@bdd","@ai","@infra","@leadership" "@cloud","@a11y","@perf","@bdd","@ai","@infra","@leadership"
], ],
/**
* Open spec files drive the editor tab strip above the main pane.
* Each test below carries a `spec` matching one of these ids, so the
* sidebar / report / source can filter by the active spec.
*/
specs: [
{ id: "portfolio", file: "portfolio.spec.ts", describe: "Ilia Dobkin · portfolio" },
{ id: "projects", file: "projects.spec.ts", describe: "Levkin · projects" },
{ id: "skills", file: "skills.spec.ts", describe: "Ilia Dobkin · skills" },
{ id: "playground", file: "playground.spec.ts", describe: "Ilia Dobkin · playground" },
],
// The "test suite" — each entry maps to a section on the page // The "test suite" — each entry maps to a section on the page
suite: { suite: {
name: "Ilia Dobkin · portfolio", name: "Ilia Dobkin · portfolio",
tests: [ tests: [
{ {
id: "about", id: "about",
spec: "portfolio",
title: 'should introduce Ilia Dobkin', title: 'should introduce Ilia Dobkin',
tags: ["@playwright","@leadership"], tags: ["@playwright","@leadership"],
duration: 142, duration: 142,
@ -40,6 +52,7 @@ window.PORTFOLIO = {
}, },
{ {
id: "experience", id: "experience",
spec: "portfolio",
title: 'should list senior SDET experience', title: 'should list senior SDET experience',
tags: ["@playwright","@api","@ci","@cloud","@leadership"], tags: ["@playwright","@api","@ci","@cloud","@leadership"],
duration: 1280, duration: 1280,
@ -52,6 +65,7 @@ window.PORTFOLIO = {
}, },
{ {
id: "skills", id: "skills",
spec: "skills",
title: 'should expose @-tagged skills', title: 'should expose @-tagged skills',
tags: ["@playwright","@cypress","@selenium","@api","@bdd","@ci","@docker","@terraform","@cloud","@a11y","@perf","@ai"], tags: ["@playwright","@cypress","@selenium","@api","@bdd","@ci","@docker","@terraform","@cloud","@a11y","@perf","@ai"],
duration: 412, duration: 412,
@ -63,6 +77,7 @@ window.PORTFOLIO = {
}, },
{ {
id: "projects", id: "projects",
spec: "projects",
title: 'should showcase self-hosted projects', title: 'should showcase self-hosted projects',
tags: ["@infra","@ai","@playwright","@docker"], tags: ["@infra","@ai","@playwright","@docker"],
duration: 680, duration: 680,
@ -74,6 +89,7 @@ window.PORTFOLIO = {
}, },
{ {
id: "stack", id: "stack",
spec: "skills",
title: 'should describe daily stack', title: 'should describe daily stack',
tags: ["@docker","@terraform","@infra","@ai"], tags: ["@docker","@terraform","@infra","@ai"],
duration: 320, duration: 320,
@ -85,6 +101,7 @@ window.PORTFOLIO = {
}, },
{ {
id: "leadership", id: "leadership",
spec: "skills",
title: 'should demonstrate quality leadership', title: 'should demonstrate quality leadership',
tags: ["@leadership","@ci"], tags: ["@leadership","@ci"],
duration: 220, duration: 220,
@ -96,6 +113,7 @@ window.PORTFOLIO = {
}, },
{ {
id: "metrics", id: "metrics",
spec: "skills",
title: 'should report quality KPIs', title: 'should report quality KPIs',
tags: ["@ci","@perf"], tags: ["@ci","@perf"],
duration: 96, duration: 96,
@ -107,6 +125,7 @@ window.PORTFOLIO = {
}, },
{ {
id: "resume", id: "resume",
spec: "portfolio",
title: 'should expose downloadable resume', title: 'should expose downloadable resume',
tags: ["@playwright"], tags: ["@playwright"],
duration: 64, duration: 64,
@ -118,6 +137,7 @@ window.PORTFOLIO = {
}, },
{ {
id: "contact", id: "contact",
spec: "portfolio",
title: 'should accept inbound contact', title: 'should accept inbound contact',
tags: ["@api"], tags: ["@api"],
duration: 88, duration: 88,
@ -127,6 +147,62 @@ window.PORTFOLIO = {
], ],
render: renderContact render: renderContact
}, },
{
id: "perf-budget",
spec: "portfolio",
title: 'should meet performance budget',
skip: true,
skipReason: "Lighthouse CI not wired — pending infra (see IDEAS.md)",
tags: ["@perf","@ci"],
duration: 0,
steps: [
{ kind: "skip", title: 'run lighthouse --budget', dur: 0 },
{ kind: "skip", title: 'assert LCP < 2.5s', dur: 0 },
{ kind: "skip", title: 'assert CLS < 0.1', dur: 0 },
],
render: renderPerfBudget
},
{
id: "response-time",
spec: "portfolio",
title: 'should match expected response time',
fail: true,
failMessage: `Error: expect(received).toBeLessThan(expected)
Expected: < 200
Received: 347
at api.spec.ts:42:31
GET /api/v1/repos/ilia/portfolio 347 ms
Retry 1/2 312 ms
Retry 2/2 289 ms
Threshold: 200 ms · Actual p95: 316 ms
Hint: latency spike possibly cold-start or DNS`,
tags: ["@api","@perf"],
duration: 0,
steps: [
{ kind: "ok", title: 'navigate to Gitea API endpoint', dur: 45 },
{ kind: "ok", title: 'send GET /api/v1/repos/ilia/portfolio', dur: 62 },
{ kind: "ok", title: 'assert status 200', dur: 8 },
{ kind: "fail", title: 'expect(latency).toBeLessThan(200)', dur: 347 },
],
render: renderResponseTime
},
{
id: "vibe-check",
spec: "playground",
title: 'should pass the vibe check',
tags: ["@playwright"],
duration: 110,
steps: [
{ kind: "info", title: 'await coffee.brew()', dur: 18 },
{ kind: "ok", title: "expect(mood).toBe('☕')", dur: 32 },
{ kind: "ok", title: 'expect(typing).toBeRhythmic', dur: 60 },
],
render: renderVibe
},
] ]
}, },
@ -234,7 +310,8 @@ window.PORTFOLIO = {
skills: [ skills: [
{ name: "Test automation: Playwright, Cypress, Selenium, SilkTest; UI, API, mobile, cross-browser; POM, BDD", level: 96, tags: ["@playwright","@cypress","@selenium","@bdd","@api"] }, { name: "Test automation: Playwright, Cypress, Selenium, SilkTest; UI, API, mobile, cross-browser; POM, BDD", level: 96, tags: ["@playwright","@cypress","@selenium","@bdd","@api"] },
{ name: "Languages & frameworks: TypeScript, JavaScript, C#, .NET, Python, Java, Bash, Node.js", level: 92, tags: [] }, { name: "Languages: TypeScript, JavaScript, C#, Python, Java, Bash/Shell", level: 92, tags: [] },
{ name: "Frameworks & runtimes: .NET (.NET Core, ASP.NET), Node.js, Spring Boot; markup: HTML/CSS", level: 90, tags: [] },
{ name: "CI/CD & DevOps: GitHub Actions, GitLab, Bitbucket, Jenkins, Azure DevOps; Git, Terraform, Ansible, Docker", level: 92, tags: ["@ci","@docker","@terraform"] }, { name: "CI/CD & DevOps: GitHub Actions, GitLab, Bitbucket, Jenkins, Azure DevOps; Git, Terraform, Ansible, Docker", level: 92, tags: ["@ci","@docker","@terraform"] },
{ name: "Cloud & infra: AWS, Azure, GCP; Linux administration, Proxmox, Caddy, TrueNAS", level: 84, tags: ["@cloud","@infra"] }, { name: "Cloud & infra: AWS, Azure, GCP; Linux administration, Proxmox, Caddy, TrueNAS", level: 84, tags: ["@cloud","@infra"] },
{ name: "Observability & performance: Grafana, Prometheus, Sentry, DataDog, Artillery, k6, JMeter", level: 86, tags: ["@perf"] }, { name: "Observability & performance: Grafana, Prometheus, Sentry, DataDog, Artillery, k6, JMeter", level: 86, tags: ["@perf"] },
@ -279,6 +356,33 @@ window.PORTFOLIO = {
{ label: "Manual regression reduction", value: "≈ 50%" }, { label: "Manual regression reduction", value: "≈ 50%" },
{ label: "SpecFlow scenarios maintained", value: "3,500+" }, { label: "SpecFlow scenarios maintained", value: "3,500+" },
{ label: "Years shipping software", value: "20+" } { label: "Years shipping software", value: "20+" }
],
/**
* Public repos on git.levkin.ca descriptions from Gitea API when set,
* otherwise first paragraph of README (see scripts/fetch-gitea-repos.mjs).
* API lists all 19 repos on one page (explore UI may paginate).
*/
giteaRepos: [
{ full_name: "ilia/ansible", name: "ansible", html_url: "https://git.levkin.ca/ilia/ansible", language: "Makefile", description: "Ansible automation for development machines, service hosts, and Proxmox-managed guests (LXC-first, with a path for KVM VMs)." },
{ full_name: "ilia/AtAnyRate", name: "AtAnyRate", html_url: "https://git.levkin.ca/ilia/AtAnyRate", language: "Python", description: "Local Python application that identifies upcoming Toronto events likely to increase Airbnb demand, sends Telegram alerts, and optionally adjusts nightly prices via Playwright automation." },
{ full_name: "ilia/atlas", name: "atlas", html_url: "https://git.levkin.ca/ilia/atlas", language: "Python", description: "Atlas is a local, privacy-focused home voice agent system — planning, architecture documentation, and kanban tickets for building the system." },
{ full_name: "ilia/crkl", name: "crkl", html_url: "https://git.levkin.ca/ilia/crkl", language: "Kotlin", description: "Privacy-first Android AI assistant — circle or touch any element on-screen; on-device AI transcribes, summarizes, explains, or drafts responses." },
{ full_name: "ilia/dotfiles", name: "dotfiles", html_url: "https://git.levkin.ca/ilia/dotfiles", language: "", description: "Dotfiles and shell configuration for dev machines." },
{ full_name: "ilia/hilitehero", name: "hilitehero", html_url: "https://git.levkin.ca/ilia/hilitehero", language: "Python", description: "Python tool for extracting highlighted text from PDFs with precise ordering and hyphenation handling." },
{ full_name: "ilia/invoice", name: "invoice", html_url: "https://git.levkin.ca/ilia/invoice", language: "JavaScript", description: "CLI for generating professional PDF invoices from JSON — interactive and non-interactive modes with preview-first workflow." },
{ full_name: "ilia/Jobber", name: "Jobber", html_url: "https://git.levkin.ca/ilia/Jobber", language: "TypeScript", description: "Self-hosted job search orchestration — discover roles, score fit, draft resumes and cover letters, export PDFs, track email; you submit applications yourself." },
{ full_name: "ilia/kanban", name: "kanban", html_url: "https://git.levkin.ca/ilia/kanban", language: "", description: "Kanban board project on self-hosted Gitea." },
{ full_name: "ilia/linkedout", name: "linkedout", html_url: "https://git.levkin.ca/ilia/linkedout", language: "JavaScript", description: "Job market intelligence platform with integrated AI-powered insights — modular architecture for extensibility." },
{ full_name: "ilia/llm_council", name: "llm_council", html_url: "https://git.levkin.ca/ilia/llm_council", language: "Python", description: "Local web UI like ChatGPT but sends each query to multiple LLMs — your \"LLM council\" votes with diverse models." },
{ full_name: "ilia/mirror_match", name: "mirror_match", html_url: "https://git.levkin.ca/ilia/mirror_match", language: "TypeScript", description: "Photo guessing game — upload photos, others guess who is in the picture for points. Next.js, PostgreSQL, NextAuth." },
{ full_name: "ilia/nanobot", name: "nanobot", html_url: "https://git.levkin.ca/ilia/nanobot", language: "Python", description: "Ultra-lightweight personal AI assistant (Python; published on PyPI as nanobot-ai)." },
{ full_name: "ilia/onboarding", name: "onboarding", html_url: "https://git.levkin.ca/ilia/onboarding", language: "Shell", description: "Developer environment setup — automates 60+ apps and tools plus Git and SSH configuration." },
{ full_name: "ilia/outreach", name: "outreach", html_url: "https://git.levkin.ca/ilia/outreach", language: "JavaScript", description: "Node.js email outreach for campaigns to law firms — templates, tracking, and tests." },
{ full_name: "ilia/POTE", name: "POTE", html_url: "https://git.levkin.ca/ilia/POTE", language: "Python", description: "Research-oriented tool for tracking and analyzing public stock trades by government officials." },
{ full_name: "ilia/profile", name: "profile", html_url: "https://git.levkin.ca/ilia/profile", language: "TypeScript", description: "Profile / personal site — TypeScript." },
{ full_name: "ilia/punimtag", name: "punimtag", html_url: "https://git.levkin.ca/ilia/punimtag", language: "TypeScript", description: "Modern photo management and facial recognition system." },
{ full_name: "ilia/resume", name: "resume", html_url: "https://git.levkin.ca/ilia/resume", language: "", description: "Résumé generator based on the best-resume-ever project." }
] ]
}; };
@ -311,8 +415,8 @@ function renderAbout(){
function renderExperience(){ function renderExperience(){
return `<div class="block">${ return `<div class="block">${
PORTFOLIO.experience.map((e,i)=>` PORTFOLIO.experience.map((e,i)=>`
<h4>${_esc(e.company)} <span style="color:var(--text-4);font-weight:400"> ${_esc(e.role)}</span></h4> <h4>${_esc(e.company)}</h4>
<p style="font-family:var(--font-mono);font-size:11.5px;color:var(--text-3);margin:2px 0 6px">${_esc(e.when)} · ${_esc(e.where)}</p> <p style="font-family:var(--font-mono);font-size:11.5px;color:var(--text-3);margin:2px 0 8px;line-height:1.45">${_esc(e.role)} · ${_esc(e.when)} · ${_esc(e.where)}</p>
<ul>${e.bullets.map(b=>`<li>${_esc(b)}</li>`).join('')}</ul> <ul>${e.bullets.map(b=>`<li>${_esc(b)}</li>`).join('')}</ul>
`).join('') `).join('')
}</div>`; }</div>`;
@ -342,7 +446,9 @@ function renderProjects(){
<p>${_esc(p.desc)}</p> <p>${_esc(p.desc)}</p>
${_tags(p.tags)} ${_tags(p.tags)}
</div>`).join('') </div>`).join('')
}</div></div>`; }</div>
<p class="projects-foot">Public code on <a href="https://git.levkin.ca/explore/repos" target="_blank" rel="noopener">git.levkin.ca</a> open the <button type="button" class="tab-link" data-tab="network">Network</button> tab for an API-style request list.</p>
</div>`;
} }
function renderStack(){ function renderStack(){
@ -382,12 +488,12 @@ function renderMetrics(){
function renderResume(){ function renderResume(){
return `<div class="block"> return `<div class="block">
<p>Full PDF resume generated from this same source of truth.</p> <p>PDF resume the same file used for applications.</p>
<div class="cta-row"> <div class="cta-row">
<button class="cta" id="dl-resume"> download resume.pdf</button> <button class="cta" id="dl-resume"> download resume.pdf</button>
<button class="cta cta--ghost" id="print-resume">print()</button> <button class="cta cta--ghost" id="print-resume">open in tab</button>
</div> </div>
<p style="color:var(--text-4);font-size:11.5px;font-family:var(--font-mono);margin-top:10px">// resume renders inline using the print stylesheet — try ⌘P</p> <p style="color:var(--text-4);font-size:11.5px;font-family:var(--font-mono);margin-top:10px">// opens the PDF for viewing or printing from the browser</p>
</div>`; </div>`;
} }
@ -395,10 +501,67 @@ function renderContact(){
const p = PORTFOLIO.person; const p = PORTFOLIO.person;
return `<div class="block"><div class="contact-grid"> return `<div class="block"><div class="contact-grid">
<div class="contact-cell"><label>email</label><a href="mailto:${p.email}">${p.email}</a></div> <div class="contact-cell"><label>email</label><a href="mailto:${p.email}">${p.email}</a></div>
<div class="contact-cell"><label>phone</label>${p.phone}</div>
<div class="contact-cell"><label>linkedin</label><a href="${p.linkedin}" target="_blank" rel="noopener">in/idobkin</a></div> <div class="contact-cell"><label>linkedin</label><a href="${p.linkedin}" target="_blank" rel="noopener">in/idobkin</a></div>
<div class="contact-cell"><label>gitea (self-hosted)</label><a href="${p.gitea}" target="_blank" rel="noopener">git.levkin.ca</a></div> <div class="contact-cell"><label>gitea (self-hosted)</label><a href="${p.gitea}" target="_blank" rel="noopener">git.levkin.ca</a></div>
<div class="contact-cell"><label>site</label><a href="${p.site}" target="_blank" rel="noopener">iliadobkin.com</a></div> <div class="contact-cell"><label>site</label><a href="${p.site}" target="_blank" rel="noopener">iliadobkin.com</a></div>
<div class="contact-cell"><label>location</label>${p.location}</div> <div class="contact-cell"><label>location</label>${p.location}</div>
</div></div>`; </div></div>`;
} }
function renderPerfBudget(){
return `<div class="block">
<p style="color:var(--skip)">Lighthouse CI not wired pending infra (see IDEAS.md)</p>
<p>Once connected, this test will assert Core Web Vitals on every deploy: LCP&lt;2.5s, CLS&lt;0.1, TBT&lt;200ms.</p>
</div>`;
}
function renderResponseTime(){
return `<div class="block">
<p>The Gitea API endpoint <code>GET /api/v1/repos/ilia/portfolio</code> exceeded the <strong>200 ms</strong> latency budget three times in a row.</p>
<h4>What happened</h4>
<ul>
<li>Initial request returned in <strong>347 ms</strong> likely a cold-start on the VPS.</li>
<li>Retry 1: <strong>312 ms</strong>, Retry 2: <strong>289 ms</strong> trending down but still above threshold.</li>
<li>The p95 across the three attempts was <strong>316 ms</strong>.</li>
</ul>
<h4>Possible fixes</h4>
<ul>
<li>Add a keep-alive cron to prevent cold-starts.</li>
<li>Enable response caching on the reverse proxy.</li>
<li>Raise the threshold to 350 ms if p50 is acceptable.</li>
</ul>
<div class="snippet"><div class="ln">1
2
3
4
5
6</div><div class="code"><span class="cm">// api.spec.ts:42</span>
<span class="kw">const</span> res = <span class="kw">await</span> request.get(<span class="str">'/api/v1/repos/ilia/portfolio'</span>);
<span class="kw">const</span> latency = res.headers[<span class="str">'x-response-time'</span>];
expect(res.status()).toBe(<span class="num">200</span>);
expect(Number(latency)).toBeLessThan(<span class="num">200</span>);
<span class="cm">// ✗ Expected: &lt; 200 · Received: 347</span></div></div>
</div>`;
}
function renderVibe(){
return `<div class="block">
<p><code>playground.spec.ts</code> is reserved for interactive demos and small experiments the things that don't quite belong in the resume but make this site fun to poke at.</p>
<h4>On the runway</h4>
<ul>
<li><strong>Real Playwright tests of this site</strong> meta, self-referential, satisfying. The runner that <em>looks</em> like a Playwright report becomes one that <em>is</em> verified by Playwright.</li>
<li><strong>Recording mode</strong> MediaRecorder API captures a 20s Run-All clip you can drop into Slack.</li>
<li><strong>Narrative replay</strong> click a tag, watch the runner play that storyline end-to-end.</li>
<li><strong>Konami easter egg</strong> because every test runner deserves one.</li>
</ul>
<div class="snippet"><div class="ln">1
2
3
4
5</div><div class="code"><span class="cm">// playground.spec.ts</span>
<span class="kw">test</span>(<span class="str">'should pass the vibe check'</span>, <span class="kw">async</span> ({ page }) =&gt; {
<span class="kw">const</span> coffee = <span class="kw">await</span> kitchen.brew();
<span class="kw">await</span> expect(coffee.temperature).toBeWithinRange(<span class="num">63</span>, <span class="num">68</span>);
});</div></div>
</div>`;
}

1119
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

15
package.json Normal file
View File

@ -0,0 +1,15 @@
{
"name": "portfolio-tests",
"private": true,
"description": "Playwright tests for iliadobkin.com — the portfolio that looks like a Playwright report, tested by actual Playwright.",
"scripts": {
"test": "playwright test",
"test:headed": "playwright test --headed",
"test:ui": "playwright test --ui",
"report": "playwright show-report"
},
"devDependencies": {
"@playwright/test": "^1.52.0",
"serve": "^14.2.6"
}
}

34
playwright.config.ts Normal file
View File

@ -0,0 +1,34 @@
import { defineConfig, devices } from "@playwright/test";
const PORT = 3173;
export default defineConfig({
testDir: "./tests",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 8 : undefined,
reporter: "html",
timeout: 30_000,
use: {
baseURL: process.env.BASE_URL || `http://localhost:${PORT}`,
trace: "on-first-retry",
screenshot: "only-on-failure",
headless: true,
},
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
// { name: "firefox", use: { ...devices["Desktop Firefox"] } },
// { name: "webkit", use: { ...devices["Desktop Safari"] } },
],
webServer: process.env.BASE_URL
? undefined
: {
command: `npx serve -l ${PORT} --no-clipboard`,
port: PORT,
reuseExistingServer: !process.env.CI,
},
});

View File

@ -0,0 +1,95 @@
#!/usr/bin/env node
/**
* Refresh PORTFOLIO.giteaRepos descriptions from git.levkin.ca:
* uses GET /api/v1/repos/search (all public repos), then README.md per repo
* when description is empty.
*
* Run from repo root: node scripts/fetch-gitea-repos.mjs
* Paste the printed `giteaRepos: [...]` block into js/data.js (no npm deps).
*/
import https from 'https';
function get(url) {
return new Promise((resolve, reject) => {
https.get(url, { headers: { 'User-Agent': 'portfolio-fetch-gitea/1' } }, (res) => {
let d = '';
res.on('data', c => { d += c; });
res.on('end', () => resolve({ status: res.statusCode, body: d }));
}).on('error', reject);
});
}
async function readmeSnippet(owner, repo, branch) {
const url = `https://git.levkin.ca/api/v1/repos/${owner}/${repo}/contents/README.md?ref=${branch}`;
const { status, body } = await get(url);
if (status !== 200) return null;
let j;
try { j = JSON.parse(body); } catch { return null; }
if (!j.content) return null;
const text = Buffer.from(j.content, 'base64').toString('utf8');
const lines = text.split(/\r?\n/);
let i = 0;
while (i < lines.length && (/^#+\s/.test(lines[i]) || lines[i].trim() === '')) i++;
const para = [];
for (; i < lines.length; i++) {
const L = lines[i].trim();
if (L === '') break;
if (/^#+\s/.test(lines[i])) break;
para.push(L.replace(/^[-*]\s+/, ''));
}
let s = para.join(' ').replace(/\s+/g, ' ').trim();
if (!s) {
for (const line of lines) {
const t = line.trim();
if (t && !t.startsWith('#') && !t.startsWith('```')) { s = t; break; }
}
}
if (s.includes('<')) {
s = s.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
}
if (s.length > 280) s = s.slice(0, 277) + '…';
return s || null;
}
function jsString(s) {
return JSON.stringify(s);
}
(async () => {
const { body } = await get('https://git.levkin.ca/api/v1/repos/search?limit=100&page=1');
const j = JSON.parse(body);
const repos = j.data.sort((a, b) => a.full_name.localeCompare(b.full_name));
const rows = [];
for (const r of repos) {
const [owner, name] = r.full_name.split('/');
let desc = (r.description || '').trim();
if (!desc) {
desc = await readmeSnippet(owner, name, r.default_branch || 'main');
if (!desc && r.default_branch !== 'master') {
desc = await readmeSnippet(owner, name, 'master');
}
}
if (!desc) {
desc = r.language ? `${r.language} repository.` : 'Self-hosted repo on git.levkin.ca.';
}
rows.push({
full_name: r.full_name,
name,
html_url: r.html_url,
language: r.language || '',
description: desc,
});
await new Promise(res => setTimeout(res, 100));
}
console.log('// --- Replace PORTFOLIO.giteaRepos in js/data.js with: ---\n');
console.log(' giteaRepos: [');
for (const row of rows) {
console.log(` { full_name: ${jsString(row.full_name)}, name: ${jsString(row.name)}, html_url: ${jsString(row.html_url)}, language: ${jsString(row.language)}, description: ${jsString(row.description)} },`);
}
console.log(' ],');
console.error(`\n// Done — ${rows.length} repos (API lists all public repos on page 1).`);
})();

443
tests/portfolio.spec.ts Normal file
View File

@ -0,0 +1,443 @@
import { test, expect, type Page } from "@playwright/test";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Wait for idle UI state (no spinners, status says "idle" or "all passed"). */
async function waitForIdle(page: Page) {
await expect(page.locator("#status-text")).not.toHaveText("running", {
timeout: 15_000,
});
}
/** Click "Run all" and wait for every runnable test to finish. */
async function runAllAndWait(page: Page) {
await page.click("#run-all");
await expect(page.locator("#status-text")).toHaveText("all passed", {
timeout: 30_000,
});
}
/** Run a single test by clicking its ▶ button in the sidebar tree. */
async function runSingleTest(page: Page, testId: string) {
await page.click(`.test[data-id="${testId}"] .test__run`);
await expect(page.locator(`#result-${testId}`)).toHaveClass(/is-open/, {
timeout: 15_000,
});
}
// ---------------------------------------------------------------------------
// Smoke — page loads and renders the shell
// ---------------------------------------------------------------------------
test.describe("smoke", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/");
});
test("page has correct title", async ({ page }) => {
await expect(page).toHaveTitle(/Ilia Dobkin/);
});
test("hero section renders name and role", async ({ page }) => {
await expect(page.locator(".hero__title")).toHaveText("Ilia Dobkin");
await expect(page.locator(".hero__sub")).toContainText("Senior SDET");
});
test("topbar contains Run all, Stop, Reset, and Theme toggle", async ({
page,
}) => {
await expect(page.locator("#run-all")).toBeVisible();
await expect(page.locator("#stop-all")).toBeVisible();
await expect(page.locator("#reset-all")).toBeVisible();
await expect(page.locator("#theme-toggle")).toBeVisible();
});
test("sidebar test explorer lists tests", async ({ page }) => {
const tests = page.locator("#tree .test");
await expect(tests.first()).toBeVisible();
expect(await tests.count()).toBeGreaterThan(0);
});
test("status bar shows Playwright version", async ({ page }) => {
await expect(page.locator(".statusbar")).toContainText("Playwright");
});
test("five tabs exist: Report, Trace, Source, Network, Console", async ({
page,
}) => {
for (const label of ["Report", "Trace", "Source", "Network", "Console"]) {
await expect(page.locator(`.tab[data-tab]`, { hasText: label })).toBeVisible();
}
});
});
// ---------------------------------------------------------------------------
// Run engine — Run All, individual runs, reset
// ---------------------------------------------------------------------------
test.describe("run engine", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/");
await waitForIdle(page);
});
test("Run All executes and all tests pass", async ({ page }) => {
await runAllAndWait(page);
const passCount = page.locator("#cnt-pass");
expect(Number(await passCount.textContent())).toBeGreaterThan(0);
await expect(page.locator("#cnt-fail")).toHaveText("0");
});
test("clicking ▶ on a single test runs only that test", async ({
page,
}) => {
await runSingleTest(page, "about");
await expect(
page.locator('.test[data-id="about"]')
).toHaveClass(/is-passed/);
const passCount = Number(await page.locator("#cnt-pass").textContent());
expect(passCount).toBeGreaterThanOrEqual(1);
});
test("Reset clears all results back to idle", async ({ page }) => {
await runAllAndWait(page);
await page.click("#reset-all");
await expect(page.locator("#status-text")).toHaveText("idle");
await expect(page.locator("#cnt-pass")).toHaveText("0");
});
test("skipped test shows skip icon and badge", async ({ page }) => {
const skippedRow = page.locator('.test[data-id="perf-budget"]');
await expect(skippedRow).toHaveClass(/is-skipped/);
});
});
// ---------------------------------------------------------------------------
// Theme toggle
// ---------------------------------------------------------------------------
test.describe("theme", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/");
});
test("starts in dark mode by default", async ({ page }) => {
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
});
test("theme toggle cycles through dark → light → hc → dark", async ({
page,
}) => {
await page.click("#theme-toggle");
await expect(page.locator("html")).toHaveAttribute("data-theme", "light");
await page.click("#theme-toggle");
await expect(page.locator("html")).toHaveAttribute("data-theme", "hc");
await page.click("#theme-toggle");
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
});
});
// ---------------------------------------------------------------------------
// Tab navigation
// ---------------------------------------------------------------------------
test.describe("tabs", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/");
});
test("clicking each tab reveals corresponding pane", async ({ page }) => {
const tabs = ["report", "trace", "source", "network", "console"];
for (const tabId of tabs) {
await page.click(`.tab[data-tab="${tabId}"]`);
await expect(page.locator(`#pane-${tabId}`)).toHaveClass(/is-active/);
}
});
test("Source tab renders spec-like code", async ({ page }) => {
await page.click('.tab[data-tab="source"]');
await expect(page.locator("#source")).toContainText("import");
await expect(page.locator("#source")).toContainText("test.describe");
});
test("Trace tab renders career timeline", async ({ page }) => {
await page.click('.tab[data-tab="trace"]');
const rows = page.locator(".trace-row");
expect(await rows.count()).toBeGreaterThan(0);
});
test("Network tab renders Gitea repo rows", async ({ page }) => {
await page.click('.tab[data-tab="network"]');
const entries = page.locator(".network-entry");
await expect(entries.first()).toBeVisible();
expect(await entries.count()).toBeGreaterThan(0);
});
test("Console tab logs events after a test run", async ({ page }) => {
await runSingleTest(page, "about");
await page.click('.tab[data-tab="console"]');
const lines = page.locator(".console__line");
expect(await lines.count()).toBeGreaterThan(0);
});
});
// ---------------------------------------------------------------------------
// Editor strip — switching spec files
// ---------------------------------------------------------------------------
test.describe("editor strip", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/");
});
test("editor tabs exist for all spec files", async ({ page }) => {
for (const file of [
"portfolio.spec.ts",
"projects.spec.ts",
"skills.spec.ts",
"playground.spec.ts",
]) {
await expect(
page.locator(".espec", { hasText: file })
).toBeVisible();
}
});
test("clicking a spec tab switches the active spec", async ({ page }) => {
await page.click('.espec[data-spec="projects"]');
await expect(
page.locator('.espec[data-spec="projects"]')
).toHaveClass(/is-active/);
const crumb = page.locator(".crumb strong");
await expect(crumb).toHaveText("projects.spec.ts");
});
test("switching spec shows Report pane with updated hero", async ({
page,
}) => {
await page.click('.espec[data-spec="projects"]');
await expect(page.locator("#pane-report")).toHaveClass(/is-active/);
await expect(page.locator(".hero__title")).toHaveText("Projects");
});
test("switching spec updates the Source tab", async ({ page }) => {
await page.click('.espec[data-spec="skills"]');
await page.click('.tab[data-tab="source"]');
await expect(page.locator("#source")).toContainText("skills");
});
});
// ---------------------------------------------------------------------------
// Sidebar — grep filter and tag chips
// ---------------------------------------------------------------------------
test.describe("filtering", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/");
});
test("typing in grep input filters visible tests", async ({ page }) => {
const allBefore = await page.locator("#tree .test").count();
await page.fill("#grep", "resume");
await page.waitForTimeout(200);
const visible = page.locator('#tree .test:not([style*="display: none"])');
const visibleCount = await visible.count();
expect(visibleCount).toBeLessThan(allBefore);
expect(visibleCount).toBeGreaterThan(0);
});
test("clicking a tag chip activates it and filters", async ({ page }) => {
const tag = page.locator('#tag-bar .tag[data-tag="@api"]');
await tag.click();
await expect(tag).toHaveClass(/is-active/);
await expect(page.locator("#grep")).toHaveValue(/@api/);
});
test("clear button removes all tag filters", async ({ page }) => {
await page.click('#tag-bar .tag[data-tag="@api"]');
await page.click("#tag-bar [data-clear]");
await expect(page.locator("#grep")).toHaveValue("");
});
});
// ---------------------------------------------------------------------------
// Keyboard shortcuts
// ---------------------------------------------------------------------------
test.describe("keyboard shortcuts", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/");
await waitForIdle(page);
});
test("pressing ? opens keyboard shortcuts overlay", async ({ page }) => {
await page.keyboard.press("?");
await expect(page.locator("#kshelp")).not.toHaveAttribute("hidden", "");
});
test("pressing Escape closes the shortcuts overlay", async ({ page }) => {
await page.keyboard.press("?");
await expect(page.locator("#kshelp")).not.toHaveAttribute("hidden", "");
await page.keyboard.press("Escape");
await expect(page.locator("#kshelp")).toHaveAttribute("hidden", "");
});
test("pressing T toggles theme", async ({ page }) => {
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
await page.keyboard.press("t");
await expect(page.locator("html")).toHaveAttribute("data-theme", "light");
});
test("pressing / focuses the grep input", async ({ page }) => {
await page.keyboard.press("/");
await expect(page.locator("#grep")).toBeFocused();
});
});
// ---------------------------------------------------------------------------
// Overflow menu (workers / headed)
// ---------------------------------------------------------------------------
test.describe("overflow menu", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/");
});
test("clicking ⋯ opens the overflow menu", async ({ page }) => {
await page.click("#overflow-btn");
await expect(page.locator("#overflow-menu")).not.toHaveAttribute(
"hidden",
""
);
});
test("workers dropdown is accessible", async ({ page }) => {
await page.click("#overflow-btn");
await expect(page.locator("#workers")).toBeVisible();
await page.selectOption("#workers", "4");
await expect(page.locator("#workers")).toHaveValue("4");
});
});
// ---------------------------------------------------------------------------
// Network tab — expand/collapse repo detail
// ---------------------------------------------------------------------------
test.describe("network detail", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/");
await page.click('.tab[data-tab="network"]');
});
test("clicking a network row expands its detail panel", async ({ page }) => {
const firstEntry = page.locator(".network-entry").first();
await firstEntry.locator(".network-row").click();
await expect(firstEntry).toHaveClass(/is-open/);
await expect(firstEntry.locator(".network-detail")).not.toHaveAttribute(
"hidden",
""
);
});
test("expanded detail shows JSON body and repo link", async ({ page }) => {
const firstEntry = page.locator(".network-entry").first();
await firstEntry.locator(".network-row").click();
await expect(
firstEntry.locator(".network-detail__body")
).toContainText("full_name");
await expect(
firstEntry.locator(".network-detail__link")
).toBeVisible();
});
});
// ---------------------------------------------------------------------------
// Accessibility basics
// ---------------------------------------------------------------------------
test.describe("accessibility", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/");
});
test("all interactive buttons have accessible names", async ({ page }) => {
const buttons = page.locator(
"button:visible:not(.tag):not(.espec):not(.tab):not(.cmdpal__item)"
);
const count = await buttons.count();
for (let i = 0; i < count; i++) {
const btn = buttons.nth(i);
const name = await btn.getAttribute("aria-label");
const title = await btn.getAttribute("title");
const text = (await btn.textContent())?.trim();
const hasName = (name && name.length > 0) ||
(title && title.length > 0) ||
(text && text.length > 0);
expect(hasName, `Button ${i} should have an accessible name`).toBe(true);
}
});
test("landmark roles are present", async ({ page }) => {
await expect(page.locator('[role="banner"]')).toBeVisible();
await expect(page.locator('[role="contentinfo"]')).toBeVisible();
});
});
// ---------------------------------------------------------------------------
// Responsive — mobile viewport
// ---------------------------------------------------------------------------
test.describe("responsive", () => {
test.use({ viewport: { width: 375, height: 812 } });
test("sidebar is hidden by default on mobile", async ({ page }) => {
await page.goto("/");
const sidebar = page.locator("#sidebar");
await expect(sidebar).not.toHaveClass(/is-open/);
});
test("menu button opens sidebar drawer on mobile", async ({ page }) => {
await page.goto("/");
await page.click("#menu-btn");
await expect(page.locator("#sidebar")).toHaveClass(/is-open/);
});
});
// ---------------------------------------------------------------------------
// End-to-end: full run lifecycle
// ---------------------------------------------------------------------------
test.describe("full lifecycle", () => {
test("run all → verify results → reset → verify idle", async ({ page }) => {
await page.goto("/");
await waitForIdle(page);
await runAllAndWait(page);
const passed = Number(await page.locator("#cnt-pass").textContent());
expect(passed).toBeGreaterThan(0);
await expect(page.locator("#cnt-fail")).toHaveText("0");
await expect(page.locator("#summary-stripe")).toContainText("passed");
await page.click("#reset-all");
await expect(page.locator("#status-text")).toHaveText("idle");
await expect(page.locator("#cnt-pass")).toHaveText("0");
});
});