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"
|
"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."
|
"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"
|
:policy="motion.policy.value"
|
||||||
:quality="actualQuality"
|
:quality="actualQuality"
|
||||||
:scene-description="content.sceneDescription"
|
: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-frame="sampleSceneFrame"
|
||||||
:sample-beam="headlight.sampleBeam"
|
:sample-beam="headlight.sampleBeam"
|
||||||
@state-change="updateSceneState"
|
@state-change="updateSceneState"
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ const props = defineProps<{
|
|||||||
policy: MotionPolicy;
|
policy: MotionPolicy;
|
||||||
quality: QualityTier;
|
quality: QualityTier;
|
||||||
sceneDescription: string;
|
sceneDescription: string;
|
||||||
/** Closing credit for the destination letterbox: real identity facts only. */
|
/** Closing quote for the destination letterbox (public domain, with attribution). */
|
||||||
credit?: string;
|
closer?: { quote: string; attribution: string } | undefined;
|
||||||
sampleFrame?: (start: number, end: number) => unknown;
|
sampleFrame?: (start: number, end: number) => unknown;
|
||||||
/** HIGH BEAM: the live headlight sample for the scene. */
|
/** HIGH BEAM: the live headlight sample for the scene. */
|
||||||
sampleBeam?: (now: number) => BeamSample | null;
|
sampleBeam?: (now: number) => BeamSample | null;
|
||||||
@@ -76,8 +76,16 @@ if (posterFailed.value) recordTestFaultEvent('poster-failure');
|
|||||||
:data-poster-failed="posterFailed"
|
:data-poster-failed="posterFailed"
|
||||||
:data-arrived="journey.arrived"
|
:data-arrived="journey.arrived"
|
||||||
>
|
>
|
||||||
<!-- Closing credit, typed into the outro letterbox (CSS generated text). -->
|
<!-- Closing quote, revealed word by word in the outro letterbox at sunrise. -->
|
||||||
<span v-if="credit" class="journey-world__credit" :data-credit="credit" />
|
<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">
|
<picture v-if="!posterFailed">
|
||||||
<source media="(max-width: 767px)" :srcset="mobilePosterUrl">
|
<source media="(max-width: 767px)" :srcset="mobilePosterUrl">
|
||||||
<img :src="posterUrl" class="journey-poster" alt="" width="1440" height="900" @error="posterFailed = true">
|
<img :src="posterUrl" class="journey-poster" alt="" width="1440" height="900" @error="posterFailed = true">
|
||||||
|
|||||||
@@ -231,6 +231,7 @@ const portfolioContentSchema = z.object({
|
|||||||
afterglow: soundtrackAssetSourcesSchema,
|
afterglow: soundtrackAssetSourcesSchema,
|
||||||
driveLift: soundtrackAssetSourcesSchema,
|
driveLift: soundtrackAssetSourcesSchema,
|
||||||
}).strict().optional(),
|
}).strict().optional(),
|
||||||
|
closer: z.object({ quote: visibleString, attribution: visibleString }).strict().optional(),
|
||||||
sceneDescription: visibleString,
|
sceneDescription: visibleString,
|
||||||
}).strict().superRefine((content, context) => {
|
}).strict().superRefine((content, context) => {
|
||||||
const navigationIds = content.navigation.map((item) => item.id);
|
const navigationIds = content.navigation.map((item) => item.id);
|
||||||
|
|||||||
@@ -175,6 +175,8 @@ export interface PortfolioContent {
|
|||||||
contact: ContactContent;
|
contact: ContactContent;
|
||||||
metadata: MetadataContent;
|
metadata: MetadataContent;
|
||||||
soundtrack?: SoundtrackContent;
|
soundtrack?: SoundtrackContent;
|
||||||
|
/** Public-domain closing quote shown in the end-of-shift letterbox. */
|
||||||
|
closer?: { quote: string; attribution: string };
|
||||||
sceneDescription: 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));
|
block-size: calc(var(--gantry-h, 72px) + min(5vh, 44px));
|
||||||
transform-origin: top;
|
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 {
|
.journey-world__credit {
|
||||||
|
--closer-delay: 0.2s;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
/* In the lower bar: the upper one is shared with the contact avatar. */
|
/* In the lower bar: the upper one is shared with the contact avatar. */
|
||||||
inset: auto 0 0;
|
inset: auto 0 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
gap: 0.28em;
|
||||||
block-size: min(8vh, 72px);
|
block-size: min(8vh, 72px);
|
||||||
padding-inline: 16px;
|
padding-inline: 16px;
|
||||||
|
color: rgb(255 226 196 / 82%);
|
||||||
|
text-align: center;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
.journey-world__credit::before {
|
.journey-world__quote {
|
||||||
content: attr(data-credit) / '';
|
font: italic 400 clamp(0.95rem, 1.5vw, 1.2rem) / 1.25 Charter, 'Iowan Old Style', 'Palatino Linotype', Palatino, Georgia, 'Times New Roman', serif;
|
||||||
color: rgb(255 226 196 / 78%);
|
letter-spacing: 0.01em;
|
||||||
font: 600 0.6rem/1.3 var(--mono, ui-monospace, monospace);
|
text-wrap: balance;
|
||||||
letter-spacing: 0.32em;
|
}
|
||||||
text-transform: uppercase;
|
.journey-world__word {
|
||||||
white-space: nowrap;
|
display: inline-block;
|
||||||
clip-path: inset(0 100% 0 0);
|
|
||||||
opacity: 0;
|
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
|
(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. */
|
scene reports a dawn (still loading), it keeps a fixed 3.4 s delay. */
|
||||||
@media (prefers-reduced-motion: no-preference) {
|
@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 {
|
.journey-world__word,
|
||||||
clip-path: inset(0 -1em 0 0);
|
.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;
|
opacity: 1;
|
||||||
transition:
|
transition-delay: calc(var(--closer-delay) + var(--i, 0) * 0.14s);
|
||||||
opacity 1ms linear 0.2s,
|
|
||||||
clip-path 1.5s steps(36, end) 0.2s;
|
|
||||||
}
|
}
|
||||||
}
|
html:is([data-dawn='sunrise'], [data-dawn='day'], :not([data-dawn])) .journey-world[data-arrived='true'] .journey-world__by {
|
||||||
/* Phones: the credit may wrap, so it fades in rather than types. */
|
opacity: 1;
|
||||||
@media (prefers-reduced-motion: no-preference) and (max-width: 767px) {
|
transition-delay: calc(var(--closer-delay) + 1.9s);
|
||||||
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:not([data-dawn]) .journey-world[data-arrived='true'] .journey-world__credit { --closer-delay: 3.4s; }
|
||||||
@media (prefers-reduced-motion: no-preference) {
|
|
||||||
html:not([data-dawn]) .journey-world[data-arrived='true'] .journey-world__credit::before { transition-delay: 3.4s; }
|
|
||||||
/* No scene, no dawn to wait for: the bars' own timing. */
|
/* 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) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.journey-world[data-arrived='true'] .journey-world__credit::before {
|
.journey-world[data-arrived='true'] .journey-world__word,
|
||||||
clip-path: inset(0 -1em 0 0);
|
.journey-world[data-arrived='true'] .journey-world__by {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transition: none;
|
transition: none;
|
||||||
}
|
}
|
||||||
@@ -639,15 +646,10 @@ html:is([data-dawn='sunrise'], [data-dawn='day']) .journey-world::after { backgr
|
|||||||
|
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
.journey-world::after { block-size: min(7vh, 56px); }
|
.journey-world::after { block-size: min(7vh, 56px); }
|
||||||
/* Phones: the credit may wrap, so it fades in rather than types. */
|
/* Phones: the quote may wrap to two lines inside the thinner bar. */
|
||||||
.journey-world__credit { block-size: min(7vh, 56px); }
|
.journey-world__credit { block-size: min(7vh, 56px); gap: 0.2em; padding-inline: 12px; }
|
||||||
.journey-world__credit::before {
|
.journey-world__quote { font-size: 0.8rem; line-height: 1.2; }
|
||||||
clip-path: none;
|
.journey-world__by { font-size: 0.56rem; letter-spacing: 0.16em; }
|
||||||
font-size: 0.5rem;
|
|
||||||
letter-spacing: 0.14em;
|
|
||||||
text-align: center;
|
|
||||||
white-space: normal;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.journey-world::before,
|
.journey-world::before,
|
||||||
|
|||||||
@@ -127,6 +127,45 @@ describe("SceneCanvas", () => {
|
|||||||
wrapper.unmount();
|
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 () => {
|
it("forwards each complete scene frame interval to adaptive quality", async () => {
|
||||||
const callbacks = new Map<number, FrameRequestCallback>();
|
const callbacks = new Map<number, FrameRequestCallback>();
|
||||||
let nextFrameId = 1;
|
let nextFrameId = 1;
|
||||||
|
|||||||
@@ -113,6 +113,16 @@ describe('content schemas', () => {
|
|||||||
.toThrow(/unmanifested asset reference: missing-soundtrack/i);
|
.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', () => {
|
it('requires audio assets to have null dimensions and a positive duration', () => {
|
||||||
const audio = {
|
const audio = {
|
||||||
id: 'soundtrack-main-opus',
|
id: 'soundtrack-main-opus',
|
||||||
|
|||||||
Reference in New Issue
Block a user