diff --git a/docs/overnight/LOG.md b/docs/overnight/LOG.md index c391127..3c32e53 100644 --- a/docs/overnight/LOG.md +++ b/docs/overnight/LOG.md @@ -31,3 +31,11 @@ Run: `npm run build:preview` then serve `dist` (dev server shows a blank page). - **Known weaknesses:** the "SHIFT OVER" label swap is invisible (HUD arrival label collapsed); the bottom credit is mostly hidden by the Contact panel (pre-existing); horizon still bright at full day; reduced-motion version reads as dusk rather than sunrise; audio checked by code review only. - Screenshots: docs/overnight/round3/. +## Round 4: Mile Markers (Profile + Experience) +- **What:** the gantry HUD subline becomes a scroll-driven ROUTE LOG odometer ("LOG 02/04 · JUN 2025 → AUG 2025 · CCF", digit roll, " · REWIND" on reverse), and lit kilometre posts with accent plates and warm light pools come down the free shoulder, one per role/fact, flashing as their plate crosses the activation line. +- **Why:** Profile and Experience were the only chapters with no world beat and left half the viewport empty. Career dates are real content, parsed from the JSON, nothing invented. +- **Build:** src/journey/route-log.ts, useRouteLog.ts, scene/world/milestones.ts, styles/route-log.css; generalised maintenance-progress helpers. 766 tests green, initial JS +1.25 KB gz (115.9 of 120 - **budget nearly spent, keep DOM-side additions tiny**). +- **Verdict: KEEP with fixes** (Fable): the delivered 0.18 x 2.6 m posts were 25 px specks at 80 m, so they were scaled to the ~2.3x world (0.4 x 6.4 m, 2.2 x 1.0 m plate), the pass point moved to z -12 to coincide with the tick, additive light pools added, and the phone subline overflow fixed. +- **Known weaknesses:** the free side is narrow because of the camera frame shift, so the big pass is brief; plates read mostly white under tonemapping; no lit number glyphs on the plates. +- Screenshots: docs/overnight/round4/. + diff --git a/docs/overnight/round4/before-experience.webp b/docs/overnight/round4/before-experience.webp new file mode 100644 index 0000000..e4da592 Binary files /dev/null and b/docs/overnight/round4/before-experience.webp differ diff --git a/docs/overnight/round4/before-profile.webp b/docs/overnight/round4/before-profile.webp new file mode 100644 index 0000000..9e1ebdd Binary files /dev/null and b/docs/overnight/round4/before-profile.webp differ diff --git a/docs/overnight/round4/experience-hover.webp b/docs/overnight/round4/experience-hover.webp new file mode 100644 index 0000000..7e67422 Binary files /dev/null and b/docs/overnight/round4/experience-hover.webp differ diff --git a/docs/overnight/round4/experience-p15.webp b/docs/overnight/round4/experience-p15.webp new file mode 100644 index 0000000..564a913 Binary files /dev/null and b/docs/overnight/round4/experience-p15.webp differ diff --git a/docs/overnight/round4/experience-p33.webp b/docs/overnight/round4/experience-p33.webp new file mode 100644 index 0000000..be55435 Binary files /dev/null and b/docs/overnight/round4/experience-p33.webp differ diff --git a/docs/overnight/round4/experience-p39.webp b/docs/overnight/round4/experience-p39.webp new file mode 100644 index 0000000..29d259e Binary files /dev/null and b/docs/overnight/round4/experience-p39.webp differ diff --git a/docs/overnight/round4/mobile-390-rewind.webp b/docs/overnight/round4/mobile-390-rewind.webp new file mode 100644 index 0000000..d34fa91 Binary files /dev/null and b/docs/overnight/round4/mobile-390-rewind.webp differ diff --git a/docs/overnight/round4/mobile-390.webp b/docs/overnight/round4/mobile-390.webp new file mode 100644 index 0000000..df06766 Binary files /dev/null and b/docs/overnight/round4/mobile-390.webp differ diff --git a/docs/overnight/round4/profile-p20.webp b/docs/overnight/round4/profile-p20.webp new file mode 100644 index 0000000..986d197 Binary files /dev/null and b/docs/overnight/round4/profile-p20.webp differ diff --git a/docs/overnight/round4/profile-p46.webp b/docs/overnight/round4/profile-p46.webp new file mode 100644 index 0000000..cd00a27 Binary files /dev/null and b/docs/overnight/round4/profile-p46.webp differ diff --git a/docs/overnight/round4/profile-p52.webp b/docs/overnight/round4/profile-p52.webp new file mode 100644 index 0000000..0a3ef8c Binary files /dev/null and b/docs/overnight/round4/profile-p52.webp differ diff --git a/docs/overnight/round4/reduced-motion.webp b/docs/overnight/round4/reduced-motion.webp new file mode 100644 index 0000000..7c7cf1c Binary files /dev/null and b/docs/overnight/round4/reduced-motion.webp differ diff --git a/docs/overnight/round4/rewind.webp b/docs/overnight/round4/rewind.webp new file mode 100644 index 0000000..92a3137 Binary files /dev/null and b/docs/overnight/round4/rewind.webp differ diff --git a/docs/overnight/round4/sweep-experience-pass.webp b/docs/overnight/round4/sweep-experience-pass.webp new file mode 100644 index 0000000..2648e51 Binary files /dev/null and b/docs/overnight/round4/sweep-experience-pass.webp differ diff --git a/docs/overnight/round4/sweep-profile-pass.webp b/docs/overnight/round4/sweep-profile-pass.webp new file mode 100644 index 0000000..8c94393 Binary files /dev/null and b/docs/overnight/round4/sweep-profile-pass.webp differ diff --git a/src/App.vue b/src/App.vue index 332d1e2..ade4e07 100644 --- a/src/App.vue +++ b/src/App.vue @@ -17,6 +17,7 @@ import './styles/cards.css'; import './styles/maintenance.css'; import './styles/print.css'; import './styles/headlight.css'; +import './styles/route-log.css'; defineProps<{ inputs: Readonly }>(); diff --git a/src/components/AppShell.vue b/src/components/AppShell.vue index 68d1a00..8ad8ed8 100644 --- a/src/components/AppShell.vue +++ b/src/components/AppShell.vue @@ -22,6 +22,7 @@ import { useKeyboardNavigation } from '@/composables/useKeyboardNavigation'; import { useMaintenanceWindow } from '@/composables/useMaintenanceWindow'; import { useMotionPreferences } from '@/composables/useMotionPreferences'; import { useRouteMemory } from '@/composables/useRouteMemory'; +import { useRouteLog } from '@/composables/useRouteLog'; import { assetUrl } from '@/content/asset-url'; import { signRouteFor } from '@/journey/sign-routes'; import { END_OF_SHIFT_SUBLINE } from '@/journey/dawn'; @@ -86,6 +87,8 @@ const journeyBinding = useJourneySections( const journey = journeyBinding.snapshot; /* The 03:12 window (Infrastructure): the plate's sub-line and the diagram steps. */ const maintenanceSubline = useMaintenanceWindow(journey); +/* MILE MARKERS (Experience, Profile): the ROUTE LOG odometer on the same sub-line. */ +const routeLogSubline = useRouteLog(journey, () => props.content); useCardEntrances(); const route = useRouteMemory(canonicalSectionIds, () => journey.value.activeSectionId); const resume = computed(() => { @@ -133,8 +136,9 @@ const controlsReady = shallowRef(false); /* END OF SHIFT: parked at the destination the plate's sub-line reads the shift's end. Client-only, like the 03:12 window, so the server HTML hydrates unchanged. */ -const hudSubline = computed(() => maintenanceSubline.value - || (controlsReady.value && journey.value.arrived ? END_OF_SHIFT_SUBLINE : '')); +const hudSubline = computed(() => (controlsReady.value && journey.value.arrived ? END_OF_SHIFT_SUBLINE : '') + || maintenanceSubline.value + || routeLogSubline.value); const adaptive = useAdaptiveQuality({ maximum: computed(() => motion.policy.value.maximumTier), sceneState, diff --git a/src/components/JourneyHud.vue b/src/components/JourneyHud.vue index abc289f..dccf4b5 100644 --- a/src/components/JourneyHud.vue +++ b/src/components/JourneyHud.vue @@ -22,6 +22,11 @@ const next = computed(() => a route rule that fills along its lower edge. */ const route = computed(() => Math.min(1, Math.max(0, props.journey.routeProgress))); const routePercent = computed(() => `${Math.round(route.value * 100)}%`); +/* MILE MARKERS: "LOG 02/04 · ..." rolls its index on 1-digit odometer + columns, each keyed by its value so a change re-enters (route-log.css). + Client-only: the server sub-line is empty, so its HTML is unchanged. */ +const log = computed(() => /^LOG (\d+)\/(\d+)(.*)$/.exec(props.subline ?? '')); +const rewind = computed(() => (props.subline ?? '').endsWith('REWIND')); /* The plate is keyed by chapter, so a new chapter is a new element: under reduced motion it fades in over 160 ms (gantry.css) instead of swapping. */ @@ -43,6 +48,10 @@ const routePercent = computed(() => `${Math.round(route.value * 100)}%`); {{ sectionLabel }} ARRIVED - {{ subline }} + + {{ subline }} diff --git a/src/composables/useRouteLog.ts b/src/composables/useRouteLog.ts new file mode 100644 index 0000000..e66ebf6 --- /dev/null +++ b/src/composables/useRouteLog.ts @@ -0,0 +1,62 @@ +import { computed, onMounted, onUnmounted, shallowRef, watch, type Ref } from 'vue'; +import type { PortfolioContent } from '@/content/types'; +import type { JourneySnapshot } from '@/journey/types'; +import { + createRewindLatch, + isMilestoneSection, + milestoneIndex, + milestonesFor, + routeLogSubline, + sectionLocalProgress, +} from '@/journey/route-log'; + +/** + * MILE MARKERS, DOM side: the ROUTE LOG sub-line and html[data-route-log] + * ("2/4") through Experience and Profile. Client-only and started after + * mount, like the 03:12 window, so the server HTML (no attribute, an empty + * sub-line) hydrates unchanged. Works without the scene. + */ +export function useRouteLog( + journey: Readonly>, + content: () => Pick, +): Readonly> { + const mounted = shallowRef(false); + const wide = shallowRef(true); + const reversing = shallowRef(false); + const latch = createRewindLatch(); + const section = computed(() => { + const id = journey.value.activeSectionId; + return mounted.value && isMilestoneSection(id) ? id : null; + }); + const items = computed(() => (section.value === null ? [] : milestonesFor(content(), section.value))); + const progress = computed(() => + section.value === null || items.value.length < 2 ? null : sectionLocalProgress(journey.value, section.value)); + const log = computed(() => + progress.value === null ? null : `${milestoneIndex(progress.value, items.value.length) + 1}/${items.value.length}`); + + watch(progress, (p) => { reversing.value = latch(p, section.value ?? undefined); }, { flush: 'sync' }); + watch(log, (value) => { + const root = document.documentElement; + if (value === null) delete root.dataset.routeLog; + else root.dataset.routeLog = value; + }); + + let query: MediaQueryList | null = null; + const onWidth = (): void => { wide.value = query?.matches ?? true; }; + onMounted(() => { + query = typeof matchMedia === 'function' ? matchMedia('(min-width: 1280px)') : null; + onWidth(); + query?.addEventListener?.('change', onWidth); + mounted.value = true; + }); + onUnmounted(() => { + mounted.value = false; + query?.removeEventListener?.('change', onWidth); + delete document.documentElement.dataset.routeLog; + }); + + return computed(() => { + const p = progress.value; + return p === null ? '' : routeLogSubline(p, items.value, reversing.value, wide.value ? 'wide' : 'compact') ?? ''; + }); +} diff --git a/src/journey/maintenance-progress.ts b/src/journey/maintenance-progress.ts index 679968e..cc7fb3c 100644 --- a/src/journey/maintenance-progress.ts +++ b/src/journey/maintenance-progress.ts @@ -1,4 +1,5 @@ import type { JourneySnapshot } from './types'; +import { createRewindLatch, sectionLocalProgress } from './route-log'; /** * The 03:12 window: the Infrastructure chapter's set-piece is driven by how @@ -27,12 +28,7 @@ type ProgressInput = Pick 0)) return null; - return Math.min(1, Math.max(0, -active / span)); + return sectionLocalProgress(snapshot, 'infrastructure'); } export function maintenanceStep(p: number): MaintenanceStep { @@ -69,20 +65,4 @@ export function maintenanceSubline(p: number, reversing: boolean, live: () => st * Latches the scroll direction inside the window: progress falling means a * rollback until it rises again. Small jitter is ignored. */ -export function createMaintenanceDirection(): (p: number | null) => boolean { - let last: number | null = null; - let reversing = false; - return (p) => { - if (p === null) { - last = null; - reversing = false; - return false; - } - if (last !== null) { - if (p < last - 0.001) reversing = true; - else if (p > last + 0.001) reversing = false; - } - last = p; - return reversing && p > 0 && p < 1; - }; -} +export const createMaintenanceDirection: () => (p: number | null) => boolean = createRewindLatch; diff --git a/src/journey/route-log.ts b/src/journey/route-log.ts new file mode 100644 index 0000000..5c890f8 --- /dev/null +++ b/src/journey/route-log.ts @@ -0,0 +1,145 @@ +import type { PortfolioContent, SectionId } from '@/content/types'; +import type { JourneySnapshot } from './types'; + +/** + * MILE MARKERS: Experience and Profile become literal route. Each role or + * fact is a kilometre post that rolls past as its plate crosses the + * activation line, and the gantry sub-line keeps a ROUTE LOG odometer. + * Shared by the DOM (sub-line, html[data-route-log]) and the lazy scene + * chunk (post positions), so it stays tiny and pure. Scroll-driven only. + */ +type ProgressInput = Pick; + +/** + * Section-local progress: 0 with the chapter heading on the activation line, + * 1 when the next chapter's heading reaches it. Null outside the chapter. + */ +export function sectionLocalProgress(snapshot: ProgressInput, id: SectionId): number | null { + if (snapshot.activeSectionId !== id) return null; + const { activeHeadingOffsetVh: active, nextHeadingOffsetVh: next } = snapshot.sectionActivation; + if (next === null || !Number.isFinite(active) || !Number.isFinite(next)) return null; + const span = next - active; + if (!(span > 0)) return null; + return Math.min(1, Math.max(0, -active / span)); +} + +/** Posts per chapter, in reading order (Experience: 3 roles + the systems plate). */ +export const MILESTONE_COUNTS = { experience: 4, profile: 3 } as const; +export type MilestoneSection = keyof typeof MILESTONE_COUNTS; + +export const isMilestoneSection = (id: SectionId): id is MilestoneSection => id in MILESTONE_COUNTS; + +export interface Milestone { + index: number; + /** Wide plate, e.g. "JUN 2025 → AUG 2025"; '' when the content has no date. */ + dateLabel: string; + /** Narrow plate, e.g. "JUN→AUG 2025". */ + compactDate: string; + /** Organisation or fact, upper case, e.g. "ISENSE SOLUTIONS". */ + caption: string; +} + +/** Post i passes the line at the middle of its share of the chapter. */ +export const passPoint = (index: number, count: number): number => (index + 0.5) / count; + +const MONTHS = 'jan feb mar apr may jun jul aug sep oct nov dec'.split(' '); +const month = (word: string | undefined): string => + word !== undefined && MONTHS.includes(word.slice(0, 3).toLowerCase()) ? `${word.slice(0, 3).toUpperCase()} ` : ''; +const SPAN = /(?:([A-Za-z]+)\s+)?(\d{4})\s*[-–]\s*(?:(?:([A-Za-z]+)\s+)?(\d{4})|(Present))/; + +/** "June 2025-August 2025" -> JUN 2025 → AUG 2025; "2023-2026"; lists of years. */ +export function parseDates(text: string): { dateLabel: string; compactDate: string } { + const span = SPAN.exec(text); + if (span !== null) { + const [, m1, y1, m2, y2, present] = span; + const end = present === undefined ? `${month(m2)}${y2}` : 'PRESENT'; + const sameYear = y1 === y2 && month(m1) !== '' && month(m2) !== ''; + return { + dateLabel: `${month(m1)}${y1} → ${end}`, + compactDate: sameYear ? `${month(m1).trim()}→${month(m2)}${y2}` : `${y1}→${present === undefined ? y2 : 'PRESENT'}`, + }; + } + const years = [...new Set(text.match(/\b(?:19|20)\d{2}\b/g) ?? [])].sort().join(' · '); + return { dateLabel: years, compactDate: years }; +} + +type MilestoneContent = Pick; + +export function milestonesFor(content: MilestoneContent, section: SectionId): Milestone[] { + const rows: Array<{ dates: string; caption: string }> = section === 'experience' + ? [ + ...content.experience.map((record) => ({ dates: record.dates, caption: record.organization })), + { dates: '', caption: content.systemsOperations.title.replace(/^professional\s+/i, '') }, + ] + : section === 'profile' + ? content.profile.screeningFacts.map((fact) => ({ dates: fact.value, caption: fact.label })) + : []; + return rows.map((row, index) => ({ index, ...parseDates(row.dates), caption: row.caption.toUpperCase() })); +} + +/** Which post the reader is at: the one whose share of the chapter holds p. */ +export const milestoneIndex = (p: number, count: number): number => + Math.min(count - 1, Math.max(0, Math.floor(p * count))); + +/** Sub-line widths in characters: the wide (>=1280 px) and narrow plates. */ +export const ROUTE_LOG_BUDGET = { wide: 50, compact: 32 } as const; + +/** Full name, then (3+ words) initials, then the first word, then initials, then nothing. */ +export function fitCaption(caption: string, room: number): string { + const words = caption.split(/\s+/).filter(Boolean); + const initials = words.length > 1 ? words.map((word) => word[0]).join('') : ''; + const tries = words.length > 2 ? [caption, initials, words[0] ?? ''] : [caption, words[0] ?? '', initials]; + return tries.find((value) => value !== '' && value.length <= room) ?? ''; +} + +const pad = (value: number): string => String(value).padStart(2, '0'); + +/** + * "LOG 02/04 · JUN 2025 → AUG 2025 · ISENSE SOLUTIONS", with " · REWIND" while + * scrolling back. Null for chapters with fewer than two posts. + */ +export function routeLogSubline( + p: number, + items: readonly Milestone[], + reversing = false, + width: keyof typeof ROUTE_LOG_BUDGET = 'wide', +): string | null { + if (items.length < 2) return null; + const item = items[milestoneIndex(p, items.length)]!; + const date = width === 'wide' ? item.dateLabel : item.compactDate; + const index = `LOG ${pad(item.index + 1)}/${pad(items.length)}`; + const tail = reversing ? ' · REWIND' : ''; + // The date goes before the caption, unless (narrow plate) it would leave the + // caption with less than its initials: then the caption alone is the better log line. + let head = date === '' ? index : `${index} · ${date}`; + let caption = fitCaption(item.caption, ROUTE_LOG_BUDGET[width] - head.length - tail.length - 3); + if (date !== '' && caption.length < 3) { + head = index; + caption = fitCaption(item.caption, ROUTE_LOG_BUDGET[width] - head.length - tail.length - 3); + } + return `${head}${caption === '' ? '' : ` · ${caption}`}${tail}`; +} + +/** + * Latches the scroll direction: progress falling means a rewind until it + * rises again. Small jitter is ignored; a new key (chapter) starts fresh. + */ +export function createRewindLatch(): (p: number | null, key?: string) => boolean { + let last: number | null = null; + let lastKey: string | undefined; + let reversing = false; + return (p, key) => { + if (p === null || key !== lastKey) { + last = null; + reversing = false; + lastKey = key; + if (p === null) return false; + } + if (last !== null) { + if (p < last - 0.001) reversing = true; + else if (p > last + 0.001) reversing = false; + } + last = p; + return reversing && p > 0 && p < 1; + }; +} diff --git a/src/scene/world/environment.ts b/src/scene/world/environment.ts index a61630a..3754eb3 100644 --- a/src/scene/world/environment.ts +++ b/src/scene/world/environment.ts @@ -36,6 +36,8 @@ import { import { createMaintenanceWindow } from '@/scene/world/maintenance-window'; import { createHeadlights } from '@/scene/world/headlights'; import { maintenanceProgress } from '@/journey/maintenance-progress'; +import { createMilestones } from '@/scene/world/milestones'; +import { isMilestoneSection, sectionLocalProgress } from '@/journey/route-log'; import { dawnStep, type DawnStep } from '@/journey/dawn'; import { dawnLightUniforms } from '@/scene/world/dawn-light'; import { isTestFault, recordTestFaultEvent } from '@/testing/fault-hooks'; @@ -169,8 +171,13 @@ export async function createProceduralSceneGeneration( // the star drain (uniform writes); the warm rims and the bloom change are // high/medium fine-pointer only. const dawnRims = maintenanceTier === 'full'; + // MILE MARKERS: kilometre posts roll past through Experience and Profile. + // Low tier and touch draw them without beam, pulse or flash. + const milestones = createMilestones(context.resources, maintenanceTier === 'full'); scene.add( maintenance.group, + milestones.mesh, + milestones.pool, atmosphere.group, road.group, expressway.group, @@ -351,6 +358,14 @@ export async function createProceduralSceneGeneration( targetPalette.starVisibility *= 1 - (1 - STORM_STAR_SCALE) * service.storm; } districts.setServiceState(service.dim, service.relight); + milestones.update( + snapshot.activeSectionId, + isMilestoneSection(snapshot.activeSectionId) + ? sectionLocalProgress(snapshot, snapshot.activeSectionId) + : null, + musicPulse, + deltaSeconds, + ); // END OF SHIFT: parked at the destination, the night ends. One scalar // drives it all; leaving rewinds it as fast as the outro returns. const dawn = easeInOutCubic(outroProgress); diff --git a/src/scene/world/milestones.ts b/src/scene/world/milestones.ts new file mode 100644 index 0000000..8e05be6 --- /dev/null +++ b/src/scene/world/milestones.ts @@ -0,0 +1,317 @@ +import * as THREE from 'three'; +import type { SectionId } from '@/content/types'; +import { SIGN_ACCENTS } from '@/journey/sign-routes'; +import { isMilestoneSection, MILESTONE_COUNTS, passPoint, type MilestoneSection } from '@/journey/route-log'; +import type { SceneResourceBag } from '@/scene/resources'; +import { BEAM_PARS, beamUniforms } from './headlights'; + +/** + * MILE MARKERS, scene side. Each role (Experience) or screening fact + * (Profile) is a lit kilometre post on the shoulder of the side the reading + * panel leaves free. Its position is a pure function of section-local scroll + * progress, so scrolling back rolls the posts back. One InstancedMesh, one + * draw call: a dark post, a pale retro-reflective cap plate that catches the + * high beam, and a chapter-accent emissive stripe that breathes +8% with the + * bass. A post flashes for 300 ms as its plate crosses the activation line, + * which is the moment it reaches the camera: the post is at its largest, at + * the bottom edge of the frame, and the odometer ticks as it goes by. A second + * additive mesh lays a warm light pool on the shoulder under each post. + * Low tier and touch draw the posts without beam, pulse or flash. + */ +/** + * Where a post stands as its plate crosses the line. The camera sits at z ~25 + * with the frame shifted toward the panel, so the free shoulder leaves the + * frame's side edge about 35 m ahead of the camera: the post goes by there, + * at its largest, as the odometer ticks. + */ +export const MILESTONE_PASS_Z = -12; +/** Metres of road per unit of travel: the next post is RUN / count metres up the road. */ +export const MILESTONE_RUN = 200; +export const MILESTONE_FAR_Z = MILESTONE_PASS_Z - MILESTONE_RUN / 2; +export const MILESTONE_NEAR_Z = MILESTONE_PASS_Z + MILESTONE_RUN / 2; +/** The free shoulder: the Experience panel is on the left, Profile's on the right. */ +export const MILESTONE_SIDE: Readonly> = { experience: 9.5, profile: -9.5 }; +/* The world is drawn ~2.3x life size (the road is 20 m wide), so a 2.8 m post is 6.4 m here. */ +const POST_W = 0.4; +const POST_H = 6.4; +const PLATE_W = 2.2; +const PLATE_H = 1.0; +/** The pool of light each post throws on the shoulder (x across the road, z along it). */ +const POOL_W = 8; +const POOL_L = 14; +/** Plates turn this far toward the road (rad). */ +const PLATE_YAW = 0.3; +const FLASH_SECONDS = 0.3; +/** One flash per post at most every 0.6 s, however fast the scroll (photosensitivity). */ +const FLASH_GAP_SECONDS = 0.6; +const PULSE_GAIN = 0.08; +/** The cap plate's resting glow: it reads as lit retro-reflective sheeting at night. */ +const PLATE_GLOW = 0.42; +const SECTIONS = Object.keys(MILESTONE_COUNTS) as MilestoneSection[]; +const INSTANCES = SECTIONS.reduce((sum, id) => sum + MILESTONE_COUNTS[id], 0); + +const clamp01 = (value: number): number => Math.min(1, Math.max(0, value)); +const smooth = (from: number, to: number, value: number): number => { + const t = clamp01((value - from) / (to - from)); + return t * t * (3 - 2 * t); +}; + +/** Travel parameter of post i: 0.5 exactly when its plate is on the line. */ +export const milestoneTravel = (p: number, index: number, count: number): number => + p - passPoint(index, count) + 0.5; + +export const milestoneZ = (p: number, index: number, count: number): number => + THREE.MathUtils.lerp(MILESTONE_FAR_Z, MILESTONE_NEAR_Z, milestoneTravel(p, index, count)); + +/** The chapter fades the posts in and out at its edges. */ +export const milestoneChapterFade = (p: number): number => smooth(0, 0.05, p) * (1 - smooth(0.95, 1, p)); + +/** A post rises out of the haze far up the road and leaves once it is past the camera. */ +export const milestoneRangeFade = (z: number): number => smooth(-190, -130, z) * (1 - smooth(10, 22, z)); + +export interface MilestonePost { + section: MilestoneSection; + index: number; + x: number; + z: number; + /** 0..1 screen-door cover: chapter edges and the far end of the run. */ + presence: number; +} + +/** Every post's place for this frame; posts of other chapters are absent (presence 0). */ +export function milestoneLayout(section: SectionId, p: number | null): MilestonePost[] { + const posts: MilestonePost[] = []; + for (const id of SECTIONS) { + const count = MILESTONE_COUNTS[id]; + for (let index = 0; index < count; index += 1) { + const t = p === null ? 0 : milestoneTravel(p, index, count); + const live = p !== null && id === section; + const z = THREE.MathUtils.lerp(MILESTONE_FAR_Z, MILESTONE_NEAR_Z, t); + posts.push({ + section: id, + index, + x: MILESTONE_SIDE[id], + z, + presence: live ? milestoneChapterFade(p) * milestoneRangeFade(z) : 0, + }); + } + } + return posts; +} + +function box(width: number, height: number, depth: number, x: number, y: number, z: number, part: number) { + const geometry = new THREE.BoxGeometry(width, height, depth); + geometry.translate(x, y, z); + return { geometry, part }; +} + +/** Post, plate and stripes merged into one indexed geometry with a part id. */ +function postGeometry() { + const pieces = [ + box(POST_W, POST_H, POST_W, 0, POST_H / 2, 0, 0), + // The number plate, framed top and bottom by the chapter's accent. + box(PLATE_W, PLATE_H, 0.08, 0, POST_H + PLATE_H / 2 - 0.12, 0.18, 1), + box(PLATE_W + 0.06, 0.12, 0.12, 0, POST_H + PLATE_H - 0.06, 0.18, 2), + box(PLATE_W + 0.06, 0.12, 0.12, 0, POST_H - 0.18, 0.18, 2), + // Two reflector bands down the post, like a real kilometre post. + box(POST_W + 0.05, 0.5, POST_W + 0.05, 0, POST_H * 0.62, 0, 2), + box(POST_W + 0.05, 0.5, POST_W + 0.05, 0, POST_H * 0.3, 0, 2), + ]; + const positions: number[] = []; + const normals: number[] = []; + const parts: number[] = []; + const indices: number[] = []; + for (const { geometry, part } of pieces) { + const offset = positions.length / 3; + const position = geometry.getAttribute('position'); + positions.push(...position.array); + normals.push(...geometry.getAttribute('normal').array); + for (let i = 0; i < position.count; i += 1) parts.push(part); + indices.push(...Array.from(geometry.getIndex().array as ArrayLike, (i) => i + offset)); + geometry.dispose(); + } + const merged = new THREE.BufferGeometry(); + merged.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)); + merged.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3)); + merged.setAttribute('aPart', new THREE.Float32BufferAttribute(parts, 1)); + merged.setIndex(indices); + return merged; +} + +export interface MilestonesWorld { + readonly mesh: { visible: boolean; count: number }; + /** The additive light pools on the shoulder, one per post. */ + readonly pool: { visible: boolean; count: number }; + /** Section-local progress (null outside the chapter) and the smoothed bass pulse (0..1). */ + update(section: SectionId, p: number | null, pulse: number, deltaSeconds: number): void; + /** Test hook: the current flash level of each instance. */ + flashes(): readonly number[]; +} + +export function createMilestones(resources: SceneResourceBag, full: boolean): MilestonesWorld { + const geometry = resources.own('geometry', postGeometry()); + // x: flash 0..1, y: presence 0..1. + const state = new THREE.InstancedBufferAttribute(new Float32Array(INSTANCES * 2), 2); + state.setUsage(THREE.DynamicDrawUsage); + geometry.setAttribute('aState', state); + const accent = new THREE.Color(SIGN_ACCENTS.experience); + const pulseUniform = { value: 0 }; + const material = resources.own('material', new THREE.ShaderMaterial({ + uniforms: { uAccent: { value: accent }, uPulse: pulseUniform, ...(full ? beamUniforms : {}) }, + vertexShader: /* glsl */ ` + attribute float aPart; + attribute vec2 aState; + varying float vPart; + varying vec2 vState; + varying vec3 vWorld; + varying vec3 vNormal; + void main() { + vec4 world = modelMatrix * instanceMatrix * vec4(position, 1.0); + vWorld = world.xyz; + vNormal = normalize(mat3(modelMatrix * instanceMatrix) * normal); + vPart = aPart; + vState = aState; + gl_Position = projectionMatrix * viewMatrix * world; + } + `, + fragmentShader: /* glsl */ ` + uniform vec3 uAccent; + uniform float uPulse; + varying float vPart; + varying vec2 vState; + varying vec3 vWorld; + varying vec3 vNormal; + ${full ? BEAM_PARS : ''} + void main() { + // Screen-door fade, like the districts: never a translucent sheet. + vec2 cell = mod(floor(gl_FragCoord.xy), 4.0); + float threshold = (mod(cell.x + cell.y * 2.0, 4.0) * 4.0 + mod(cell.y, 2.0) * 2.0 + mod(cell.x, 2.0) + 0.5) / 16.0; + if (vState.y < threshold) discard; + vec3 n = normalize(vNormal); + vec3 toCamera = normalize(cameraPosition - vWorld); + float key = max(0.0, dot(n, normalize(vec3(-0.45, 0.85, 0.35)))); + float facing = max(0.0, dot(n, toCamera)); + float flash = ${full ? 'vState.x' : '0.0'}; + vec3 color; + if (vPart < 0.5) { + color = vec3(0.04, 0.034, 0.06) + vec3(0.09, 0.08, 0.13) * key + uAccent * 0.06 * facing; + } else if (vPart < 1.5) { + // Lit retro-reflective plate: brightest facing the camera, and in the beam. + // Tinted by the chapter accent so it never reads as one more street lamp. + color = mix(vec3(0.86, 0.88, 0.96), uAccent, 0.7) * (${PLATE_GLOW.toFixed(2)} + 0.12 * key + 0.5 * facing * facing); + color += uAccent * 0.35; + ${full ? 'color += vec3(1.0, 0.9, 0.76) * headlightBeam(vWorld) * (0.5 + 1.8 * facing);' : ''} + color += vec3(1.0, 0.96, 0.9) * flash * 1.4; + } else { + color = uAccent * (1.5 * (1.0 + ${PULSE_GAIN.toFixed(2)} * uPulse) + flash * 1.8); + } + gl_FragColor = vec4(color, 1.0); + #include + #include + } + `, + fog: false, + })); + const mesh = new THREE.InstancedMesh(geometry, material, INSTANCES); + mesh.name = 'mile-markers'; + mesh.frustumCulled = false; + mesh.visible = false; + mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage); + + // The light pool: an additive quad on the shoulder, pulled toward the road. + const poolGeometry = resources.own('geometry', new THREE.PlaneGeometry(POOL_W, POOL_L).rotateX(-Math.PI / 2)); + poolGeometry.setAttribute('aState', state); + const poolMaterial = resources.own('material', new THREE.ShaderMaterial({ + uniforms: { uAccent: { value: accent }, uPulse: pulseUniform }, + vertexShader: /* glsl */ ` + attribute vec2 aState; + varying vec2 vUv; + varying vec2 vState; + void main() { + vUv = uv; + vState = aState; + gl_Position = projectionMatrix * viewMatrix * modelMatrix * instanceMatrix * vec4(position, 1.0); + } + `, + fragmentShader: /* glsl */ ` + uniform vec3 uAccent; + uniform float uPulse; + varying vec2 vUv; + varying vec2 vState; + void main() { + vec2 d = (vUv - 0.5) * 2.0; + float r = length(d); + float pool = pow(max(0.0, 1.0 - r), 1.8); + vec3 warm = vec3(1.0, 0.86, 0.62); + vec3 color = mix(warm, uAccent, 0.5) * pool * (2.4 + ${PULSE_GAIN.toFixed(2)} * 3.0 * uPulse + 3.0 * vState.x); + gl_FragColor = vec4(color * vState.y, 1.0); + #include + #include + } + `, + transparent: true, + blending: THREE.AdditiveBlending, + depthWrite: false, + fog: false, + })); + const pool = new THREE.InstancedMesh(poolGeometry, poolMaterial, INSTANCES); + pool.name = 'mile-marker-pools'; + pool.frustumCulled = false; + pool.visible = false; + pool.instanceMatrix.setUsage(THREE.DynamicDrawUsage); + const poolMatrix = new THREE.Matrix4(); + const poolPosition = new THREE.Vector3(); + const noRotation = new THREE.Quaternion(); + + const flash = new Float32Array(INSTANCES); + const sinceFlash = new Float32Array(INSTANCES).fill(Number.POSITIVE_INFINITY); + const side = new Float32Array(INSTANCES); + let lastSection: SectionId | null = null; + const matrix = new THREE.Matrix4(); + const rotation = new THREE.Quaternion(); + const position = new THREE.Vector3(); + const scale = new THREE.Vector3(); + const up = new THREE.Vector3(0, 1, 0); + + return { + mesh, + pool, + flashes: () => Array.from(flash), + update(section, p, pulse, deltaSeconds) { + const posts = milestoneLayout(section, p); + const changed = section !== lastSection; + lastSection = section; + let any = false; + posts.forEach((post, i) => { + // The flash fires when the plate crosses the line, either way. + const crossing = p === null || post.presence === 0 ? 0 : Math.sign(p - passPoint(post.index, MILESTONE_COUNTS[post.section])); + sinceFlash[i]! += deltaSeconds; + if (full && !changed && crossing !== 0 && side[i] !== 0 && crossing !== side[i] && sinceFlash[i]! >= FLASH_GAP_SECONDS) { + flash[i] = 1; + sinceFlash[i] = 0; + } else { + flash[i] = Math.max(0, flash[i]! - deltaSeconds / FLASH_SECONDS); + } + if (crossing !== 0) side[i] = crossing; + if (post.presence === 0) side[i] = 0; + const visible = post.presence > 0; + any ||= visible; + position.set(post.x, 0, post.z); + rotation.setFromAxisAngle(up, -Math.sign(post.x) * PLATE_YAW); + scale.setScalar(visible ? 1 : 0); + mesh.setMatrixAt(i, matrix.compose(position, rotation, scale)); + poolPosition.set(post.x - Math.sign(post.x) * 1.4, 0.05, post.z); + pool.setMatrixAt(i, poolMatrix.compose(poolPosition, noRotation, scale)); + state.setXY(i, flash[i]!, post.presence); + }); + mesh.visible = any; + pool.visible = any; + if (!any) return; + if (isMilestoneSection(section)) accent.set(SIGN_ACCENTS[section]); + pulseUniform.value = full ? clamp01(pulse) : 0; + mesh.instanceMatrix.needsUpdate = true; + pool.instanceMatrix.needsUpdate = true; + state.needsUpdate = true; + }, + }; +} diff --git a/src/styles/route-log.css b/src/styles/route-log.css new file mode 100644 index 0000000..7d69620 --- /dev/null +++ b/src/styles/route-log.css @@ -0,0 +1,27 @@ +/* ---------- MILE MARKERS: the ROUTE LOG odometer ---------- + html[data-route-log='2/4'] is set client-side through Experience and + Profile. The log index rolls on 1-digit columns (each keyed by its value, + so a change re-enters) and the route rule thickens 1 px for 200 ms. */ +html[data-route-log] .journey-hud__subline { letter-spacing: 0.06em; } +.route-odo { display: inline-flex; overflow: hidden; vertical-align: bottom; } +.route-odo__digit { display: inline-block; } +.journey-hud__log-pulse { + position: absolute; inset: auto auto 0 0; block-size: 3px; + inline-size: calc(var(--route, 0) * 100%); + background: var(--sign-accent); opacity: 0; pointer-events: none; +} +@media (min-width: 1280px) { + .journey-hud__log-pulse { inset: auto auto 12px 77px; inline-size: calc(var(--route, 0) * (100% - 107px)); } +} +@media (max-width: 767px) { + html[data-route-log] .journey-hud__subline { letter-spacing: 0.01em; } +} +@media (prefers-reduced-motion: no-preference) { + .route-odo__digit { animation: route-odo-roll 220ms cubic-bezier(0.2, 0.8, 0.2, 1) both; } + [data-rewind='true'] .route-odo__digit { animation-name: route-odo-rewind; } + .journey-hud__log-pulse { animation: route-log-rule 200ms ease-out both; } +} +@keyframes route-odo-roll { from { transform: translateY(100%); } } +@keyframes route-odo-rewind { from { transform: translateY(-100%); } } +@keyframes route-log-rule { 0%, 70% { opacity: 1; } 100% { opacity: 0; } } +@media (forced-colors: active), print { .journey-hud__log-pulse { display: none; } } diff --git a/tests/unit/AppShell.test.ts b/tests/unit/AppShell.test.ts index b2af2e2..aba75e3 100644 --- a/tests/unit/AppShell.test.ts +++ b/tests/unit/AppShell.test.ts @@ -163,6 +163,16 @@ describe('the 03:12 window on the server', () => { }); }); +describe('MILE MARKERS on the server', () => { + it('ships no route log: no attribute, no odometer and an empty sub-line', async () => { + const html = await render('/', validInputs); + expect(html).not.toContain('data-route-log'); + expect(html).not.toContain('route-odo'); + expect(html).not.toContain('journey-hud__log-pulse'); + expect(html).not.toMatch(/LOG \d\d\//u); + }); +}); + describe('HIGH BEAM on the server', () => { it('ships no beam state: the name renders fully lit and its text is unchanged', async () => { const html = await render('/', validInputs); diff --git a/tests/unit/SceneCanvas.test.ts b/tests/unit/SceneCanvas.test.ts index cc66fd8..84fcbe3 100644 --- a/tests/unit/SceneCanvas.test.ts +++ b/tests/unit/SceneCanvas.test.ts @@ -448,4 +448,32 @@ describe("JourneyHud", () => { expect(window.get(".journey-hud__subline").text()).toBe("MAINTENANCE WINDOW \u00b7 03:12"); window.unmount(); }); + + it("rolls the ROUTE LOG index on odometer digits keyed by value", async () => { + const hud = mount(JourneyHud, { + props: { + journey: journey({ activeSectionId: "experience" }), + sectionLabel: "Experience", + subline: "LOG 01/04 \u00b7 AUG 2025 \u2192 PRESENT \u00b7 TRUPA LUI R\u0102ZU\u0218", + }, + }); + const subline = hud.get(".journey-hud__subline"); + expect(subline.text()).toBe("LOG 01/04 \u00b7 AUG 2025 \u2192 PRESENT \u00b7 TRUPA LUI R\u0102ZU\u0218"); + expect(hud.findAll(".route-odo__digit").map((digit) => digit.text())).toEqual(["0", "1"]); + const tens = hud.findAll(".route-odo__digit")[0]!.element; + const ones = hud.findAll(".route-odo__digit")[1]!.element; + const pulse = hud.get(".journey-hud__log-pulse").element; + await hud.setProps({ subline: "LOG 02/04 \u00b7 JUN 2025 \u2192 AUG 2025 \u00b7 CCF \u00b7 REWIND" }); + const digits = hud.findAll(".route-odo__digit"); + expect(digits.map((digit) => digit.text())).toEqual(["0", "2"]); + expect(digits[0]!.element).toBe(tens); + expect(digits[1]!.element).not.toBe(ones); + expect(hud.get(".journey-hud__log-pulse").element).not.toBe(pulse); + expect(hud.get(".journey-hud__subline").attributes("data-rewind")).toBe("true"); + await hud.setProps({ subline: "" }); + expect(hud.findAll(".route-odo").length).toBe(0); + expect(hud.find(".journey-hud__log-pulse").exists()).toBe(false); + expect(hud.get(".journey-hud__subline").text()).toBe(""); + hud.unmount(); + }); }); diff --git a/tests/unit/milestones.test.ts b/tests/unit/milestones.test.ts new file mode 100644 index 0000000..24beaf1 --- /dev/null +++ b/tests/unit/milestones.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from 'vitest'; +import { SceneResourceBag } from '@/scene/resources'; +import { + createMilestones, + MILESTONE_FAR_Z, + MILESTONE_NEAR_Z, + MILESTONE_PASS_Z, + MILESTONE_SIDE, + milestoneLayout, + milestoneZ, +} from '@/scene/world/milestones'; +import { MILESTONE_COUNTS, passPoint } from '@/journey/route-log'; + +const sweep = (from: number, to: number, steps = 200): number[] => + Array.from({ length: steps + 1 }, (_, i) => from + ((to - from) * i) / steps); + +describe('mile marker layout', () => { + it('rolls every post toward the camera monotonically with progress, in order', () => { + for (const section of ['experience', 'profile'] as const) { + const count = MILESTONE_COUNTS[section]; + for (let i = 0; i < count; i += 1) { + let previous = Number.NEGATIVE_INFINITY; + for (const p of sweep(0, 1)) { + const z = milestoneZ(p, i, count); + expect(z).toBeGreaterThan(previous); + previous = z; + } + // At the camera's near edge exactly as its plate crosses the line. + expect(milestoneZ(passPoint(i, count), i, count)).toBeCloseTo(MILESTONE_PASS_Z); + expect(MILESTONE_PASS_Z).toBeCloseTo((MILESTONE_FAR_Z + MILESTONE_NEAR_Z) / 2); + // Still on the near side of the camera (z ~25) and large in frame. + expect(MILESTONE_PASS_Z).toBeLessThan(20); + // Earlier posts are always nearer. + if (i > 0) expect(milestoneZ(0.5, i - 1, count)).toBeGreaterThan(milestoneZ(0.5, i, count)); + } + } + }); + + it('stands on the free shoulder: right of the road in Experience, left in Profile', () => { + expect(MILESTONE_SIDE.experience).toBeGreaterThan(8); + expect(MILESTONE_SIDE.profile).toBeLessThan(-8); + const posts = milestoneLayout('experience', 0.5).filter((post) => post.presence > 0); + expect(posts.length).toBeGreaterThan(0); + expect(posts.every((post) => post.section === 'experience' && post.x === MILESTONE_SIDE.experience)).toBe(true); + }); + + it('is hidden outside its chapters and fades at the chapter edges', () => { + for (const section of ['start', 'infrastructure', 'toolkit', 'contact'] as const) { + expect(milestoneLayout(section, 0.5).every((post) => post.presence === 0)).toBe(true); + } + expect(milestoneLayout('experience', null).every((post) => post.presence === 0)).toBe(true); + expect(milestoneLayout('experience', 0).every((post) => post.presence === 0)).toBe(true); + expect(milestoneLayout('profile', 1).every((post) => post.presence === 0)).toBe(true); + const inside = milestoneLayout('profile', 0.5); + expect(inside.filter((post) => post.section === 'experience').every((post) => post.presence === 0)).toBe(true); + expect(inside.some((post) => post.section === 'profile' && post.presence === 1)).toBe(true); + }); + + it('is symmetric in reverse: the same progress gives the same frame either way', () => { + const ps = sweep(0, 1); + const forward = ps.map((p) => milestoneLayout('experience', p)); + const backward = [...ps].reverse().map((p) => milestoneLayout('experience', p)).reverse(); + expect(backward).toEqual(forward); + }); +}); + +describe('mile marker mesh', () => { + it('draws seven instances in one mesh, visible only in Experience and Profile', () => { + const resources = new SceneResourceBag(); + const markers = createMilestones(resources, true); + expect(markers.mesh.count).toBe(7); + expect(markers.pool.count).toBe(7); + markers.update('start', null, 0, 1 / 60); + expect(markers.mesh.visible).toBe(false); + expect(markers.pool.visible).toBe(false); + markers.update('experience', 0.4, 0.5, 1 / 60); + expect(markers.mesh.visible).toBe(true); + expect(markers.pool.visible).toBe(true); + markers.update('infrastructure', null, 0, 1 / 60); + expect(markers.mesh.visible).toBe(false); + expect(markers.pool.visible).toBe(false); + resources.dispose(); + }); + + it('flashes a post as its plate crosses the line, either way, and decays in 300 ms', () => { + const resources = new SceneResourceBag(); + const markers = createMilestones(resources, true); + const second = passPoint(1, 4); + markers.update('experience', second - 0.02, 0, 1 / 60); + markers.update('experience', second - 0.01, 0, 1 / 60); + expect(markers.flashes()[1]).toBe(0); + markers.update('experience', second + 0.01, 0, 1 / 60); + expect(markers.flashes()[1]).toBe(1); + for (let i = 0; i < 20; i += 1) markers.update('experience', second + 0.01, 0, 1 / 60); + expect(markers.flashes()[1]).toBe(0); + // Back the other way (after the 0.6 s gap) flashes again. + for (let i = 0; i < 30; i += 1) markers.update('experience', second + 0.01, 0, 1 / 60); + markers.update('experience', second - 0.01, 0, 1 / 60); + expect(markers.flashes()[1]).toBe(1); + resources.dispose(); + }); + + it('never flashes on the low tier or touch', () => { + const resources = new SceneResourceBag(); + const markers = createMilestones(resources, false); + const first = passPoint(0, 3); + markers.update('profile', first - 0.01, 1, 1 / 60); + markers.update('profile', first + 0.01, 1, 1 / 60); + expect(markers.flashes().every((level) => level === 0)).toBe(true); + expect(markers.mesh.visible).toBe(true); + resources.dispose(); + }); +}); diff --git a/tests/unit/route-log.test.ts b/tests/unit/route-log.test.ts new file mode 100644 index 0000000..da03435 --- /dev/null +++ b/tests/unit/route-log.test.ts @@ -0,0 +1,187 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { mount } from '@vue/test-utils'; +import { describe, expect, it } from 'vitest'; +import { defineComponent, h, nextTick, shallowRef } from 'vue'; +import { useRouteLog } from '@/composables/useRouteLog'; +import { maintenanceProgress } from '@/journey/maintenance-progress'; +import { + createRewindLatch, + fitCaption, + milestoneIndex, + milestonesFor, + parseDates, + passPoint, + ROUTE_LOG_BUDGET, + routeLogSubline, + sectionLocalProgress, +} from '@/journey/route-log'; +import type { JourneySnapshot } from '@/journey/types'; +import type { PortfolioContent, SectionId } from '@/content/types'; + +const content = JSON.parse( + readFileSync(resolve(process.cwd(), 'content/public/portfolio-content.json'), 'utf8'), +) as PortfolioContent; + +const at = (activeSectionId: SectionId, active: number, next: number | null) => ({ + activeSectionId, + sectionActivation: { layoutRevision: 1, activeHeadingOffsetVh: active, nextHeadingOffsetVh: next }, +}); + +describe('section-local progress', () => { + it('runs 0..1 between the chapter heading and the next, null elsewhere', () => { + expect(sectionLocalProgress(at('experience', 0, 2), 'experience')).toBe(0); + expect(sectionLocalProgress(at('experience', -1, 1), 'experience')).toBeCloseTo(0.5); + expect(sectionLocalProgress(at('experience', -3, -1), 'experience')).toBe(1); + expect(sectionLocalProgress(at('experience', -1, 1), 'profile')).toBeNull(); + expect(sectionLocalProgress(at('profile', 0, null), 'profile')).toBeNull(); + expect(sectionLocalProgress(at('profile', 1, 1), 'profile')).toBeNull(); + expect(sectionLocalProgress(at('profile', Number.NaN, 1), 'profile')).toBeNull(); + }); + + it('still backs the 03:12 window', () => { + expect(maintenanceProgress(at('infrastructure', -1, 1))).toBeCloseTo(0.5); + expect(maintenanceProgress(at('experience', -1, 1))).toBeNull(); + }); +}); + +describe('milestones from the real content', () => { + it('lists three roles and the systems plate for Experience', () => { + const items = milestonesFor(content, 'experience'); + expect(items).toHaveLength(4); + expect(items.map((item) => item.index)).toEqual([0, 1, 2, 3]); + expect(items[0]).toMatchObject({ dateLabel: 'AUG 2025 → PRESENT', compactDate: '2025→PRESENT', caption: 'TRUPA LUI RĂZUȘ' }); + expect(items[1]).toMatchObject({ dateLabel: 'JUN 2025 → AUG 2025', compactDate: 'JUN→AUG 2025' }); + expect(items[2]).toMatchObject({ dateLabel: 'JUN 2025 → AUG 2025', caption: 'ISENSE SOLUTIONS' }); + expect(items[3]).toMatchObject({ dateLabel: '', caption: 'SYSTEMS OPERATIONS' }); + }); + + it('lists the three screening facts for Profile', () => { + const items = milestonesFor(content, 'profile'); + expect(items.map((item) => item.caption)).toEqual(['EDUCATION', 'LANGUAGES', 'CERTIFICATIONS']); + expect(items.map((item) => item.dateLabel)).toEqual(['2023 → 2026', '', '2022 · 2025']); + }); + + it('has no posts in other chapters', () => { + expect(milestonesFor(content, 'toolkit')).toEqual([]); + expect(routeLogSubline(0.5, milestonesFor(content, 'toolkit'))).toBeNull(); + expect(routeLogSubline(0.5, milestonesFor(content, 'experience').slice(0, 1))).toBeNull(); + }); + + it('parses spans, open spans and year lists', () => { + expect(parseDates('Jan 2024 - Present')).toEqual({ dateLabel: 'JAN 2024 → PRESENT', compactDate: '2024→PRESENT' }); + expect(parseDates('2019–2021')).toEqual({ dateLabel: '2019 → 2021', compactDate: '2019→2021' }); + expect(parseDates('Remote')).toEqual({ dateLabel: '', compactDate: '' }); + }); +}); + +describe('the ROUTE LOG sub-line', () => { + const experience = milestonesFor(content, 'experience'); + const profile = milestonesFor(content, 'profile'); + + it('steps through the posts at the middle of each share', () => { + expect(passPoint(0, 4)).toBe(0.125); + expect(passPoint(3, 4)).toBe(0.875); + expect(milestoneIndex(0, 4)).toBe(0); + expect(milestoneIndex(0.26, 4)).toBe(1); + expect(milestoneIndex(1, 4)).toBe(3); + expect(routeLogSubline(0.3, experience)).toBe('LOG 02/04 · JUN 2025 → AUG 2025 · CCF'); + expect(routeLogSubline(0.6, experience)).toBe('LOG 03/04 · JUN 2025 → AUG 2025 · ISENSE SOLUTIONS'); + expect(routeLogSubline(0.95, experience)).toBe('LOG 04/04 · SYSTEMS OPERATIONS'); + expect(routeLogSubline(0.4, profile)).toBe('LOG 02/03 · LANGUAGES'); + expect(routeLogSubline(0.1, profile)).toBe('LOG 01/03 · 2023 → 2026 · EDUCATION'); + }); + + it('adds REWIND while scrolling back', () => { + expect(routeLogSubline(0.6, experience, true)).toMatch(/^LOG 03\/04 · JUN 2025 → AUG 2025 · .+ · REWIND$/u); + // A narrow plate rewinding has no room for the date: the index and caption stay. + expect(routeLogSubline(0.1, experience, true, 'compact')).toBe('LOG 01/04 · TLR · REWIND'); + }); + + it('fits the narrow plates (phones) in the budget, shortening the organisation', () => { + for (const items of [experience, profile]) { + for (let p = 0; p <= 1; p += 0.05) { + for (const reversing of [false, true]) { + expect(routeLogSubline(p, items, reversing, 'compact')!.length).toBeLessThanOrEqual(ROUTE_LOG_BUDGET.compact); + expect(routeLogSubline(p, items, reversing, 'wide')!.length).toBeLessThanOrEqual(ROUTE_LOG_BUDGET.wide); + } + } + } + expect(routeLogSubline(0.1, experience, false, 'compact')).toBe('LOG 01/04 · 2025→PRESENT · TLR'); + // Only 'IS' would fit after the date, so the caption takes the date's place. + expect(routeLogSubline(0.6, experience, false, 'compact')).toBe('LOG 03/04 · ISENSE SOLUTIONS'); + expect(fitCaption('CAMERA CONSULTANȚILOR FISCALI', 10)).toBe('CCF'); + expect(fitCaption('ISENSE SOLUTIONS', 2)).toBe('IS'); + expect(fitCaption('CERTIFICATIONS', 4)).toBe(''); + }); + + it('latches a rewind while progress falls, per chapter', () => { + const latch = createRewindLatch(); + expect(latch(0.2, 'experience')).toBe(false); + expect(latch(0.4, 'experience')).toBe(false); + expect(latch(0.3, 'experience')).toBe(true); + expect(latch(0.3005, 'experience')).toBe(true); + expect(latch(0.5, 'experience')).toBe(false); + // Profile ends at 1 and Experience starts at 0: a new chapter is not a rewind. + expect(latch(1, 'profile')).toBe(false); + expect(latch(0, 'experience')).toBe(false); + expect(latch(0.1, 'experience')).toBe(false); + expect(latch(null)).toBe(false); + }); +}); + +describe('useRouteLog', () => { + const snapshot = (activeSectionId: SectionId, a: number, n: number | null): JourneySnapshot => ({ + scrollY: 0, + routeProgress: 0.3, + direction: 0, + acceleration: 0, + driveSpeed: 0, + activeSectionId, + chapter: 'systems', + sign: null, + arrived: false, + sectionActivation: { layoutRevision: 1, activeHeadingOffsetVh: a, nextHeadingOffsetVh: n }, + }); + + it('sets html[data-route-log] and the sub-line only in its chapters, then cleans up', async () => { + const journey = shallowRef(snapshot('start', -1, 1)); + let subline = shallowRef(''); + const wrapper = mount(defineComponent({ + setup() { + subline = useRouteLog(journey, () => content) as typeof subline; + return () => h('span', subline.value); + }, + })); + const root = document.documentElement; + await nextTick(); + expect(root.dataset.routeLog).toBeUndefined(); + expect(subline.value).toBe(''); + + journey.value = snapshot('experience', -0.2, 1.8); + await nextTick(); + expect(root.dataset.routeLog).toBe('1/4'); + expect(subline.value).toMatch(/^LOG 01\/04 · /u); + + journey.value = snapshot('experience', -1.2, 0.8); + await nextTick(); + expect(root.dataset.routeLog).toBe('3/4'); + expect(subline.value).not.toMatch(/REWIND/u); + + journey.value = snapshot('experience', -0.8, 1.2); + await nextTick(); + expect(root.dataset.routeLog).toBe('2/4'); + expect(subline.value).toMatch(/^LOG 02\/04 · .* · REWIND$/u); + + journey.value = snapshot('infrastructure', -0.5, 1.5); + await nextTick(); + expect(root.dataset.routeLog).toBeUndefined(); + expect(subline.value).toBe(''); + + journey.value = snapshot('profile', -1, 1); + await nextTick(); + expect(root.dataset.routeLog).toBe('2/3'); + wrapper.unmount(); + expect(root.dataset.routeLog).toBeUndefined(); + }); +});