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

@@ -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();
});
});