diff --git a/docs/superpowers/plans/2026-09-02-portfolio-nightdrive-redesign-implementation.md b/docs/superpowers/plans/2026-09-02-portfolio-nightdrive-redesign-implementation.md index 044f26d..77370cf 100644 --- a/docs/superpowers/plans/2026-09-02-portfolio-nightdrive-redesign-implementation.md +++ b/docs/superpowers/plans/2026-09-02-portfolio-nightdrive-redesign-implementation.md @@ -65,51 +65,40 @@ **Files:** - Modify: `package.json` - Modify: `package-lock.json` -- Modify: `src/App.vue` - Modify: `src/styles/tokens.css` -- Modify: `src/styles/base.css` +- Modify: `scripts/build-contract.mjs` - Create: `docs/third-party-notices.md` -- Test: `tests/unit/toolchain.test.ts` +- Modify: `tests/unit/build-contract.test.ts` +- Modify: `tests/browser/semantic.spec.ts` **Interfaces:** -- Consumes: existing Vite CSS import order and Vue application entry. -- Produces: `Geist Variable`, `Geist Mono`, Phosphor Vue components, Simple Icons exports, and stable CSS tokens used by every subsequent task. +- Consumes: existing Vite CSS pipeline, manifest closure validator, and generated preview. +- Produces: `Geist Variable`, `Geist Mono`, font-aware asset closure, and stable CSS tokens used by every subsequent task. Phosphor Vue and Simple Icons are installed only when their tested consumers are introduced in Tasks 7 and 10. -- [ ] **Step 1: Write dependency and typography assertions** +- [ ] **Step 1: Write the browser typography assertion** -Add assertions that exact direct dependencies exist and that the application imports the two font stylesheets before local CSS: +Load the generated preview, await `document.fonts.ready`, and assert the actual rendered heading and route-label families: ```ts -expect(packageJson.dependencies).toMatchObject({ - '@fontsource-variable/geist': '5.3.0', - '@fontsource-variable/geist-mono': '5.3.0', - '@phosphor-icons/vue': '2.2.1', - 'simple-icons': '16.29.0', -}); -expect(appSource).toContain("@fontsource-variable/geist/wght.css"); -expect(appSource).toContain("@fontsource-variable/geist-mono/wght.css"); +expect(fonts.headingFamily).toContain('Geist Variable'); +expect(fonts.routeFamily).toContain('Geist Mono Variable'); ``` - [ ] **Step 2: Run the focused test and verify it fails** -Run: `npx vitest run tests/unit/toolchain.test.ts` +Run: `npm run build:preview && npx playwright test tests/browser/semantic.spec.ts --project=desktop-chromium --grep "loads the self-hosted Geist"` -Expected: FAIL because the dependencies and font imports are absent. +Expected: FAIL because rendered text still uses Inter and the system monospace stack. -- [ ] **Step 3: Install verified dependencies and import fonts** +- [ ] **Step 3: Install verified font dependencies and define only required subsets** Run: ```bash -npm install --save-exact @fontsource-variable/geist@5.3.0 @fontsource-variable/geist-mono@5.3.0 @phosphor-icons/vue@2.2.1 simple-icons@16.29.0 +npm install --save-exact @fontsource-variable/geist@5.3.0 @fontsource-variable/geist-mono@5.3.0 ``` -Add before local CSS in `src/App.vue`: - -```ts -import '@fontsource-variable/geist/wght.css'; -import '@fontsource-variable/geist-mono/wght.css'; -``` +Define Latin and Latin Extended Geist faces plus the Latin Geist Mono face in `src/styles/tokens.css`. Reference package-owned `.woff2` files directly so Vite emits only the three subsets used by visible content. Define the stable visual tokens: @@ -141,18 +130,18 @@ Define the stable visual tokens: } ``` -Record the package names, versions, licenses, copyright owners, and source URLs in `docs/third-party-notices.md`. +Teach `resolveViteAssetClosure` to accept a non-entry manifest record only when its emitted file is already named by the `assets` field of a reachable module. Keep unrelated records rejected. Record the font package names, versions, licenses, copyright owners, and source URLs in `docs/third-party-notices.md`. - [ ] **Step 4: Run the focused test and static verification** -Run: `npx vitest run tests/unit/toolchain.test.ts && npm run typecheck` +Run: `npx vitest run tests/unit/build-contract.test.ts && npm run build:preview && npx playwright test tests/browser/semantic.spec.ts --project=desktop-chromium --grep "loads the self-hosted Geist"` -Expected: PASS. +Expected: PASS with `initialFonts` below the existing 122880-byte threshold. - [ ] **Step 5: Commit the foundation** ```bash -git add package.json package-lock.json src/App.vue src/styles/tokens.css src/styles/base.css docs/third-party-notices.md tests/unit/toolchain.test.ts +git add package.json package-lock.json src/styles/tokens.css scripts/build-contract.mjs docs/third-party-notices.md tests/unit/build-contract.test.ts tests/browser/semantic.spec.ts docs/superpowers/plans/2026-09-02-portfolio-nightdrive-redesign-implementation.md git commit -m "feat: establish nightdrive visual foundation" ``` @@ -538,6 +527,8 @@ git commit -m "feat: integrate the cinematic nightdrive scene" ### Task 7: Replace exposed controls with the compact vehicle HUD **Files:** +- Modify: `package.json` +- Modify: `package-lock.json` - Create: `src/components/IconGlyph.vue` - Modify: `src/components/DriveControls.vue` - Modify: `src/components/JourneyHud.vue` @@ -573,6 +564,12 @@ Expected: FAIL because the current controls expose every setting and use text-on - [ ] **Step 3: Implement the icon contract and control states** +Install the verified Vue interface-icon package: + +```bash +npm install --save-exact @phosphor-icons/vue@2.2.1 +``` + Use tree-shaken Phosphor imports with a standard `20px`, weight `regular`, and visual stroke weight. Keep button labels in `aria-label` and tooltips, not duplicate visible strings. ```vue @@ -603,7 +600,7 @@ Expected: PASS. - [ ] **Step 6: Commit the HUD milestone** ```bash -git add src/components/IconGlyph.vue src/components/DriveControls.vue src/components/JourneyHud.vue src/components/AppShell.vue src/styles/world.css tests/unit/SceneCanvas.test.ts tests/browser/audio-controls.spec.ts tests/browser/scene-controls.spec.ts tests/browser/responsive-layout.spec.ts +git add package.json package-lock.json src/components/IconGlyph.vue src/components/DriveControls.vue src/components/JourneyHud.vue src/components/AppShell.vue src/styles/world.css tests/unit/SceneCanvas.test.ts tests/browser/audio-controls.spec.ts tests/browser/scene-controls.spec.ts tests/browser/responsive-layout.spec.ts docs/third-party-notices.md git commit -m "feat: introduce the compact vehicle HUD" ``` @@ -770,6 +767,8 @@ git commit -m "feat: recompose the portfolio information layer" ### Task 10: Build the infrastructure flow and official icon registries **Files:** +- Modify: `package.json` +- Modify: `package-lock.json` - Create: `src/components/BrandIcon.vue` - Create: `src/icons/brand-icons.ts` - Modify: `src/components/InfrastructureDiagram.vue` @@ -807,6 +806,12 @@ Expected: FAIL because the registry and real flow diagram do not exist. - [ ] **Step 3: Implement brand and interface icon rendering** +Install the verified brand-data package: + +```bash +npm install --save-exact simple-icons@16.29.0 +``` + Import named Simple Icons exports only in `brand-icons.ts`. Render package-provided paths through `BrandIcon.vue`; hide decorative marks from assistive technology and keep the visible technology or service name. Continue using Phosphor only for interface and diagram actions. - [ ] **Step 4: Replace the four-box diagram with a flow** @@ -828,7 +833,7 @@ Expected: PASS. - [ ] **Step 6: Commit diagrams and iconography** ```bash -git add src/components/BrandIcon.vue src/icons/brand-icons.ts src/components/InfrastructureDiagram.vue src/components/ToolkitGrid.vue src/components/ContactDestination.vue src/styles/components.css tests/unit/brand-icons.test.ts tests/unit/components.test.ts tests/browser/accessibility-axe.spec.ts tests/browser/responsive-layout.spec.ts +git add package.json package-lock.json src/components/BrandIcon.vue src/icons/brand-icons.ts src/components/InfrastructureDiagram.vue src/components/ToolkitGrid.vue src/components/ContactDestination.vue src/styles/components.css tests/unit/brand-icons.test.ts tests/unit/components.test.ts tests/browser/accessibility-axe.spec.ts tests/browser/responsive-layout.spec.ts docs/third-party-notices.md git commit -m "feat: add infrastructure flow and official iconography" ``` diff --git a/docs/third-party-notices.md b/docs/third-party-notices.md new file mode 100644 index 0000000..cf76ea3 --- /dev/null +++ b/docs/third-party-notices.md @@ -0,0 +1,23 @@ +# Third-party notices + +This project includes the following third-party software and typefaces. + +## Geist Variable + +- Package: `@fontsource-variable/geist` version 5.3.0 +- Typeface copyright: 2024 The Geist Project Authors +- Package source: https://github.com/fontsource/font-files +- Typeface source: https://github.com/vercel/geist-font +- License: SIL Open Font License 1.1 + +The complete license text is distributed in `node_modules/@fontsource-variable/geist/LICENSE` during development and dependency review. + +## Geist Mono Variable + +- Package: `@fontsource-variable/geist-mono` version 5.3.0 +- Typeface copyright: 2024 The Geist Project Authors +- Package source: https://github.com/fontsource/font-files +- Typeface source: https://github.com/vercel/geist-font +- License: SIL Open Font License 1.1 + +The complete license text is distributed in `node_modules/@fontsource-variable/geist-mono/LICENSE` during development and dependency review. diff --git a/package-lock.json b/package-lock.json index 069906b..ead24d3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,8 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "@fontsource-variable/geist": "5.3.0", + "@fontsource-variable/geist-mono": "5.3.0", "canonicalize": "2.1.0", "three": "^0.185.1", "vue": "^3.5.42", @@ -332,6 +334,24 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, + "node_modules/@fontsource-variable/geist": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/geist/-/geist-5.3.0.tgz", + "integrity": "sha512-j0m+vLQuG5XAYoHtGCVu0spvlGreR3EzpECUVzkFmI1mTVnAO38l/NEPDCFgZ177JxzYJCLSmTQibIiYPilGrA==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource-variable/geist-mono": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/geist-mono/-/geist-mono-5.3.0.tgz", + "integrity": "sha512-vBbuwDEo9AkrqADMXOrlAR3DFcJi4/JxeuU43FoiQERnNwsfXNnvxvReZG02cQKmyk4DZkZdBZX3oTDvy2zBAw==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@formatjs/ecma402-abstract": { "version": "2.3.6", "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.6.tgz", diff --git a/package.json b/package.json index 8d507e2..85f17ca 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,8 @@ "author": "", "license": "ISC", "dependencies": { + "@fontsource-variable/geist": "5.3.0", + "@fontsource-variable/geist-mono": "5.3.0", "canonicalize": "2.1.0", "three": "^0.185.1", "vue": "^3.5.42", diff --git a/scripts/build-contract.mjs b/scripts/build-contract.mjs index 8aeaef2..59ae35d 100644 --- a/scripts/build-contract.mjs +++ b/scripts/build-contract.mjs @@ -73,7 +73,25 @@ export const resolveViteAssetClosure = (manifest) => { }; visit('index.html', true); - const unreachable = Object.keys(manifest).filter((key) => !allReachable.has(key)); + const reachableAssetFiles = new Set( + [...allReachable].flatMap((key) => manifest[key].assets ?? []), + ); + const emittedAssetRecords = new Set( + Object.entries(manifest).flatMap(([key, record]) => { + const isEmittedAssetRecord = + reachableAssetFiles.has(record.file) && + record.isEntry === undefined && + record.isDynamicEntry === undefined && + (record.imports?.length ?? 0) === 0 && + (record.dynamicImports?.length ?? 0) === 0 && + (record.css?.length ?? 0) === 0 && + (record.assets?.length ?? 0) === 0; + return isEmittedAssetRecord ? [key] : []; + }), + ); + const unreachable = Object.keys(manifest).filter( + (key) => !allReachable.has(key) && !emittedAssetRecords.has(key), + ); if (unreachable.length > 0) throw new Error(`Unreachable manifest record: ${unreachable.join(', ')}`); const executableModules = []; diff --git a/src/styles/tokens.css b/src/styles/tokens.css index 82aeef1..1e5ca75 100644 --- a/src/styles/tokens.css +++ b/src/styles/tokens.css @@ -1,29 +1,71 @@ +@font-face { + font-family: 'Geist Variable'; + font-style: normal; + font-display: swap; + font-weight: 100 900; + src: url('@fontsource-variable/geist/files/geist-latin-ext-wght-normal.woff2') format('woff2-variations'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +@font-face { + font-family: 'Geist Variable'; + font-style: normal; + font-display: swap; + font-weight: 100 900; + src: url('@fontsource-variable/geist/files/geist-latin-wght-normal.woff2') format('woff2-variations'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: 'Geist Mono Variable'; + font-style: normal; + font-display: swap; + font-weight: 100 900; + src: url('@fontsource-variable/geist-mono/files/geist-mono-latin-wght-normal.woff2') format('woff2-variations'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + :root { color-scheme: dark; - --canvas: #080d16; - --surface: #101722; - --surface-raised: #141e2b; - --line: #314154; - --line-strong: #4a6275; - --text: #f2f4f5; - --text-muted: #b5c0c9; - --cyan: #80d9e6; - --cyan-deep: #163b48; - --amber: #f5b752; - --focus: #ffd17a; - --sans: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - --mono: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; + --canvas: #040814; + --surface: rgb(7 14 25 / 95%); + --surface-strong: rgb(7 14 25 / 97%); + --surface-soft: rgb(15 28 43 / 92%); + --surface-raised: var(--surface-soft); + --line: rgb(126 200 214 / 22%); + --line-strong: rgb(126 220 232 / 44%); + --text: #f3f7f8; + --text-muted: #aab9c2; + --accent: #78d8e5; + --accent-deep: #103745; + --focus: #ffd693; + --world-magenta: #c74b91; + --world-amber: #f4ad67; + --cyan: var(--accent); + --cyan-deep: var(--accent-deep); + --amber: var(--world-amber); + --sans: 'Geist Variable', ui-sans-serif, system-ui, sans-serif; + --mono: 'Geist Mono Variable', ui-monospace, monospace; --step--1: clamp(0.82rem, 0.78rem + 0.14vw, 0.92rem); --step-0: clamp(1rem, 0.94rem + 0.24vw, 1.15rem); --step-1: clamp(1.3rem, 1.14rem + 0.68vw, 1.75rem); --step-2: clamp(1.9rem, 1.5rem + 1.35vw, 2.85rem); --step-3: clamp(2.45rem, 1.82rem + 2.25vw, 4rem); + --step-4: clamp(3.5rem, 2.2rem + 5.8vw, 7.5rem); --space-1: clamp(0.5rem, 0.45rem + 0.2vw, 0.68rem); --space-2: clamp(0.85rem, 0.75rem + 0.38vw, 1.15rem); --space-3: clamp(1.3rem, 1.1rem + 0.7vw, 1.9rem); --space-4: clamp(2rem, 1.65rem + 1.1vw, 3rem); --space-5: clamp(3.25rem, 2.5rem + 2.6vw, 6rem); + --radius-surface: 12px; + --radius-control: 10px; --panel-border: 1px solid var(--line); - --panel-shadow: 0 14px 34px rgb(0 0 0 / 18%); - --transition: 160ms ease; + --panel-shadow: 0 1.5rem 4rem rgb(0 7 19 / 34%); + --transition: 220ms cubic-bezier(0.2, 0.75, 0.2, 1); + --z-world: 0; + --z-atmosphere: 1; + --z-content: 2; + --z-navigation: 3; + --z-hud: 4; + --z-popover: 5; } diff --git a/tests/browser/semantic.spec.ts b/tests/browser/semantic.spec.ts index a202ddf..392c109 100644 --- a/tests/browser/semantic.spec.ts +++ b/tests/browser/semantic.spec.ts @@ -111,6 +111,25 @@ test('exposes the complete semantic portfolio from the generated document', asyn ).toBe(true); }); +test('loads the self-hosted Geist families for information and route text', async ({ + page, + distUrl, +}) => { + await page.goto(distUrl, { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => document.fonts.ready); + + const fonts = await page.evaluate(() => ({ + sansLoaded: document.fonts.check('16px "Geist Variable"'), + monoLoaded: document.fonts.check('16px "Geist Mono Variable"'), + headingFamily: getComputedStyle(document.querySelector('h1')!).fontFamily, + routeFamily: getComputedStyle(document.querySelector('.route-label')!).fontFamily, + })); + + expect(fonts).toMatchObject({ sansLoaded: true, monoLoaded: true }); + expect(fonts.headingFamily).toContain('Geist Variable'); + expect(fonts.routeFamily).toContain('Geist Mono Variable'); +}); + test('keeps canonical native anchors and safe external destinations', async ({ page, distUrl, diff --git a/tests/unit/build-contract.test.ts b/tests/unit/build-contract.test.ts index ea64b7b..ca18599 100644 --- a/tests/unit/build-contract.test.ts +++ b/tests/unit/build-contract.test.ts @@ -53,6 +53,23 @@ describe('trusted Vite asset closure', () => { }); }); + it('accepts Vite emitted-asset records when a reachable module owns the emitted file', () => { + const manifest = recursiveManifest(); + manifest['index.html']!.assets = [ + 'assets/entry-logo-a1.webp', + 'assets/geist-latin-a1.woff2', + ]; + manifest['node_modules/font/files/geist-latin.woff2'] = { + file: 'assets/geist-latin-a1.woff2', + src: 'node_modules/font/files/geist-latin.woff2', + }; + + expect(resolveViteAssetClosure(manifest).viteAssets).toEqual([ + 'assets/entry-logo-a1.webp', + 'assets/geist-latin-a1.woff2', + ]); + }); + it.each([ { name: 'a missing exact index.html entry',