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

@@ -26,6 +26,7 @@ import { useRouteLog } from '@/composables/useRouteLog';
import { useBillboard } from '@/composables/useBillboard';
import { useDashcam } from '@/composables/useDashcam';
import { useRelay } from '@/composables/useRelay';
import { useLivingTab } from '@/composables/useLivingTab';
import { assetUrl } from '@/content/asset-url';
import { signRouteFor } from '@/journey/sign-routes';
import { END_OF_SHIFT_SUBLINE } from '@/journey/dawn';
@@ -173,6 +174,16 @@ const dashcam = useDashcam({
sceneRunning: computed(() => sceneState.value === 'running'),
drift: () => actualQuality.value !== 'low' && matchMedia('(pointer: fine)').matches,
});
/* PARKING LIGHTS: the tab title and favicon follow the drive (after mount). */
useLivingTab({
routeProgress: () => journey.value.routeProgress,
activeSectionId: () => journey.value.activeSectionId,
label: () => sectionLabel(journey.value.activeSectionId),
arrived: () => journey.value.arrived,
dashcamOpen: dashcam.open,
animated: () => motion.policy.value.reason === 'full' && actualQuality.value !== 'static',
scenePending: () => motion.policy.value.allowSceneRequest && (sceneState.value === 'idle' || sceneState.value === 'loading'),
});
const activeSectionLabel = computed(() => sectionLabel(journey.value.activeSectionId));
const serverDefaultPolicy: MotionPolicy = {
allowSceneRequest: false,

View File

@@ -0,0 +1,146 @@
import { computed, onMounted, onUnmounted, shallowRef, watch, type Ref } from 'vue';
import { SIGN_ACCENTS } from '@/journey/sign-routes';
import { formatTabTitle, quantiseRoute } from '@/journey/tab-feed';
import type { SectionId } from '@/content/types';
/** Minimum gap between two writes of the drive (a brisk scroll still steps < 3 Hz). */
export const LIVING_TAB_MIN_GAP_MS = 400;
type TabIconModule = typeof import('@/journey/tab-icon');
/**
* PARKING LIGHTS: document.title and the favicon follow the drive after
* mount. Writes only when the quantised route (5%), the chapter, arrival,
* visibility, dawn or the dashcam change, at most once per
* LIVING_TAB_MIN_GAP_MS (visibility and the dashcam hand-back apply at once).
* The dashcam owns the title while open; the ignition (html[data-ignition])
* and a scene still coming up keep the metadata title. The icon renderer is
* a lazy chunk. Without full motion the icon is one fixed lit plate. The
* original title and icon come back on unmount. All DOM access is after mount.
*/
export function useLivingTab(sources: {
routeProgress: () => number;
activeSectionId: () => SectionId;
label: () => string;
arrived: () => boolean;
dashcamOpen: Readonly<Ref<boolean>>;
/** Full motion: the icon follows the drive and parks with the tab. */
animated: () => boolean;
/** The scene is still coming up (its ignition will set html[data-ignition]). */
scenePending: () => boolean;
}) {
const hidden = shallowRef(false);
const dawn = shallowRef(false);
const igniting = shallowRef(false);
const painter = shallowRef<TabIconModule | null>(null);
let mounted = false;
/** Set once the live title has been written: later scene states no longer hold it. */
let live = false;
let originalTitle = '';
let originalHref: string | null = null;
let link: HTMLLinkElement | null = null;
let observer: MutationObserver | null = null;
let timer = 0;
let settleFrame = 0;
let lastWrite = -Infinity;
// The scroll drive resets its snapshot to route 0 while the tab is hidden
// (and for one frame after it returns): the parked title and icon keep the
// last route the visitor saw.
const route = shallowRef(quantiseRoute(sources.routeProgress()));
watch(() => quantiseRoute(sources.routeProgress()), (next) => { if (!hidden.value) route.value = next; });
const title = computed(() => formatTabTitle({
routeProgress: route.value,
label: sources.label(),
arrived: sources.arrived(),
parked: hidden.value,
dashcam: sources.dashcamOpen.value,
}));
const icon = computed(() => {
const paint = painter.value;
if (paint === null) return null;
const animated = sources.animated();
return paint.tabIconHref(paint.renderTabIcon(animated
? { route: route.value, accent: SIGN_ACCENTS[sources.activeSectionId()], parked: hidden.value, dawn: dawn.value }
: { route: 1, accent: paint.TAB_ICON.glyph, parked: false, dawn: false }));
});
const write = (): void => {
window.clearTimeout(timer);
timer = 0;
lastWrite = performance.now();
// The dashcam owns the title while open (title is null then).
const hold = igniting.value || (!live && sources.scenePending());
const next = title.value === null ? null : hold ? originalTitle : title.value;
if (next !== null && document.title !== next) document.title = next;
if (!hold && next !== null) live = true;
const href = icon.value;
if (link !== null && href !== null && link.getAttribute('href') !== href) link.setAttribute('href', href);
};
const schedule = (now = false): void => {
if (!mounted) return;
const wait = now ? 0 : lastWrite + LIVING_TAB_MIN_GAP_MS - performance.now();
if (wait <= 0) write();
else if (timer === 0) timer = window.setTimeout(write, wait);
};
const readRoot = (): void => {
const root = document.documentElement;
dawn.value = root.dataset.dawn === 'day';
igniting.value = root.dataset.ignition !== undefined;
};
const syncRoute = (): void => { route.value = quantiseRoute(sources.routeProgress()); };
const onVisibility = (): void => {
hidden.value = document.visibilityState === 'hidden';
if (hidden.value) return;
// Back in view: the drive re-reads the scroll position on its next frame.
window.cancelAnimationFrame(settleFrame);
settleFrame = window.requestAnimationFrame(() => { settleFrame = window.requestAnimationFrame(syncRoute); });
};
// Post flush: on close the dashcam frame restores its stale title while
// unmounting; this runs after it and puts the live one back.
watch([title, icon], () => schedule(), { flush: 'post' });
watch([hidden, () => sources.dashcamOpen.value, igniting], () => schedule(true), { flush: 'post' });
// The scene came up: give its ignition two frames to claim the title.
watch(() => sources.scenePending(), (pending) => {
if (pending || !mounted) return;
window.cancelAnimationFrame(settleFrame);
settleFrame = window.requestAnimationFrame(() => {
settleFrame = window.requestAnimationFrame(() => schedule(true));
});
});
onMounted(() => {
originalTitle = document.title;
link = document.querySelector<HTMLLinkElement>("link[rel~='icon']");
originalHref = link?.getAttribute('href') ?? null;
readRoot();
onVisibility();
observer = new MutationObserver(readRoot);
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-dawn', 'data-ignition'] });
document.addEventListener('visibilitychange', onVisibility);
mounted = true;
schedule(true);
if (link !== null) {
import('@/journey/tab-icon').then((module) => { if (mounted) painter.value = module; }, () => {});
}
});
onUnmounted(() => {
mounted = false;
window.clearTimeout(timer);
window.cancelAnimationFrame(settleFrame);
observer?.disconnect();
document.removeEventListener('visibilitychange', onVisibility);
// Runs after a still-open dashcam frame has unmounted (and restored its own).
document.title = originalTitle;
if (link !== null) {
if (originalHref === null) link.removeAttribute('href');
else link.setAttribute('href', originalHref);
}
});
return { title, icon };
}

34
src/journey/tab-feed.ts Normal file
View File

@@ -0,0 +1,34 @@
/**
* PARKING LIGHTS: the browser tab is part of the drive. Pure, initial chunk:
* the tab title. The favicon renderer lives in tab-icon.ts (lazy). Route is
* quantised to 5% so the strings only change per step.
*/
/** Route quantum (0.05 = 5%). */
export const TAB_ROUTE_STEP = 0.05;
/** 0..1, finite, snapped to the 5% step. */
export const quantiseRoute = (route: number): number => {
const r = Number.isFinite(route) ? Math.min(1, Math.max(0, route)) : 0;
return Math.round(r / TAB_ROUTE_STEP) * TAB_ROUTE_STEP;
};
const percent = (route: number): number => Math.round(quantiseRoute(route) * 100);
export interface TabTitleInput {
routeProgress: number;
label: string;
arrived: boolean;
parked: boolean;
/** The dashcam owns document.title while open: null means "leave it". */
dashcam: boolean;
}
/** "ROUTE 45% · INFRASTRUCTURE", "ARRIVED · CONTACT", "PARKED · 45% · INFRASTRUCTURE"; null while the dashcam is open. */
export function formatTabTitle(input: TabTitleInput): string | null {
if (input.dashcam) return null;
const label = input.label.trim().toUpperCase();
const pct = input.arrived ? 100 : percent(input.routeProgress);
if (input.parked) return `PARKED · ${pct}% · ${label}`;
return input.arrived ? `ARRIVED · ${label}` : `ROUTE ${pct}% · ${label}`;
}

65
src/journey/tab-icon.ts Normal file
View File

@@ -0,0 +1,65 @@
/**
* PARKING LIGHTS, the favicon (lazy chunk, pure): a 32x32 SVG of the existing
* dark plate and 'L' glyph (index.html) plus two headlight beams that lengthen
* and brighten with the quantised route, glowing in the chapter accent.
* Parked (tab hidden): amber parking lights. Day: gold on peach.
*/
import { quantiseRoute } from '@/journey/tab-feed';
/** Tunables. */
export const TAB_ICON = {
/** Beam length in px at 0% and its growth to 100% (4 px: still a beam at 16 px tab size). */
beamMin: 4,
beamGrow: 10,
/** Beam opacity at 0% and its growth to 100%. */
glowMin: 0.55,
glowGrow: 0.4,
/** Beam half-height growth per px of length (0.14 keeps the two beams apart at 100%). */
spread: 0.14,
plate: '#0b1020',
glyph: '#ffd693',
parking: '#ffb340',
/** END OF SHIFT (html[data-dawn='day']): gold on peach. */
dawnPlate: '#ffd9c0',
dawnGlyph: '#c48a12',
} as const;
export interface TabIconInput {
route: number;
/** Beam glow colour (SIGN_ACCENTS[activeSectionId]). */
accent: string;
/** Tab hidden: amber parking lights, no beams. */
parked: boolean;
/** html[data-dawn='day']. */
dawn: boolean;
}
const LAMPS = [10, 17] as const;
const LAMP_X = 16;
const fixed = (n: number): string => String(Math.round(n * 100) / 100);
/** The favicon as an SVG string (single-quoted attributes, no line breaks). */
export function renderTabIcon(input: TabIconInput): string {
const k = TAB_ICON;
const route = quantiseRoute(input.route);
const plate = input.dawn ? k.dawnPlate : k.plate;
const glyph = input.dawn ? k.dawnGlyph : k.glyph;
const accent = input.dawn ? k.dawnGlyph : /^#[0-9a-f]{3,8}$/iu.test(input.accent) ? input.accent : k.glyph;
let lamps = '';
for (const y of LAMPS) {
if (input.parked) {
lamps += `<circle cx='${LAMP_X + 1}' cy='${y}' r='2' fill='${k.parking}'/>`;
continue;
}
const length = k.beamMin + k.beamGrow * route;
const spread = 1 + length * k.spread;
const end = LAMP_X + length;
lamps += `<path d='M${LAMP_X} ${y - 1}L${fixed(end)} ${fixed(y - spread)}V${fixed(y + spread)}L${LAMP_X} ${y + 1}Z' fill='${accent}' fill-opacity='${fixed(k.glowMin + k.glowGrow * route)}'/>`
+ `<circle cx='${LAMP_X}' cy='${y}' r='1.5' fill='${glyph}'/>`;
}
return `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='6' fill='${plate}'/>`
+ `<path d='M11 7v18h11' fill='none' stroke='${glyph}' stroke-width='4'/>${lamps}</svg>`;
}
/** link[rel=icon] href for an SVG string. */
export const tabIconHref = (svg: string): string => `data:image/svg+xml,${encodeURIComponent(svg)}`;