diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 785c0cac..a98e2e3e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -147,3 +147,34 @@ jobs: # so an explicit build step here would just build twice. - run: npx playwright install --with-deps chromium firefox webkit - run: npm test + + configurator: + name: Configurator tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 22 + cache: npm + # Root deps first: the configurator's sync script reads docs/api-index.json + # via tooling generated at the repo root. + - run: npm ci + - run: npm ci + working-directory: configurator + # Unit suite — the sync tripwire: curated Basic controls, presets, fluid + # engine scalars and knob defaults all pinned to the live catalogue. + - run: npm test + working-directory: configurator + # Type/diagnostics gate (kept at 0 errors / 0 warnings). + - run: npm run check + working-directory: configurator + # Browser regression suite (test:e2e prebuilds; Playwright manages the + # preview server via its webServer option). Chromium only — cross-engine + # CSS coverage lives in the root Regression tests job. + - run: npx playwright install --with-deps chromium + working-directory: configurator + - run: npm run test:e2e + working-directory: configurator diff --git a/configurator/index.html b/configurator/index.html index 0e1386b8..cca03b29 100644 --- a/configurator/index.html +++ b/configurator/index.html @@ -8,6 +8,25 @@ name="description" content="Configure every SLASHED CSS framework token and generate ready-to-paste override CSS." /> + + + + + + + + +
diff --git a/configurator/package-lock.json b/configurator/package-lock.json index 1520b2cf..9e9b1128 100644 --- a/configurator/package-lock.json +++ b/configurator/package-lock.json @@ -8,6 +8,7 @@ "name": "slashed-configurator", "version": "0.1.0", "devDependencies": { + "@playwright/test": "^1.60.0", "@sveltejs/vite-plugin-svelte": "^7.1.2", "svelte": "^5.56.3", "svelte-check": "^4.6.0", @@ -127,6 +128,22 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@playwright/test": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", @@ -951,6 +968,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.15", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", diff --git a/configurator/package.json b/configurator/package.json index 8012a1cc..d4ad6ceb 100644 --- a/configurator/package.json +++ b/configurator/package.json @@ -12,9 +12,12 @@ "build": "vite build", "preview": "vite preview", "check": "svelte-check --tsconfig ./jsconfig.json", - "test": "node --test tests/*.test.js" + "test": "node --test tests/*.test.js", + "pretest:e2e": "npm run build", + "test:e2e": "playwright test" }, "devDependencies": { + "@playwright/test": "^1.60.0", "@sveltejs/vite-plugin-svelte": "^7.1.2", "svelte": "^5.56.3", "svelte-check": "^4.6.0", diff --git a/configurator/playwright.config.js b/configurator/playwright.config.js new file mode 100644 index 00000000..4f9b108d --- /dev/null +++ b/configurator/playwright.config.js @@ -0,0 +1,32 @@ +/** + * Playwright config for the configurator's end-to-end regression suite. + * + * These specs pin the behaviors that unit tests can't reach (real cascade, + * localStorage, keyboard, viewport transitions) — most of them are direct + * regressions for bugs found during the IA-restructure QA sweeps. + * + * `npm run test:e2e` builds first (pretest:e2e) and Playwright then manages + * the preview server itself via `webServer`. Chromium-only on purpose: the + * suite tests the configurator app, not the framework's cross-engine CSS + * (that lives in the root /tests Playwright suite). + */ +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests-e2e', + timeout: 30_000, + workers: 1, + retries: process.env.CI ? 1 : 0, + reporter: process.env.CI ? 'line' : 'list', + use: { + baseURL: 'http://localhost:4173', + headless: true, + viewport: { width: 1600, height: 1000 }, + }, + webServer: { + command: 'npm run preview -- --port 4173 --strictPort', + url: 'http://localhost:4173', + reuseExistingServer: !process.env.CI, + timeout: 30_000, + }, +}); diff --git a/configurator/src/App.svelte b/configurator/src/App.svelte index 16145790..b30b4ba0 100644 --- a/configurator/src/App.svelte +++ b/configurator/src/App.svelte @@ -23,6 +23,7 @@ */ import { DOMAINS, DOMAIN_BY_ID, BASIC_DOMAIN_IDS } from './lib/domains.js'; import { ui, undo, redo, overrides } from './lib/store.svelte.js'; + import { UI_STORAGE_KEY } from './lib/uiState.js'; import { setProbeContext } from './lib/probeHost.js'; import Header from './components/Header.svelte'; import Sidebar from './components/Sidebar.svelte'; @@ -55,6 +56,17 @@ setProbeContext({ overrides, theme: ui.previewTheme }); }); + // Persist the navigation prefs so a reload restores where the user was. + // Restore (with validation) happens in store.svelte.js via sanitiseUiState. + $effect(() => { + const snapshot = JSON.stringify({ mode: ui.mode, domain: ui.domain, outputMode: ui.outputMode }); + try { + localStorage.setItem(UI_STORAGE_KEY, snapshot); + } catch { + /* quota / private mode — non-essential, ignore */ + } + }); + // On narrow viewports the preview is a slide-over overlay, so it must start // closed — opening a full-width scrim on first paint would bury the panel. // The media query is tracked live: shrinking the window dismisses the @@ -98,7 +110,15 @@ } } } - if (e.key === '/') { + if (e.key === 'Escape') { + // Dismiss the slide-over preview overlay (narrow viewports only — the + // desktop pane is a persistent layout region, not a dialog). The search + // box's own Escape (clear query) takes precedence when it has focus. + const inInput = e.target instanceof HTMLElement && e.target.tagName === 'INPUT'; + if (!inInput && ui.previewOpen && window.matchMedia('(max-width: 1100px)').matches) { + ui.previewOpen = false; + } + } else if (e.key === '/') { e.preventDefault(); document.querySelector('#cfg-search')?.focus(); } else if (e.key === 'b' || e.key === 'B') { diff --git a/configurator/src/components/Cheatsheet.svelte b/configurator/src/components/Cheatsheet.svelte index 2acb0c4b..480e09a0 100644 --- a/configurator/src/components/Cheatsheet.svelte +++ b/configurator/src/components/Cheatsheet.svelte @@ -8,6 +8,7 @@ * filtered in-memory so switching filters is instant. */ import { allTokens } from '../lib/model.js'; + import { copyText, COPY_FEEDBACK_MS } from '../lib/clipboard.js'; const PAGE_SIZE = 60; @@ -63,11 +64,10 @@ // ── Copy ───────────────────────────────────────────────────────────────────── let copied = $state(null); async function copyName(name) { - try { - await navigator.clipboard.writeText(name); + if (await copyText(name)) { copied = name; - setTimeout(() => (copied = null), 1200); - } catch { /* silent */ } + setTimeout(() => (copied = null), COPY_FEEDBACK_MS); + } } // ── Tier colour ────────────────────────────────────────────────────────────── @@ -360,6 +360,7 @@ color: var(--cfg-text-muted); display: -webkit-box; -webkit-line-clamp: 2; + line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; word-break: break-all; diff --git a/configurator/src/components/DomainPanel.svelte b/configurator/src/components/DomainPanel.svelte index 46ce5e38..313c82f1 100644 --- a/configurator/src/components/DomainPanel.svelte +++ b/configurator/src/components/DomainPanel.svelte @@ -120,6 +120,16 @@ } +{#snippet cardHead(title, count, hint = '')} +{r.name}
- {r.before || '—'}
- {r.after}
+ {r.name}
+ {r.before || '—'}
+ {r.after}
; keep the code transparent
+ so the per-cell colors/strike-through apply unchanged. */
+ .diff__row code { font: inherit; color: inherit; }
.diff__name { color: var(--cfg-text); word-break: break-all; }
.diff__before {
color: var(--cfg-text-faint);
diff --git a/configurator/src/components/Preview.svelte b/configurator/src/components/Preview.svelte
index b2af5de6..59144009 100644
--- a/configurator/src/components/Preview.svelte
+++ b/configurator/src/components/Preview.svelte
@@ -10,6 +10,20 @@
import { overrides, ui } from '../lib/store.svelte.js';
import { buildPreviewDeclarations } from '../lib/preview.js';
+ /** @type {HTMLButtonElement | undefined} */
+ let closeBtn;
+
+ // Overlay focus management (narrow viewports): the slide-over behaves like
+ // a dialog, so focus lands on its close button when it opens and returns
+ // to the header toggle when it unmounts. Desktop pane: no-op.
+ $effect(() => {
+ if (!window.matchMedia('(max-width: 1100px)').matches) return;
+ closeBtn?.focus();
+ return () => {
+ document.querySelector('button[aria-label="Toggle preview"]')?.focus();
+ };
+ });
+
/** Viewport width presets — the breakpoints the framework's fluid scale targets. */
const VIEWPORTS = [
{ id: 'mobile', label: '📱 Mobile', width: 360, hint: '360 px' },
@@ -93,6 +107,7 @@
{ui.previewTheme}{ui.previewMotion === 'reduced' ? ' · reduced motion' : ''}{activeViewport.width ? ` · ${activeViewport.width} px` : ''}