Add lint, typecheck, and html-validate guardrails

Tooling:
- ESLint 9 flat config (eslint.config.mjs) with three scopes:
  - browser vanilla JS for js/ (no modules, window globals)
  - Node ESM for scripts/
  - typescript-eslint for tests/ and *.ts
- Stylelint with a deliberately minimal "bug-only" ruleset
  (block-no-empty, color-no-invalid-hex, function-no-unknown,
  property-no-unknown, …) — no nags about compact handwritten CSS
- html-validate against index.html (DOCTYPE, accessible names,
  non-redundant ARIA roles, valid landmark usage)
- TypeScript --noEmit strict on playwright.config.ts + tests/*.ts

npm scripts:
- lint        — parallel run of lint:js, lint:css, lint:html (~3s)
- lint:js{,:fix}, lint:css{,:fix}, lint:html
- typecheck   — tsc --noEmit
- check       — lint + typecheck + test in parallel (CI entry point)

Fixes uncovered by the new checks:
- js/app.js: drop unused `activeTimers`, drop unused `t` lookup in
  refreshTreeRow, switch `activeTags` let→const, replace empty
  `catch(_)` blocks with parameter-less catch + intent comment
- js/data.js: drop unused index arg in renderExperience
- index.html: uppercase DOCTYPE, drop redundant role="banner" /
  role="contentinfo" on <header>/<footer>, add aria-labels on icon-only
  Stop / Reset buttons, add role="group" to the tag bar so its
  aria-label is valid, add explicit type="button" everywhere
- tests/portfolio.spec.ts: landmark test now uses getByRole() rather
  than the explicit [role="banner"] attribute selector

Housekeeping:
- .gitignore picks up .eslintcache / .stylelintcache / *.tsbuildinfo
- README documents the lint + check toolchain

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Builder 2026-05-11 23:06:42 -04:00
parent b596b2d608
commit 6f897fafb9
12 changed files with 3447 additions and 29 deletions

5
.gitignore vendored
View File

@ -21,6 +21,11 @@ playwright-report/
# Dependencies (test-only) # Dependencies (test-only)
node_modules/ node_modules/
# Lint / typecheck caches
.eslintcache
.stylelintcache
*.tsbuildinfo
# Local server / scratch # Local server / scratch
.cache/ .cache/
tmp/ tmp/

10
.htmlvalidate.json Normal file
View File

@ -0,0 +1,10 @@
{
"extends": ["html-validate:recommended"],
"rules": {
"void-style": "off",
"no-inline-style": "off",
"no-trailing-whitespace": "warn",
"wcag/h32": "off",
"no-implicit-button-type": "warn"
}
}

37
.stylelintrc.json Normal file
View File

@ -0,0 +1,37 @@
{
"rules": {
"block-no-empty": true,
"no-empty-source": true,
"no-duplicate-at-import-rules": true,
"no-invalid-double-slash-comments": true,
"no-invalid-position-at-import-rule": true,
"no-irregular-whitespace": true,
"comment-no-empty": true,
"font-family-no-missing-generic-family-keyword": true,
"function-calc-no-unspaced-operator": true,
"function-linear-gradient-no-nonstandard-direction": true,
"function-no-unknown": true,
"keyframe-declaration-no-important": true,
"media-query-no-invalid": true,
"named-grid-areas-no-invalid": true,
"selector-anb-no-unmatchable": true,
"selector-pseudo-class-no-unknown": true,
"selector-pseudo-element-no-unknown": true,
"selector-type-no-unknown": true,
"string-no-newline": true,
"unit-no-unknown": true,
"color-no-invalid-hex": true,
"declaration-block-no-duplicate-properties": [
true,
{ "ignore": ["consecutive-duplicates-with-different-values"] }
],
"declaration-block-no-shorthand-property-overrides": true,
"property-no-unknown": true,
"at-rule-no-unknown": true
},
"ignoreFiles": [
"node_modules/**",
"playwright-report/**",
"test-results/**"
]
}

View File

@ -120,6 +120,30 @@ BASE_URL=https://iliadobkin.com npx playwright test
--- ---
## Lint & quality checks
The site has no build step, but the dev toolchain runs a four-step quality pass on every commit-worthy change. All checks have an npm script and can be run individually.
```bash
npm run lint # eslint + stylelint + html-validate, in parallel
npm run lint:js # ESLint over js/, scripts/, tests/, *.ts (vanilla JS + TS)
npm run lint:css # Stylelint over css/*.css (bug-only ruleset, no style nags)
npm run lint:html # html-validate over index.html (a11y, roles, DOCTYPE)
npm run typecheck # tsc --noEmit on playwright.config.ts + tests/*.ts
npm run check # lint + typecheck + test (the full guardrail set)
```
Conventions worth knowing:
- **ESLint** uses a flat config (`eslint.config.mjs`) with three scopes — browser globals for `js/`, Node ESM for `scripts/`, and `typescript-eslint` for `tests/` + `*.ts`. `console.warn` / `console.error` are allowed; `console.log` is not.
- **Stylelint** runs a minimal "bug-only" ruleset (`.stylelintrc.json`) — block-no-empty, no-invalid-hex, function-no-unknown, property-no-unknown, etc. Compact handwritten CSS (single-line rules, `rgba()`, 6-char hex) is intentional and not flagged.
- **html-validate** (`.htmlvalidate.json`) enforces uppercase DOCTYPE, accessible button names, non-redundant ARIA roles, and valid landmark usage.
- **tsc** (`tsconfig.json`) runs in `--noEmit` strict mode against the test files — fails fast if a selector signature drifts.
`npm-run-all2` parallelizes the linters for speed (`npm run lint` finishes in ~3s); `npm run check` is the one to wire into a Gitea Actions runner.
---
## 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.

89
eslint.config.mjs Normal file
View File

@ -0,0 +1,89 @@
// Flat config — ESLint 9. Three lint targets:
// 1. Browser vanilla JS in `js/` (no build, no modules)
// 2. Node ES modules in `scripts/` (utility tasks)
// 3. TypeScript test + config files (@playwright/test)
import js from "@eslint/js";
import globals from "globals";
import tseslint from "typescript-eslint";
export default [
{
ignores: [
"node_modules/",
"playwright-report/",
"test-results/",
"assets/",
"dist/",
".cache/",
],
},
// ----- Browser vanilla JS (js/app.js, js/data.js) -----
{
files: ["js/**/*.js"],
languageOptions: {
ecmaVersion: 2022,
sourceType: "script",
globals: {
...globals.browser,
// `data.js` exposes window.PORTFOLIO and a set of `renderXxx`
// top-level functions referenced from inline `render:` properties.
// They are looked up by reference inside data.js, not by name from
// other files, so we don't need to pre-declare each renderer.
PORTFOLIO: "readonly",
},
},
rules: {
...js.configs.recommended.rules,
eqeqeq: ["error", "always"],
"no-var": "error",
"prefer-const": "warn",
"no-console": ["warn", { allow: ["warn", "error"] }],
// _ / _esc are intentional helper / placeholder names.
"no-unused-vars": [
"warn",
{
argsIgnorePattern: "^_",
varsIgnorePattern: "^_",
caughtErrorsIgnorePattern: "^_",
},
],
},
},
// ----- Node ES modules (scripts/*.mjs) -----
{
files: ["scripts/**/*.{js,mjs}"],
languageOptions: {
ecmaVersion: 2022,
sourceType: "module",
globals: { ...globals.node },
},
rules: {
...js.configs.recommended.rules,
eqeqeq: ["error", "always"],
"no-var": "error",
"prefer-const": "warn",
"no-console": "off",
},
},
// ----- TypeScript (tests/, playwright.config.ts) -----
...tseslint.configs.recommended.map((cfg) => ({
...cfg,
files: ["tests/**/*.ts", "*.ts"],
})),
{
files: ["tests/**/*.ts", "*.ts"],
languageOptions: {
globals: { ...globals.node },
},
rules: {
"@typescript-eslint/no-unused-vars": [
"warn",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
],
"@typescript-eslint/no-explicit-any": "off",
},
},
];

View File

@ -1,4 +1,4 @@
<!doctype html> <!DOCTYPE html>
<html lang="en" data-theme="dark"> <html lang="en" data-theme="dark">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
@ -14,9 +14,9 @@
</head> </head>
<body> <body>
<!-- ============== TOP BAR ============== --> <!-- ============== TOP BAR ============== -->
<header class="topbar" role="banner"> <header class="topbar">
<div class="topbar__left"> <div class="topbar__left">
<button class="btn btn--ghost menu-btn" id="menu-btn" aria-label="Open test explorer" title="Test Explorer"> <button type="button" class="btn btn--ghost menu-btn" id="menu-btn" aria-label="Open test explorer" title="Test Explorer">
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M2 4h12M2 8h12M2 12h12" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg> <svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M2 4h12M2 8h12M2 12h12" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>
</button> </button>
<a class="brand" href="#" aria-label="Ilia Dobkin home"> <a class="brand" href="#" aria-label="Ilia Dobkin home">
@ -33,20 +33,20 @@
</div> </div>
<div class="topbar__right"> <div class="topbar__right">
<button class="btn btn--run" id="run-all" title="Run all tests"> <button type="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>
<span>Run all</span> <span>Run all</span>
</button> </button>
<button class="btn btn--ghost" id="stop-all" title="Stop" disabled> <button type="button" class="btn btn--ghost" id="stop-all" title="Stop" aria-label="Stop running tests" disabled>
<svg viewBox="0 0 16 16" width="12" height="12" aria-hidden="true"><rect x="3" y="3" width="10" height="10" rx="1.5" fill="currentColor"/></svg> <svg viewBox="0 0 16 16" width="12" height="12" aria-hidden="true"><rect x="3" y="3" width="10" height="10" rx="1.5" fill="currentColor"/></svg>
</button> </button>
<button class="btn btn--ghost" id="reset-all" title="Reset"> <button type="button" class="btn btn--ghost" id="reset-all" title="Reset" aria-label="Reset all tests">
<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>
<button class="btn btn--ghost" id="theme-toggle" title="Toggle theme (T)" aria-label="Toggle theme"> <button type="button" class="btn btn--ghost" id="theme-toggle" title="Toggle theme (T)" 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"> <button type="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"> <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"/> <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"/> <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"/>
@ -63,7 +63,7 @@
<aside class="sidebar" id="sidebar" aria-label="Test Explorer"> <aside class="sidebar" id="sidebar" aria-label="Test Explorer">
<div class="sidebar__head"> <div class="sidebar__head">
<span class="sidebar__title">TEST EXPLORER</span> <span class="sidebar__title">TEST EXPLORER</span>
<button class="iconbtn" id="expand-all" title="Expand all" aria-label="Expand all"><svg viewBox="0 0 16 16" width="12" height="12"><path d="M3 6 L8 11 L13 6" stroke="currentColor" stroke-width="1.6" fill="none"/></svg></button> <button type="button" class="iconbtn" id="expand-all" title="Expand all" aria-label="Expand all"><svg viewBox="0 0 16 16" width="12" height="12"><path d="M3 6 L8 11 L13 6" stroke="currentColor" stroke-width="1.6" fill="none"/></svg></button>
</div> </div>
<div class="filter"> <div class="filter">
@ -71,7 +71,7 @@
<input id="grep" type="text" placeholder='--grep "@playwright"' autocomplete="off" spellcheck="false" /> <input id="grep" type="text" placeholder='--grep "@playwright"' autocomplete="off" spellcheck="false" />
</div> </div>
<div class="tags" id="tag-bar" aria-label="Filter by tag"></div> <div class="tags" id="tag-bar" role="group" aria-label="Filter by tag"></div>
<nav class="tree" id="tree" aria-label="Tests"></nav> <nav class="tree" id="tree" aria-label="Tests"></nav>
@ -121,11 +121,11 @@
</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 type="button" class="tab is-active" data-tab="report" role="tab">Report</button>
<button class="tab" data-tab="trace" role="tab">Trace</button> <button type="button" class="tab" data-tab="trace" role="tab">Trace</button>
<button class="tab" data-tab="source" role="tab">Source</button> <button type="button" class="tab" data-tab="source" role="tab">Source</button>
<button class="tab" data-tab="network" role="tab">Network</button> <button type="button" class="tab" data-tab="network" role="tab">Network</button>
<button class="tab" data-tab="console" role="tab">Console</button> <button type="button" class="tab" data-tab="console" role="tab">Console</button>
<div class="tabs__spacer"></div> <div class="tabs__spacer"></div>
</div> </div>
@ -178,7 +178,7 @@
</main> </main>
<!-- ============== BOTTOM STATUS BAR ============== --> <!-- ============== BOTTOM STATUS BAR ============== -->
<footer class="statusbar" role="contentinfo"> <footer class="statusbar">
<span class="sb__seg">⎇ main</span> <span class="sb__seg">⎇ main</span>
<span class="sb__seg">&nbsp;0</span> <span class="sb__seg">&nbsp;0</span>
<span class="sb__seg" id="sb-runtime">runtime: 0ms</span> <span class="sb__seg" id="sb-runtime">runtime: 0ms</span>

View File

@ -13,7 +13,6 @@
// Test state: idle | running | passed | skipped // Test state: idle | running | passed | skipped
const state = Object.fromEntries(tests.map(t=>[t.id, { status: t.skip ? 'skipped' : t.fail ? 'failed' : 'idle', runtime:0 }])); const state = Object.fromEntries(tests.map(t=>[t.id, { status: t.skip ? 'skipped' : t.fail ? 'failed' : 'idle', runtime:0 }]));
let isRunningAll = false; let isRunningAll = false;
let activeTimers = [];
const headed = () => $('#headed').checked; const headed = () => $('#headed').checked;
const getWorkers = () => Math.max(1, parseInt($('#workers').value, 10) || 1); const getWorkers = () => Math.max(1, parseInt($('#workers').value, 10) || 1);
@ -24,7 +23,7 @@
return m ? m[1] : null; return m ? m[1] : null;
} }
function writeSpecCookie(v){ function writeSpecCookie(v){
try { document.cookie = `spec=${v}; path=/; max-age=31536000; SameSite=Lax`; } catch(_) {} try { document.cookie = `spec=${v}; path=/; max-age=31536000; SameSite=Lax`; } catch { /* cookies disabled */ }
} }
function getSpec(id){ return specs.find(s => s.id === id) || specs[0]; } function getSpec(id){ return specs.find(s => s.id === id) || specs[0]; }
function specCountFor(id){ return tests.filter(t => t.spec === id).length; } function specCountFor(id){ return tests.filter(t => t.spec === id).length; }
@ -122,7 +121,6 @@
</div>`; </div>`;
} }
function refreshTreeRow(id){ function refreshTreeRow(id){
const t = tests.find(x=>x.id===id);
const s = state[id]; const s = state[id];
const row = $(`.test[data-id="${id}"]`); const row = $(`.test[data-id="${id}"]`);
if (!row) return; if (!row) return;
@ -141,7 +139,7 @@
// chip that expands them inline on click. Keeps the sidebar visual mass // chip that expands them inline on click. Keeps the sidebar visual mass
// small until a visitor needs the full registry. // small until a visitor needs the full registry.
const VISIBLE_TAGS = 6; const VISIBLE_TAGS = 6;
let activeTags = new Set(); const activeTags = new Set();
function renderTagBar(){ function renderTagBar(){
const bar = $('#tag-bar'); const bar = $('#tag-bar');
const all = Array.isArray(data.tags) ? data.tags : []; const all = Array.isArray(data.tags) ? data.tags : [];
@ -628,10 +626,10 @@
} }
function utf8ByteLength(str){ function utf8ByteLength(str){
if (typeof TextEncoder !== 'undefined') { if (typeof TextEncoder !== 'undefined') {
try { return new TextEncoder().encode(str).length; } catch (_) {} try { return new TextEncoder().encode(str).length; } catch { /* fall through */ }
} }
try { return encodeURIComponent(str).replace(/%../g, 'x').length; } try { return encodeURIComponent(str).replace(/%../g, 'x').length; }
catch (_) { return str.length * 4; } catch { return str.length * 4; }
} }
/** Resolve list mount (handles older HTML ids + creates a node under `.network-pane` if needed). */ /** Resolve list mount (handles older HTML ids + creates a node under `.network-pane` if needed). */
@ -780,7 +778,7 @@
return m ? m[1] : null; return m ? m[1] : null;
} }
function writeThemeCookie(v){ function writeThemeCookie(v){
try { document.cookie = `theme=${v}; path=/; max-age=31536000; SameSite=Lax`; } catch(_) {} try { document.cookie = `theme=${v}; path=/; max-age=31536000; SameSite=Lax`; } catch { /* cookies disabled */ }
} }
function initTheme(){ function initTheme(){
const saved = readThemeCookie() || 'dark'; const saved = readThemeCookie() || 'dark';

View File

@ -414,7 +414,7 @@ function renderAbout(){
function renderExperience(){ function renderExperience(){
return `<div class="block">${ return `<div class="block">${
PORTFOLIO.experience.map((e,i)=>` PORTFOLIO.experience.map((e)=>`
<h4>${_esc(e.company)}</h4> <h4>${_esc(e.company)}</h4>
<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> <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>

3220
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -6,10 +6,28 @@
"test": "playwright test", "test": "playwright test",
"test:headed": "playwright test --headed", "test:headed": "playwright test --headed",
"test:ui": "playwright test --ui", "test:ui": "playwright test --ui",
"report": "playwright show-report" "report": "playwright show-report",
"lint": "npm-run-all -p lint:js lint:css lint:html",
"lint:js": "eslint .",
"lint:js:fix": "eslint . --fix",
"lint:css": "stylelint \"css/**/*.css\"",
"lint:css:fix": "stylelint \"css/**/*.css\" --fix",
"lint:html": "html-validate index.html",
"typecheck": "tsc --noEmit",
"check": "npm-run-all -p lint typecheck test"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1",
"@playwright/test": "^1.52.0", "@playwright/test": "^1.52.0",
"serve": "^14.2.6" "@types/node": "^25.7.0",
"eslint": "^10.3.0",
"globals": "^17.6.0",
"html-validate": "^11.0.0",
"npm-run-all2": "^8.0.4",
"serve": "^14.2.6",
"stylelint": "^17.11.0",
"stylelint-config-standard": "^40.0.0",
"typescript": "^6.0.3",
"typescript-eslint": "^8.59.3"
} }
} }

View File

@ -394,8 +394,10 @@ test.describe("accessibility", () => {
}); });
test("landmark roles are present", async ({ page }) => { test("landmark roles are present", async ({ page }) => {
await expect(page.locator('[role="banner"]')).toBeVisible(); // <header> and <footer> at the top level expose implicit banner /
await expect(page.locator('[role="contentinfo"]')).toBeVisible(); // contentinfo roles; querying via getByRole picks them up natively.
await expect(page.getByRole("banner")).toBeVisible();
await expect(page.getByRole("contentinfo")).toBeVisible();
}); });
}); });

17
tsconfig.json Normal file
View File

@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"allowJs": false,
"isolatedModules": true,
"types": ["node"]
},
"include": ["playwright.config.ts", "tests/**/*.ts"],
"exclude": ["node_modules", "playwright-report", "test-results"]
}