feat: close the shift with a Thoreau quote in the end letterbox

This commit is contained in:
2026-10-12 18:13:06 +03:00
parent 71465fa481
commit f4d9cb0cd4
14 changed files with 108 additions and 42 deletions

View File

@@ -382,5 +382,9 @@
"mp3AssetId": "soundtrack-drive-lift-mp3"
}
},
"closer": {
"quote": "There is more day to dawn. The sun is but a morning star.",
"attribution": "Henry David Thoreau, Walden"
},
"sceneDescription": "A restrained retro-futuristic night drive supports the reading journey while the portfolio content remains fully available as a semantic document."
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

View File

@@ -296,7 +296,7 @@ onUnmounted(() => removePageListeners());
:policy="motion.policy.value"
:quality="actualQuality"
:scene-description="content.sceneDescription"
:credit="`${content.identity.name} · ${content.identity.role} · ${content.identity.location.city}, ${content.identity.location.country}`"
:closer="content.closer"
:sample-frame="sampleSceneFrame"
:sample-beam="headlight.sampleBeam"
@state-change="updateSceneState"

View File

@@ -16,8 +16,8 @@ const props = defineProps<{
policy: MotionPolicy;
quality: QualityTier;
sceneDescription: string;
/** Closing credit for the destination letterbox: real identity facts only. */
credit?: string;
/** Closing quote for the destination letterbox (public domain, with attribution). */
closer?: { quote: string; attribution: string } | undefined;
sampleFrame?: (start: number, end: number) => unknown;
/** HIGH BEAM: the live headlight sample for the scene. */
sampleBeam?: (now: number) => BeamSample | null;
@@ -76,8 +76,16 @@ if (posterFailed.value) recordTestFaultEvent('poster-failure');
:data-poster-failed="posterFailed"
:data-arrived="journey.arrived"
>
<!-- Closing credit, typed into the outro letterbox (CSS generated text). -->
<span v-if="credit" class="journey-world__credit" :data-credit="credit" />
<!-- Closing quote, revealed word by word in the outro letterbox at sunrise. -->
<span v-if="closer" class="journey-world__credit">
<span class="journey-world__quote"><span
v-for="(word, index) in closer.quote.split(' ')"
:key="index"
class="journey-world__word"
:style="{ '--i': index }"
>{{ word }}</span></span>
<span class="journey-world__by">{{ closer.attribution }}</span>
</span>
<picture v-if="!posterFailed">
<source media="(max-width: 767px)" :srcset="mobilePosterUrl">
<img :src="posterUrl" class="journey-poster" alt="" width="1440" height="900" @error="posterFailed = true">

View File

@@ -231,6 +231,7 @@ const portfolioContentSchema = z.object({
afterglow: soundtrackAssetSourcesSchema,
driveLift: soundtrackAssetSourcesSchema,
}).strict().optional(),
closer: z.object({ quote: visibleString, attribution: visibleString }).strict().optional(),
sceneDescription: visibleString,
}).strict().superRefine((content, context) => {
const navigationIds = content.navigation.map((item) => item.id);

View File

@@ -175,6 +175,8 @@ export interface PortfolioContent {
contact: ContactContent;
metadata: MetadataContent;
soundtrack?: SoundtrackContent;
/** Public-domain closing quote shown in the end-of-shift letterbox. */
closer?: { quote: string; attribution: string };
sceneDescription: string;
}

View File

@@ -556,57 +556,64 @@ html:is([data-dawn='sunrise'], [data-dawn='day']) .journey-world::after { backgr
block-size: calc(var(--gantry-h, 72px) + min(5vh, 44px));
transform-origin: top;
}
/* The closing credit types in, in mono, once the bars have closed. */
/* The closing quote fades in word by word once the bars have closed. */
.journey-world__credit {
--closer-delay: 0.2s;
position: absolute;
z-index: 2;
/* In the lower bar: the upper one is shared with the contact avatar. */
inset: auto 0 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.28em;
block-size: min(8vh, 72px);
padding-inline: 16px;
color: rgb(255 226 196 / 82%);
text-align: center;
pointer-events: none;
}
.journey-world__credit::before {
content: attr(data-credit) / '';
color: rgb(255 226 196 / 78%);
font: 600 0.6rem/1.3 var(--mono, ui-monospace, monospace);
letter-spacing: 0.32em;
text-transform: uppercase;
white-space: nowrap;
clip-path: inset(0 100% 0 0);
.journey-world__quote {
font: italic 400 clamp(0.95rem, 1.5vw, 1.2rem) / 1.25 Charter, 'Iowan Old Style', 'Palatino Linotype', Palatino, Georgia, 'Times New Roman', serif;
letter-spacing: 0.01em;
text-wrap: balance;
}
.journey-world__word {
display: inline-block;
opacity: 0;
}
/* END OF SHIFT: the credit types as the sun clears the skyline
.journey-world__word + .journey-world__word { margin-inline-start: 0.28em; }
.journey-world__by {
color: rgb(255 226 196 / 68%);
font: 600 0.66rem/1.2 var(--mono, ui-monospace, monospace);
font-variant-caps: all-small-caps;
letter-spacing: 0.22em;
opacity: 0;
}
.journey-world__by::before { content: '\2014\00a0'; }
/* END OF SHIFT: the quote arrives as the sun clears the skyline
(html[data-dawn] reaches sunrise about 3.8 s after arrival). Until the
scene reports a dawn (still loading), it keeps a fixed 3.4 s delay. */
@media (prefers-reduced-motion: no-preference) {
html:is([data-dawn='sunrise'], [data-dawn='day'], :not([data-dawn])) .journey-world[data-arrived='true'] .journey-world__credit::before {
clip-path: inset(0 -1em 0 0);
.journey-world__word,
.journey-world__by { transition: opacity 0.9s ease; }
html:is([data-dawn='sunrise'], [data-dawn='day'], :not([data-dawn])) .journey-world[data-arrived='true'] .journey-world__word {
opacity: 1;
transition:
opacity 1ms linear 0.2s,
clip-path 1.5s steps(36, end) 0.2s;
transition-delay: calc(var(--closer-delay) + var(--i, 0) * 0.14s);
}
}
/* Phones: the credit may wrap, so it fades in rather than types. */
@media (prefers-reduced-motion: no-preference) and (max-width: 767px) {
html:is([data-dawn='sunrise'], [data-dawn='day'], :not([data-dawn])) .journey-world[data-arrived='true'] .journey-world__credit::before {
clip-path: none;
transition: opacity 0.8s ease 0.2s;
html:is([data-dawn='sunrise'], [data-dawn='day'], :not([data-dawn])) .journey-world[data-arrived='true'] .journey-world__by {
opacity: 1;
transition-delay: calc(var(--closer-delay) + 1.9s);
}
}
@media (prefers-reduced-motion: no-preference) {
html:not([data-dawn]) .journey-world[data-arrived='true'] .journey-world__credit::before { transition-delay: 3.4s; }
html:not([data-dawn]) .journey-world[data-arrived='true'] .journey-world__credit { --closer-delay: 3.4s; }
/* No scene, no dawn to wait for: the bars' own timing. */
html .journey-world[data-static-fallback='true'][data-arrived='true'] .journey-world__credit::before { transition-delay: 1.9s; }
html .journey-world[data-static-fallback='true'][data-arrived='true'] .journey-world__credit { --closer-delay: 1.9s; }
}
/* Reduced motion: the bars and the dimming only; the credit simply shows. */
/* Reduced motion: the bars and the dimming only; the quote simply shows. */
@media (prefers-reduced-motion: reduce) {
.journey-world[data-arrived='true'] .journey-world__credit::before {
clip-path: inset(0 -1em 0 0);
.journey-world[data-arrived='true'] .journey-world__word,
.journey-world[data-arrived='true'] .journey-world__by {
opacity: 1;
transition: none;
}
@@ -639,15 +646,10 @@ html:is([data-dawn='sunrise'], [data-dawn='day']) .journey-world::after { backgr
@media (max-width: 767px) {
.journey-world::after { block-size: min(7vh, 56px); }
/* Phones: the credit may wrap, so it fades in rather than types. */
.journey-world__credit { block-size: min(7vh, 56px); }
.journey-world__credit::before {
clip-path: none;
font-size: 0.5rem;
letter-spacing: 0.14em;
text-align: center;
white-space: normal;
}
/* Phones: the quote may wrap to two lines inside the thinner bar. */
.journey-world__credit { block-size: min(7vh, 56px); gap: 0.2em; padding-inline: 12px; }
.journey-world__quote { font-size: 0.8rem; line-height: 1.2; }
.journey-world__by { font-size: 0.56rem; letter-spacing: 0.16em; }
}
@media (prefers-reduced-motion: reduce) {
.journey-world::before,

View File

@@ -127,6 +127,45 @@ describe("SceneCanvas", () => {
wrapper.unmount();
});
it("renders the closing quote word by word, inside the hidden world", async () => {
const wrapper = mount(SceneCanvas, {
props: {
journey: journey(),
policy: policy(),
quality: "high",
sceneDescription: "A decorative night road behind the portfolio content.",
closer: {
quote: "There is more day to dawn. The sun is but a morning star.",
attribution: "Henry David Thoreau, Walden",
},
},
});
await flushPromises();
const words = wrapper.findAll(".journey-world__word");
expect(words.map((w) => w.text()).join(" ")).toBe(
"There is more day to dawn. The sun is but a morning star.",
);
expect(words[3]!.attributes("style")).toContain("--i: 3");
expect(wrapper.get(".journey-world__by").text()).toBe("Henry David Thoreau, Walden");
expect(wrapper.get(".journey-world").element.contains(wrapper.get(".journey-world__credit").element)).toBe(true);
wrapper.unmount();
});
it("omits the closing quote when the content has none", async () => {
const wrapper = mount(SceneCanvas, {
props: {
journey: journey(),
policy: policy(),
quality: "high",
sceneDescription: "A decorative night road behind the portfolio content.",
},
});
await flushPromises();
expect(wrapper.find(".journey-world__credit").exists()).toBe(false);
wrapper.unmount();
});
it("forwards each complete scene frame interval to adaptive quality", async () => {
const callbacks = new Map<number, FrameRequestCallback>();
let nextFrameId = 1;

View File

@@ -113,6 +113,16 @@ describe('content schemas', () => {
.toThrow(/unmanifested asset reference: missing-soundtrack/i);
});
it('accepts an optional closer quote and rejects stray or empty fields', () => {
const content = cloneContent() as Record<string, unknown>;
content.closer = { quote: 'There is more day to dawn.', attribution: 'Henry David Thoreau, Walden' };
expect(parsePortfolioContent(content).closer?.attribution).toBe('Henry David Thoreau, Walden');
content.closer = { quote: '', attribution: 'Henry David Thoreau, Walden' };
expect(() => parsePortfolioContent(content)).toThrow();
content.closer = { quote: 'x', attribution: 'y', extra: 1 };
expect(() => parsePortfolioContent(content)).toThrow();
});
it('requires audio assets to have null dimensions and a positive duration', () => {
const audio = {
id: 'soundtrack-main-opus',