feat: close the shift with a Thoreau quote in the end letterbox
This commit is contained in:
@@ -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."
|
||||
}
|
||||
|
||||
BIN
docs/overnight/polish/closer-1280x800-bar.webp
Normal file
BIN
docs/overnight/polish/closer-1280x800-bar.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
BIN
docs/overnight/polish/closer-1280x800.webp
Normal file
BIN
docs/overnight/polish/closer-1280x800.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 89 KiB |
BIN
docs/overnight/polish/closer-1440x900-bar.webp
Normal file
BIN
docs/overnight/polish/closer-1440x900-bar.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
BIN
docs/overnight/polish/closer-1440x900.webp
Normal file
BIN
docs/overnight/polish/closer-1440x900.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 105 KiB |
BIN
docs/overnight/polish/closer-390x844-bar.webp
Normal file
BIN
docs/overnight/polish/closer-390x844-bar.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.1 KiB |
BIN
docs/overnight/polish/closer-390x844.webp
Normal file
BIN
docs/overnight/polish/closer-390x844.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 49 KiB |
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user