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:
parent
7564148c3c
commit
b596b2d608
7
.gitignore
vendored
7
.gitignore
vendored
@ -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
|
||||||
|
|||||||
93
IDEAS.md
93
IDEAS.md
@ -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 5–10 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 5–10 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 1–2 GIFs or screenshots in this README
|
- [ ] Add 1–2 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?"
|
|
||||||
|
|||||||
130
README.md
130
README.md
@ -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, `1–9` run Nth test
|
||||||
- Mobile drawer for the test explorer, fully responsive
|
- Mobile drawer for the test explorer, fully responsive
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -30,13 +41,14 @@ 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 |
|
||||||
|
| Tests (dev) | `@playwright/test` against `npx serve` |
|
||||||
| Hosting | Any static host (S3, Netlify, GitHub Pages, your homelab) |
|
| 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)` |
|
||||||
|
|||||||
BIN
assets/ilia-dobkin-resume.pdf
Normal file
BIN
assets/ilia-dobkin-resume.pdf
Normal file
Binary file not shown.
528
css/app.css
528
css/app.css
@ -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 — 1–2px 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; }
|
||||||
|
}
|
||||||
|
|||||||
45
css/base.css
45
css/base.css
@ -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 {
|
||||||
|
|||||||
102
index.html
102
index.html
@ -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>
|
||||||
|
|||||||
181
js/data.js
181
js/data.js
@ -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<2.5s, CLS<0.1, TBT<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: < 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 }) => {
|
||||||
|
<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
1119
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
15
package.json
Normal file
15
package.json
Normal 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
34
playwright.config.ts
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
95
scripts/fetch-gitea-repos.mjs
Normal file
95
scripts/fetch-gitea-repos.mjs
Normal 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
443
tests/portfolio.spec.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user