feat: living tab title and favicon follow the drive

This commit is contained in:
2026-10-11 11:18:47 +03:00
parent 989928c5bb
commit 4e097e5ef9
21 changed files with 596 additions and 0 deletions

View File

@@ -72,6 +72,7 @@ describe('release budgets', () => {
expect(classifyArtifact('assets/high-tier-b.js', 'optional bloom')).toBe('high-tier-extras');
expect(classifyArtifact('assets/DashcamFrame-c.js', 'postcard')).toBe('deferred-non-video');
expect(classifyArtifact('assets/DashcamFrame-c.css', '.dashcam-frame{}')).toBe('deferred-non-video');
expect(classifyArtifact('assets/tab-icon-c.js', 'renderTabIcon')).toBe('deferred-non-video');
expect(() => classifyArtifact('assets/mystery.bin', '')).toThrow(/unclassified/u);
});

View File

@@ -0,0 +1,214 @@
import { mount } from '@vue/test-utils';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { defineComponent, h, nextTick, shallowRef } from 'vue';
import { LIVING_TAB_MIN_GAP_MS, useLivingTab } from '@/composables/useLivingTab';
import { renderTabIcon, tabIconHref, TAB_ICON } from '@/journey/tab-icon';
import { SIGN_ACCENTS } from '@/journey/sign-routes';
import type { SectionId } from '@/content/types';
const ORIGINAL_TITLE = 'Luca Pîslaru — System Administrator & Full-Stack Developer';
const ORIGINAL_HREF = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3C/svg%3E";
let visibility: DocumentVisibilityState = 'visible';
const setVisibility = (next: DocumentVisibilityState): void => {
visibility = next;
document.dispatchEvent(new Event('visibilitychange'));
};
const iconHref = (): string | null => document.querySelector("link[rel~='icon']")?.getAttribute('href') ?? null;
const flush = async (): Promise<void> => {
await nextTick();
await nextTick();
};
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
/** Two animation frames: the return-to-view route re-sync. */
const frames = (): Promise<void> => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => r())));
/** The lazy icon chunk has landed and its throttled write has run. */
const iconReady = async (): Promise<void> => {
await import('@/journey/tab-icon');
await flush();
await sleep(LIVING_TAB_MIN_GAP_MS + 20);
await flush();
};
function setup(options: { animated?: boolean; pending?: boolean } = {}) {
const route = shallowRef(0.47);
const section = shallowRef<SectionId>('infrastructure');
const arrived = shallowRef(false);
const dashcamOpen = shallowRef(false);
const pending = shallowRef(options.pending ?? false);
const labels: Record<string, string> = { infrastructure: 'Infrastructure', contact: 'Contact', toolkit: 'Toolkit' };
const Host = defineComponent({
setup() {
useLivingTab({
routeProgress: () => route.value,
activeSectionId: () => section.value,
label: () => labels[section.value] ?? section.value,
arrived: () => arrived.value,
dashcamOpen,
animated: () => options.animated ?? true,
scenePending: () => pending.value,
});
return () => h('div');
},
});
const wrapper = mount(Host);
return { wrapper, route, section, arrived, dashcamOpen, pending };
}
describe('useLivingTab', () => {
beforeEach(() => {
visibility = 'visible';
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => visibility });
document.head.innerHTML = `<link rel="icon" type="image/svg+xml" href="${ORIGINAL_HREF}">`;
document.title = ORIGINAL_TITLE;
delete document.documentElement.dataset.dawn;
delete document.documentElement.dataset.ignition;
});
afterEach(() => {
vi.useRealTimers();
document.head.innerHTML = '';
});
it('writes the route title and the lit icon after mount', async () => {
const { wrapper } = setup();
expect(document.title).toBe('ROUTE 45% · INFRASTRUCTURE');
await iconReady();
expect(iconHref()).toBe(tabIconHref(renderTabIcon({
route: 0.45, accent: SIGN_ACCENTS.infrastructure, parked: false, dawn: false,
})));
wrapper.unmount();
});
it('parks on visibilitychange and relights on return in one step', async () => {
const { wrapper } = setup();
await iconReady();
const lit = iconHref();
setVisibility('hidden');
await flush();
expect(document.title).toBe('PARKED · 45% · INFRASTRUCTURE');
expect(decodeURIComponent(iconHref() ?? '')).toContain(TAB_ICON.parking);
setVisibility('visible');
await flush();
expect(document.title).toBe('ROUTE 45% · INFRASTRUCTURE');
expect(iconHref()).toBe(lit);
wrapper.unmount();
});
it('keeps the last seen route while parked (the drive resets to 0 when hidden)', async () => {
const { wrapper, route } = setup();
await iconReady();
const lit = iconHref();
// useScrollDrive resets its snapshot on hidden, before this composable hears the event.
route.value = 0;
setVisibility('hidden');
await flush();
expect(document.title).toBe('PARKED · 45% · INFRASTRUCTURE');
setVisibility('visible');
await flush();
expect(document.title).toBe('ROUTE 45% · INFRASTRUCTURE');
expect(iconHref()).toBe(lit);
// The drive's first frame after return reads the real scroll position.
route.value = 0.47;
await flush();
await sleep(LIVING_TAB_MIN_GAP_MS + 20);
expect(document.title).toBe('ROUTE 45% · INFRASTRUCTURE');
wrapper.unmount();
});
it('restores the original title and icon on unmount', async () => {
const { wrapper } = setup();
await iconReady();
expect(document.title).not.toBe(ORIGINAL_TITLE);
expect(iconHref()).not.toBe(ORIGINAL_HREF);
wrapper.unmount();
expect(document.title).toBe(ORIGINAL_TITLE);
expect(iconHref()).toBe(ORIGINAL_HREF);
setVisibility('hidden');
await flush();
expect(document.title).toBe(ORIGINAL_TITLE);
});
it('leaves the title to the dashcam while open and resumes on close', async () => {
const { wrapper, dashcamOpen, route } = setup();
dashcamOpen.value = true;
await flush();
document.title = 'DASHCAM · 45% · INFRASTRUCTURE · 03:51';
route.value = 0.8;
setVisibility('hidden');
await flush();
await sleep(LIVING_TAB_MIN_GAP_MS + 20);
expect(document.title).toBe('DASHCAM · 45% · INFRASTRUCTURE · 03:51');
setVisibility('visible');
// The frame restores the title it found when it opened, then the tab resumes.
document.title = 'ROUTE 45% · INFRASTRUCTURE';
dashcamOpen.value = false;
await frames();
await flush();
expect(document.title).toBe('ROUTE 80% · INFRASTRUCTURE');
wrapper.unmount();
});
it('steps at most once per gap while the route moves, and reads arrival', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'performance'] });
const { wrapper, route, section, arrived } = setup();
route.value = 0.481; // same 5% step as 0.47: nothing to do
await flush();
expect(document.title).toBe('ROUTE 45% · INFRASTRUCTURE');
route.value = 0.6;
await flush();
expect(document.title).toBe('ROUTE 45% · INFRASTRUCTURE');
vi.advanceTimersByTime(LIVING_TAB_MIN_GAP_MS);
expect(document.title).toBe('ROUTE 60% · INFRASTRUCTURE');
section.value = 'contact';
route.value = 1;
arrived.value = true;
await flush();
vi.advanceTimersByTime(LIVING_TAB_MIN_GAP_MS);
expect(document.title).toBe('ARRIVED · CONTACT');
wrapper.unmount();
});
it('turns the icon gold on peach at day', async () => {
const { wrapper } = setup();
await iconReady();
document.documentElement.dataset.dawn = 'day';
await iconReady();
expect(decodeURIComponent(iconHref() ?? '')).toContain(TAB_ICON.dawnPlate);
wrapper.unmount();
});
it('keeps the metadata title through the ignition', async () => {
document.documentElement.dataset.ignition = 'cold';
const { wrapper } = setup();
expect(document.title).toBe(ORIGINAL_TITLE);
delete document.documentElement.dataset.ignition;
await sleep(0);
await flush();
expect(document.title).toBe('ROUTE 45% · INFRASTRUCTURE');
wrapper.unmount();
});
it('holds the metadata title while the scene comes up', async () => {
const { wrapper, pending } = setup({ pending: true });
expect(document.title).toBe(ORIGINAL_TITLE);
pending.value = false;
await flush();
await sleep(120);
expect(document.title).toBe('ROUTE 45% · INFRASTRUCTURE');
wrapper.unmount();
});
it('keeps a fixed lit icon without full motion (no visibility swap)', async () => {
const { wrapper, route } = setup({ animated: false });
const fixed = tabIconHref(renderTabIcon({ route: 1, accent: TAB_ICON.glyph, parked: false, dawn: false }));
await iconReady();
expect(iconHref()).toBe(fixed);
route.value = 0.1;
await flush();
setVisibility('hidden');
await flush();
expect(iconHref()).toBe(fixed);
expect(document.title).toBe('PARKED · 10% · INFRASTRUCTURE');
wrapper.unmount();
});
});

115
tests/unit/tab-feed.test.ts Normal file
View File

@@ -0,0 +1,115 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
import { formatTabTitle, quantiseRoute } from '@/journey/tab-feed';
import { renderTabIcon, tabIconHref, TAB_ICON } from '@/journey/tab-icon';
import { validateProductionInputs } from '@/content/schemas';
import { render } from '@/entry-server';
const base = { routeProgress: 0.47, label: 'Infrastructure', arrived: false, parked: false, dashcam: false };
describe('formatTabTitle', () => {
it('reads the route in 5% steps with an uppercase chapter', () => {
expect(formatTabTitle(base)).toBe('ROUTE 45% · INFRASTRUCTURE');
expect(formatTabTitle({ ...base, routeProgress: 0.48 })).toBe('ROUTE 50% · INFRASTRUCTURE');
expect(formatTabTitle({ ...base, routeProgress: 0, label: 'Start' })).toBe('ROUTE 0% · START');
expect(formatTabTitle({ ...base, label: ' Selected builds ' })).toBe('ROUTE 45% · SELECTED BUILDS');
});
it('clamps and survives non-finite progress', () => {
expect(formatTabTitle({ ...base, routeProgress: 1.4 })).toBe('ROUTE 100% · INFRASTRUCTURE');
expect(formatTabTitle({ ...base, routeProgress: -1 })).toBe('ROUTE 0% · INFRASTRUCTURE');
expect(formatTabTitle({ ...base, routeProgress: Number.NaN })).toBe('ROUTE 0% · INFRASTRUCTURE');
});
it('parks, arrives, and yields to the dashcam', () => {
expect(formatTabTitle({ ...base, parked: true })).toBe('PARKED · 45% · INFRASTRUCTURE');
expect(formatTabTitle({ ...base, arrived: true, label: 'Contact' })).toBe('ARRIVED · CONTACT');
expect(formatTabTitle({ ...base, arrived: true, parked: true, label: 'Contact' })).toBe('PARKED · 100% · CONTACT');
expect(formatTabTitle({ ...base, dashcam: true })).toBeNull();
expect(formatTabTitle({ ...base, dashcam: true, parked: true, arrived: true })).toBeNull();
});
it('quantises to stable steps', () => {
expect(quantiseRoute(0.024)).toBe(0);
expect(quantiseRoute(0.026)).toBe(0.05);
expect(new Set([0.5, 0.51, 0.52, 0.49].map((r) => formatTabTitle({ ...base, routeProgress: r }))).size).toBe(1);
});
});
const beamLengths = (svg: string): number[] =>
[...svg.matchAll(/<path d='M16 \d+L([\d.]+) /gu)].map((m) => Number(m[1]) - 16);
const opacities = (svg: string): number[] => [...svg.matchAll(/fill-opacity='([\d.]+)'/gu)].map((m) => Number(m[1]));
describe('renderTabIcon', () => {
const icon = { route: 0, accent: '#8be9ed', parked: false, dawn: false };
it('keeps the dark plate and the L glyph', () => {
const svg = renderTabIcon(icon);
expect(svg.startsWith("<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'>")).toBe(true);
expect(svg).toContain(`fill='${TAB_ICON.plate}'`);
expect(svg).toContain(`<path d='M11 7v18h11' fill='none' stroke='${TAB_ICON.glyph}' stroke-width='4'/>`);
expect(svg).not.toMatch(/["\n]/u);
});
it('lengthens and brightens two beams with the route, in the accent', () => {
const at = (route: number) => renderTabIcon({ ...icon, route });
const [l0, l50, l100] = [at(0), at(0.5), at(1)].map(beamLengths);
expect(l0).toEqual([4, 4]);
expect(l50).toEqual([9, 9]);
expect(l100).toEqual([14, 14]);
expect(opacities(at(0))).toEqual([0.55, 0.55]);
expect(opacities(at(0.5))).toEqual([0.75, 0.75]);
expect(opacities(at(1))).toEqual([0.95, 0.95]);
expect(at(0.5).match(/fill='#8be9ed'/gu)).toHaveLength(2);
expect(at(0.51)).toBe(at(0.5));
});
it('drops to amber parking lights when parked', () => {
const svg = renderTabIcon({ ...icon, route: 0.8, parked: true });
expect(beamLengths(svg)).toEqual([]);
expect(svg.match(new RegExp(`fill='${TAB_ICON.parking}'`, 'gu'))).toHaveLength(2);
expect(svg).not.toContain('#8be9ed');
});
it('turns gold on peach at day', () => {
const svg = renderTabIcon({ ...icon, route: 1, dawn: true });
expect(svg).toContain(`<rect width='32' height='32' rx='6' fill='${TAB_ICON.dawnPlate}'/>`);
expect(svg).toContain(`stroke='${TAB_ICON.dawnGlyph}'`);
expect(svg).not.toContain(TAB_ICON.plate);
expect(svg).not.toContain('#8be9ed');
});
it('ignores an accent that is not a hex colour', () => {
expect(renderTabIcon({ ...icon, accent: "red'/><script>" })).not.toContain('script');
});
it('encodes the href as a data:image/svg+xml URI', () => {
const svg = renderTabIcon({ ...icon, route: 0.5 });
const href = tabIconHref(svg);
expect(href.startsWith('data:image/svg+xml,%3Csvg')).toBe(true);
expect(href).not.toMatch(/[<>#" ]/u);
expect(decodeURIComponent(href.slice('data:image/svg+xml,'.length))).toBe(svg);
});
});
describe('server HTML', () => {
const fixture = (name: string): unknown =>
JSON.parse(readFileSync(resolve(process.cwd(), 'tests/fixtures', name), 'utf8'));
it('carries no living tab state', async () => {
const html = await render('/', validateProductionInputs(
fixture('portfolio-content.valid.json'),
fixture('asset-manifest.valid.json'),
fixture('site-config.preview.json'),
new URL('file:///prepared-assets/'),
));
expect(html).not.toMatch(/ROUTE \d+%|PARKED ·|ARRIVED ·|data:image\/svg\+xml/u);
});
it('leaves index.html title and icon alone', () => {
const index = readFileSync(resolve(process.cwd(), 'index.html'), 'utf8');
expect(index).toContain('<title>Portfolio</title>');
expect(index).toContain("%3Cpath d='M11 7v18h11' fill='none' stroke='%23ffd693' stroke-width='4'/%3E");
});
});