diff --git a/content/public/portfolio-content.json b/content/public/portfolio-content.json index 3030e72..bccff5b 100644 --- a/content/public/portfolio-content.json +++ b/content/public/portfolio-content.json @@ -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." } diff --git a/docs/overnight/polish/closer-1280x800-bar.webp b/docs/overnight/polish/closer-1280x800-bar.webp new file mode 100644 index 0000000..1d942d2 Binary files /dev/null and b/docs/overnight/polish/closer-1280x800-bar.webp differ diff --git a/docs/overnight/polish/closer-1280x800.webp b/docs/overnight/polish/closer-1280x800.webp new file mode 100644 index 0000000..a428144 Binary files /dev/null and b/docs/overnight/polish/closer-1280x800.webp differ diff --git a/docs/overnight/polish/closer-1440x900-bar.webp b/docs/overnight/polish/closer-1440x900-bar.webp new file mode 100644 index 0000000..a377097 Binary files /dev/null and b/docs/overnight/polish/closer-1440x900-bar.webp differ diff --git a/docs/overnight/polish/closer-1440x900.webp b/docs/overnight/polish/closer-1440x900.webp new file mode 100644 index 0000000..181d280 Binary files /dev/null and b/docs/overnight/polish/closer-1440x900.webp differ diff --git a/docs/overnight/polish/closer-390x844-bar.webp b/docs/overnight/polish/closer-390x844-bar.webp new file mode 100644 index 0000000..8c9b6e8 Binary files /dev/null and b/docs/overnight/polish/closer-390x844-bar.webp differ diff --git a/docs/overnight/polish/closer-390x844.webp b/docs/overnight/polish/closer-390x844.webp new file mode 100644 index 0000000..7516827 Binary files /dev/null and b/docs/overnight/polish/closer-390x844.webp differ diff --git a/src/components/AppShell.vue b/src/components/AppShell.vue index dfcb693..b11e01d 100644 --- a/src/components/AppShell.vue +++ b/src/components/AppShell.vue @@ -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" diff --git a/src/components/SceneCanvas.vue b/src/components/SceneCanvas.vue index 513a65e..43cf204 100644 --- a/src/components/SceneCanvas.vue +++ b/src/components/SceneCanvas.vue @@ -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" > - - + + + {{ word }} + {{ closer.attribution }} + diff --git a/src/content/schemas.ts b/src/content/schemas.ts index c9e895a..bff0844 100644 --- a/src/content/schemas.ts +++ b/src/content/schemas.ts @@ -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); diff --git a/src/content/types.ts b/src/content/types.ts index 9c35900..e380564 100644 --- a/src/content/types.ts +++ b/src/content/types.ts @@ -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; } diff --git a/src/styles/world.css b/src/styles/world.css index f8416da..85fad7b 100644 --- a/src/styles/world.css +++ b/src/styles/world.css @@ -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, diff --git a/tests/unit/SceneCanvas.test.ts b/tests/unit/SceneCanvas.test.ts index 1782040..778d0b6 100644 --- a/tests/unit/SceneCanvas.test.ts +++ b/tests/unit/SceneCanvas.test.ts @@ -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(); let nextFrameId = 1; diff --git a/tests/unit/content-schemas.test.ts b/tests/unit/content-schemas.test.ts index 89e4182..095f1e9 100644 --- a/tests/unit/content-schemas.test.ts +++ b/tests/unit/content-schemas.test.ts @@ -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; + 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',