feat: living tab title and favicon follow the drive
@@ -79,3 +79,11 @@ Run: `npm run build:preview` then serve `dist` (dev server shows a blank page).
|
|||||||
- **Verdict: SHIP** (Fable, hardware GL 1440x900 high tier, wheel-driven passes, frames every ~100 ms): the deck is big, reads as a concrete overpass (parapet, soffit, piers, three strips) and the plate is legible from mid-distance; the sign/deck hand-off is clean (the deck holds in the haze until the sign has passed, and on a brisk approach it sits behind the sign, never through it); the shade beat is one slow dip and release with no step over 22/255 in frame mean (sky band <= 45) at any of Start->Profile, Profile->Experience, Infra->Builds, Builds->Additional, Toolkit->Contact; reverse scroll re-emerges under the same deck (shade latches again, 1 s apart); brisk and nav-jump passes get the structure and no shade/sound; reduced motion renders the static poster with nothing; 1024x700 (medium) shades, 390x844 (low) shows deck+strips only; rAF p50 16.7 / p95 16.7-16.8 ms in every serial run, 0-1 frames over 34 ms, no console errors. Fixes made during review: (1) DECK_PASS_Z 30 -> 25 (the chase camera's z): the deck had gone overhead ~5% of the gap (~1 s at a slow scroll) before the HUD cut, now the cut lands ~0.5 s after 'under' sets, in the deck's shadow; (2) STRIP_GLOW 2.4 -> 1.3 and POOL_GAIN 1.6 -> 1.1: the strips crossed the 0.85 bloom threshold and washed the frame then dropped (sky band 146 -> 32 in one step at Contact), now the strips read as sodium tubes without a flash; (3) plate 11x2.75 -> 16x4.2 over the deck face + parapet, 168 px type, 14 px accent bars: 'EXIT 0N' is readable at z ~ -100 instead of only in the last 30 m; (4) pier shader: 2.6 m form lines, grime at the base and the sodium pool bouncing up the lower faces, so the close pier is poured concrete, not a flat slab.
|
- **Verdict: SHIP** (Fable, hardware GL 1440x900 high tier, wheel-driven passes, frames every ~100 ms): the deck is big, reads as a concrete overpass (parapet, soffit, piers, three strips) and the plate is legible from mid-distance; the sign/deck hand-off is clean (the deck holds in the haze until the sign has passed, and on a brisk approach it sits behind the sign, never through it); the shade beat is one slow dip and release with no step over 22/255 in frame mean (sky band <= 45) at any of Start->Profile, Profile->Experience, Infra->Builds, Builds->Additional, Toolkit->Contact; reverse scroll re-emerges under the same deck (shade latches again, 1 s apart); brisk and nav-jump passes get the structure and no shade/sound; reduced motion renders the static poster with nothing; 1024x700 (medium) shades, 390x844 (low) shows deck+strips only; rAF p50 16.7 / p95 16.7-16.8 ms in every serial run, 0-1 frames over 34 ms, no console errors. Fixes made during review: (1) DECK_PASS_Z 30 -> 25 (the chase camera's z): the deck had gone overhead ~5% of the gap (~1 s at a slow scroll) before the HUD cut, now the cut lands ~0.5 s after 'under' sets, in the deck's shadow; (2) STRIP_GLOW 2.4 -> 1.3 and POOL_GAIN 1.6 -> 1.1: the strips crossed the 0.85 bloom threshold and washed the frame then dropped (sky band 146 -> 32 in one step at Contact), now the strips read as sodium tubes without a flash; (3) plate 11x2.75 -> 16x4.2 over the deck face + parapet, 168 px type, 14 px accent bars: 'EXIT 0N' is readable at z ~ -100 instead of only in the last 30 m; (4) pier shader: 2.6 m form lines, grime at the base and the sodium pool bouncing up the lower faces, so the close pier is poured concrete, not a flat slab.
|
||||||
- **Known weaknesses:** the pier at x 16 crosses the left edge of the Selected-builds billboard (stilt at x 17) in front of it for ~6 m of travel, and one expressway lamp head pokes past the right pier at Infra->Builds (both brief, both read as foreground structure); the Contact deck is lit by the dawn sun and reads paler than the night decks (intentional, but less massive); a moderate approach walk can set data-underpass for ~50 ms once before the real pass (hidden by the 0.3 s HUD colour transition, no visible effect); the low tier never shades, so phones get the structure without the beat; `portfolio-underpass-frame` events only exist in test builds, so the review read html[data-underpass] and screencast frames instead.
|
- **Known weaknesses:** the pier at x 16 crosses the left edge of the Selected-builds billboard (stilt at x 17) in front of it for ~6 m of travel, and one expressway lamp head pokes past the right pier at Infra->Builds (both brief, both read as foreground structure); the Contact deck is lit by the dawn sun and reads paler than the night decks (intentional, but less massive); a moderate approach walk can set data-underpass for ~50 ms once before the real pass (hidden by the 0.3 s HUD colour transition, no visible effect); the low tier never shades, so phones get the structure without the beat; `portfolio-underpass-frame` events only exist in test builds, so the review read html[data-underpass] and screencast frames instead.
|
||||||
- Screenshots: docs/overnight/round9/ (01 EXIT 02 rising behind the docked sign, 02 under the deck with strips and piers, 03 EXIT 03 overhead at the cut with the amber HUD label, 04 EXIT 08 at dawn into Contact, 05 EXIT 05 pier against the Selected-builds billboard, 06 phone 390x844 low tier, 07 contact sheet of the Profile->Experience pass 20.5-25.5 s).
|
- Screenshots: docs/overnight/round9/ (01 EXIT 02 rising behind the docked sign, 02 under the deck with strips and piers, 03 EXIT 03 overhead at the cut with the amber HUD label, 04 EXIT 08 at dawn into Contact, 05 EXIT 05 pier against the Selected-builds billboard, 06 phone 390x844 low tier, 07 contact sheet of the Profile->Experience pass 20.5-25.5 s).
|
||||||
|
|
||||||
|
## Round 10: PARKING LIGHTS (living tab title + favicon)
|
||||||
|
- **What:** the browser tab is part of the drive. After mount, document.title follows the route in 5% steps ("ROUTE 45% · INFRASTRUCTURE", "ARRIVED · CONTACT"), reads "PARKED · 45% · INFRASTRUCTURE" while the tab is hidden, keeps the metadata title through the ignition opening and while the scene is still coming up, hands the title to the dashcam while it is open and resumes on close. The favicon is a lazy SVG data URI: the index.html plate and 'L' plus two headlight beams that lengthen and brighten with the route in the chapter accent (SIGN_ACCENTS), two amber parking lights when the tab is hidden, gold on peach at html[data-dawn='day']. Writes are rate-limited to one per 400 ms (visibility, dashcam hand-back and the scene settling write at once). Reduced motion / static: live title, one fixed lit icon. SSR output and index.html unchanged.
|
||||||
|
- **Why:** the drive had no presence outside the viewport; a juror with twelve tabs open sees the route resume in the tab strip, and the parked state is the last thing the site says when they look away.
|
||||||
|
- **Build:** journey/tab-feed.ts (pure, initial chunk: title + 5% quantiser), journey/tab-icon.ts (lazy chunk, 0.67 KB gz), composables/useLivingTab.ts, AppShell.vue (one call), bundle-budgets.mjs (tab-icon chunk class). 894 tests (77 files, +22), initial JS 119.07 KB gz (+0.04 KB; **0.9 KB left**), CSS 26.78 KB gz, scene chunk unchanged.
|
||||||
|
- **Verdict: SHIP with fixes** (Fable, hardware GL 1440x900, fresh contexts; title and link[rel=icon] observed with MutationObservers over a full drive, park/return at Contact and Infrastructure, reverse scroll, dashcam open/close, brisk 40% scroll, reduced motion, nav jumps, #contact deep link, return visit): the title steps per chapter exactly as the HUD does (5% → 90%, ARRIVED at Contact), the metadata title holds until html[data-ignition] is removed (+4.5 s on first visit, ~3.9 s on the short form), the deep link holds the metadata title until the scene is running then writes ARRIVED · CONTACT; the dashcam owns the title ("DASHCAM · 27% · INFRASTRUCTURE · 03:12") and the route title is back within the same tick the frame unmounts (no stale title after close, scroll after close steps normally); max 3 title/icon writes in any 1 s window on a brisk scroll (2.5 Hz cap holds); rAF p50 16.7 / p95 16.7-16.8 ms with 0 frames over 34 ms during the drive and the brisk scroll; no console errors or warnings, no hydration warnings. Fixes made during review: (1) **parked title read 0%**: useScrollDrive resets its snapshot to route 0 on visibilitychange (before this composable hears it) and keeps 0 for one frame after return, so the hidden tab said "PARKED · 0% · INFRASTRUCTURE" and briefly "ROUTE 0%" on return; the composable now freezes the route while hidden and re-syncs two frames after return (unit test added, two tests that had encoded the old behaviour corrected); (2) **beams invisible at tab size** in the first chapters: at 16 px the 0-20% beams were 2-4 px dots at 35-45% opacity; floor raised (beamMin 2 → 4 px, glowMin 0.35 → 0.55; max unchanged at 14 px / 0.95) so the icon reads as "lamps on" from the first write and still grows x3.5 to Contact. Judged at 16/24/32/128 px in light and dark tab strips: the 'L' reads at every size and state (dark plate on a light strip is a strong silhouette), the parked amber dots are unmistakable, the day plate is the one state that reads as a different icon at a glance (intended), chapter accents are distinct at 32 px and still tellable at 16 px for cyan vs violet vs coral (pink vs coral is a near miss at 16 px).
|
||||||
|
- **Known weaknesses:** at 16 px the accent is a tint on a 4-14 px smear, so the chapter colour is a subliminal cue rather than a readable signal; the dashcam title shows the raw percentage (27%) while the tab shows the quantised one (25%), two vocabularies for two seconds; pink is shared by Start/Profile/Additional work (SIGN_ACCENTS), so those chapters are told apart only by the beam length; headless Chromium cannot hide a tab, so the parked states were driven by dispatching visibilitychange with document.visibilityState overridden (the unit test does the same); under that emulation the scene loop paused and did not resume (html[data-dawn] stayed 'day' through the reverse drive in the timeline sheet), which a run without the emulation does not show (dawn rewinds to night at Infrastructure in ~1.6 s) — a real tab switch should be checked once by hand; under reduced motion SceneCanvas rewrites html[data-dawn] every frame (pre-existing), which fires this composable's MutationObserver at 60 Hz for a cheap no-op; return visits open at route 0 with the short ignition (route memory does not restore the scroll position, pre-existing).
|
||||||
|
- Screenshots: docs/overnight/round10/ (00-05 favicon contact sheets from the build; 06 favicon states at 128/32/24/16 px in light and dark tab strips; 07 observed title + favicon timeline over the 35 s review drive, every title write with the icon it shipped with; 09 ignition with the metadata title and the index.html icon held; 11 tab hidden at Infrastructure, "PARKED · 25%" with amber lights; 12 dashcam owning the title; 13 END OF SHIFT, gold on peach).
|
||||||
|
|||||||
BIN
docs/overnight/round10/00-contact-sheet.webp
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
docs/overnight/round10/01-route-0-start.webp
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
docs/overnight/round10/02-route-50-infrastructure.webp
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
docs/overnight/round10/03-route-100-contact.webp
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
docs/overnight/round10/04-parked-50.webp
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
docs/overnight/round10/05-dawn-arrived.webp
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
docs/overnight/round10/06-favicon-states-tab-size.webp
Normal file
|
After Width: | Height: | Size: 143 KiB |
BIN
docs/overnight/round10/07-title-timeline-drive.webp
Normal file
|
After Width: | Height: | Size: 276 KiB |
BIN
docs/overnight/round10/09-ignition-metadata-title.webp
Normal file
|
After Width: | Height: | Size: 98 KiB |
BIN
docs/overnight/round10/11-parked-infrastructure.webp
Normal file
|
After Width: | Height: | Size: 158 KiB |
BIN
docs/overnight/round10/12-dashcam-owns-title.webp
Normal file
|
After Width: | Height: | Size: 158 KiB |
BIN
docs/overnight/round10/13-dawn-arrived-gold-on-peach.webp
Normal file
|
After Width: | Height: | Size: 134 KiB |
@@ -44,6 +44,8 @@ export const classifyArtifact = (path, source = '') => {
|
|||||||
if (/^assets\/relay-tone-[^/]+\.js$/u.test(path)) return 'deferred-non-video';
|
if (/^assets\/relay-tone-[^/]+\.js$/u.test(path)) return 'deferred-non-video';
|
||||||
// UNDERPASS: the pass swell, loaded when the first deck shades the road with sound on.
|
// UNDERPASS: the pass swell, loaded when the first deck shades the road with sound on.
|
||||||
if (/^assets\/underpass-pass-[^/]+\.js$/u.test(path)) return 'deferred-non-video';
|
if (/^assets\/underpass-pass-[^/]+\.js$/u.test(path)) return 'deferred-non-video';
|
||||||
|
// PARKING LIGHTS: the favicon renderer, loaded after mount.
|
||||||
|
if (/^assets\/tab-icon-[^/]+\.js$/u.test(path)) return 'deferred-non-video';
|
||||||
if (/^assets\/nightdrive-(?:grand-tourer|districts)-[^/]+\.glb$/u.test(path)) return 'first-live-scene';
|
if (/^assets\/nightdrive-(?:grand-tourer|districts)-[^/]+\.glb$/u.test(path)) return 'first-live-scene';
|
||||||
if (/\.(?:woff2?|ttf|otf)$/u.test(path)) return 'initial-fonts';
|
if (/\.(?:woff2?|ttf|otf)$/u.test(path)) return 'initial-fonts';
|
||||||
if (/^assets\/hero-[^/]+\.(?:avif|webp|png|jpe?g)$/u.test(path)) return 'hero-poster';
|
if (/^assets\/hero-[^/]+\.(?:avif|webp|png|jpe?g)$/u.test(path)) return 'hero-poster';
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { useRouteLog } from '@/composables/useRouteLog';
|
|||||||
import { useBillboard } from '@/composables/useBillboard';
|
import { useBillboard } from '@/composables/useBillboard';
|
||||||
import { useDashcam } from '@/composables/useDashcam';
|
import { useDashcam } from '@/composables/useDashcam';
|
||||||
import { useRelay } from '@/composables/useRelay';
|
import { useRelay } from '@/composables/useRelay';
|
||||||
|
import { useLivingTab } from '@/composables/useLivingTab';
|
||||||
import { assetUrl } from '@/content/asset-url';
|
import { assetUrl } from '@/content/asset-url';
|
||||||
import { signRouteFor } from '@/journey/sign-routes';
|
import { signRouteFor } from '@/journey/sign-routes';
|
||||||
import { END_OF_SHIFT_SUBLINE } from '@/journey/dawn';
|
import { END_OF_SHIFT_SUBLINE } from '@/journey/dawn';
|
||||||
@@ -173,6 +174,16 @@ const dashcam = useDashcam({
|
|||||||
sceneRunning: computed(() => sceneState.value === 'running'),
|
sceneRunning: computed(() => sceneState.value === 'running'),
|
||||||
drift: () => actualQuality.value !== 'low' && matchMedia('(pointer: fine)').matches,
|
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 activeSectionLabel = computed(() => sectionLabel(journey.value.activeSectionId));
|
||||||
const serverDefaultPolicy: MotionPolicy = {
|
const serverDefaultPolicy: MotionPolicy = {
|
||||||
allowSceneRequest: false,
|
allowSceneRequest: false,
|
||||||
|
|||||||
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
@@ -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
@@ -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)}`;
|
||||||
@@ -72,6 +72,7 @@ describe('release budgets', () => {
|
|||||||
expect(classifyArtifact('assets/high-tier-b.js', 'optional bloom')).toBe('high-tier-extras');
|
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.js', 'postcard')).toBe('deferred-non-video');
|
||||||
expect(classifyArtifact('assets/DashcamFrame-c.css', '.dashcam-frame{}')).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);
|
expect(() => classifyArtifact('assets/mystery.bin', '')).toThrow(/unclassified/u);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
214
tests/unit/living-tab.test.ts
Normal 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
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||