feat: living tab title and favicon follow the drive
This commit is contained in:
@@ -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,
|
||||
|
||||
146
src/composables/useLivingTab.ts
Normal file
146
src/composables/useLivingTab.ts
Normal 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
34
src/journey/tab-feed.ts
Normal 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
65
src/journey/tab-icon.ts
Normal 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)}`;
|
||||
Reference in New Issue
Block a user