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',