Skip to content

Commit 914edc4

Browse files
committed
Add theme-aware cinema overlays for homepage videos
1 parent 5d7b7c8 commit 914edc4

7 files changed

Lines changed: 447 additions & 13 deletions

File tree

IMPLEMENTATION-NOTES.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,55 @@ feedback. Only the existing localhost analytics warning appeared. Preflight,
237237
HTML validation, contrast/reduced-motion regressions, link policy and public
238238
packaging passed; no dependencies or separate image assets were added.
239239

240+
### Homepage video cinema — 2026-08-30
241+
242+
Baseline: `5d7b7c898e66cd0d33874ee3fbaaaf9a376b4bc4`.
243+
The five existing posters now progressively enhance into a centered, native
244+
dialog with a dimmed backdrop, theme-colored border, video title, 44px close
245+
control and a direct "Watch on YouTube" link. The homepage layout and scroll
246+
position remain intact. The iframe is created only after explicit activation
247+
and removed on close, stopping playback; focus returns to the invoking poster.
248+
Space activation and deliberate backdrop dismissal supplement native dialog
249+
behavior. Modified clicks and the existing YouTube URLs are preserved.
250+
251+
This is an intentional, tightly scoped exception to the previous all-new-tab
252+
video policy. No-JavaScript and unsupported-dialog visitors still receive the
253+
original five safe new-tab links. All other destination, ISO, same-page and
254+
email behavior is unchanged. The source audit permits only the five known
255+
posters to request this enhancement and continues to reject legacy inline
256+
interception of destination links. No dependencies were added; secondary pages
257+
and their existing video behavior are untouched.
258+
259+
Files changed: `index.html`, `assets/css/video.css`,
260+
`assets/js/modules/video.js`, `bin/check`, `tests/home-video.mjs`, `README.md`
261+
and this document. The dialog uses existing palette tokens and no animations.
262+
The privacy-enhanced YouTube embed permits inline mobile playback. Its minimum
263+
height follows [YouTube's player requirements](https://developers.google.com/youtube/player_parameters).
264+
Focus containment and native Escape dismissal use the platform's
265+
[`dialog` behavior](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/dialog).
266+
267+
Verification: all five source/destination mappings opened in the in-app browser;
268+
actual introduction and Alex Finn playback was observed. Close removed the
269+
iframe, restored focus and returned to the identical scroll offset. Space-key
270+
activation worked. The Black Turq palette propagated to the dialog border.
271+
Desktop (1440 × 900), mobile (390 × 844), 320px, tablet 768px and landscape
272+
844 × 390 checks found no page or dialog horizontal overflow after correcting
273+
an aspect-ratio/minimum-height interaction on narrow screens. Close remained
274+
onscreen. Desktop/mobile captures were reviewed; scratch captures stay outside
275+
the public repository. Only the existing localhost analytics warning appeared.
276+
277+
Preflight, HTML validation, link audit and public packaging passed. New
278+
dependency-free tests cover lazy loading, all five IDs, close cleanup, focus and
279+
scroll restoration, Space/modified clicks, backdrop drag protection, invalid
280+
URLs and missing/failed dialog support. Static fallback checks and motion-free
281+
CSS checks supplement the existing theme/reduced-motion regressions. Browser
282+
native Escape/Tab default actions are not dispatched by the current testing
283+
tool, so these were not claimed as end-to-end verified; no custom keyboard trap
284+
was added to compensate. A physical keyboard/screen-reader pass and real-device
285+
Safari testing remain outside this verification. JavaScript-disabled and
286+
reduced-motion assertions for this slice are source/unit checks, not new browser
287+
setting captures.
288+
240289
### Push-to-deploy automation — 2026-08-30
241290

242291
Starting from `f728d6ab0d47da60b49269affa7cced2d2bd94e7`, the existing validation

README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,11 @@ development tool, not a production server.
5151
- All content and links remain available without JavaScript.
5252
- Reduced-motion support, keyboard navigation and visible focus states.
5353
- Responsive layouts verified at 1440px and 390px, with intermediate checks.
54-
- All five video posters open YouTube in a new tab; no player loads on the homepage.
55-
- Homepage destination links open new tabs; ISO downloads, home/section navigation
56-
and email links retain their native behavior.
54+
- All five video posters open a theme-aware cinema overlay. The player only loads
55+
after a click; closing stops playback and restores focus and scroll position.
56+
A direct YouTube link remains available, including without JavaScript.
57+
- Other homepage destination links open new tabs; ISO downloads, home/section
58+
navigation and email links retain their native behavior.
5759

5860
The main changes are in `index.html`, `assets/css/home.css`, and
5961
`assets/js/modules/home.js`. Existing public routes and upstream history remain.

assets/css/video.css

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,3 +93,127 @@
9393
}
9494

9595
}
96+
97+
/* The homepage cinema stays in the top layer: no page reflow, no animation,
98+
and the same theme tokens as the rest of the operating-system chrome. */
99+
html:has(.home) {
100+
scrollbar-gutter: stable;
101+
}
102+
103+
html:has(.home .video-dialog[open]) {
104+
overflow: hidden;
105+
}
106+
107+
.home .video-dialog {
108+
background: var(--sm-deep);
109+
border: 1px solid var(--sm-cyan);
110+
border-radius: 2px;
111+
box-shadow: 0 1.5rem 6rem rgb(0 0 0 / 0.65);
112+
color: var(--sm-text);
113+
inset: 0;
114+
margin: auto;
115+
max-height: calc(100dvh - 2rem);
116+
max-width: calc(100vw - 2rem);
117+
overflow: auto;
118+
overscroll-behavior: contain;
119+
width: min(68rem, calc((100dvh - 10rem) * 16 / 9));
120+
}
121+
122+
.home .video-dialog:not([open]) {
123+
display: none;
124+
}
125+
126+
.home .video-dialog::backdrop {
127+
background: rgb(2 3 5 / 0.88);
128+
}
129+
130+
.video-dialog__header {
131+
align-items: center;
132+
background: var(--sm-panel);
133+
border-bottom: 1px solid var(--sm-line);
134+
display: flex;
135+
gap: 1rem;
136+
justify-content: space-between;
137+
padding: 1rem 1.25rem;
138+
}
139+
140+
.video-dialog__eyebrow,
141+
.video-dialog__footer {
142+
font-family: var(--sm-font-interface);
143+
font-size: 0.65rem;
144+
}
145+
146+
.video-dialog__eyebrow {
147+
color: var(--sm-gold);
148+
letter-spacing: 0.14em;
149+
margin-bottom: 0.25rem;
150+
text-transform: uppercase;
151+
}
152+
153+
.video-dialog__header h2 {
154+
font-family: var(--sm-font-interface);
155+
font-size: clamp(0.85rem, 1.6vw, 1.05rem);
156+
line-height: 1.5;
157+
overflow-wrap: anywhere;
158+
}
159+
160+
.home .video-dialog__close {
161+
align-items: center;
162+
border: 1px solid var(--sm-line-strong);
163+
color: var(--sm-cyan-soft);
164+
cursor: pointer;
165+
display: flex;
166+
flex: 0 0 2.75rem;
167+
height: 2.75rem;
168+
justify-content: center;
169+
}
170+
171+
.video-dialog__close:hover {
172+
background: var(--sm-panel-raised);
173+
border-color: var(--sm-cyan);
174+
}
175+
176+
.video-dialog__close svg {
177+
fill: none;
178+
height: 1.2rem;
179+
stroke: currentColor;
180+
stroke-width: 1.5;
181+
width: 1.2rem;
182+
}
183+
184+
.video-dialog__screen {
185+
aspect-ratio: 16 / 9;
186+
background: #000;
187+
min-height: 200px;
188+
width: 100%;
189+
}
190+
191+
.video-dialog__screen iframe {
192+
border: 0;
193+
display: block;
194+
height: 100%;
195+
width: 100%;
196+
}
197+
198+
.video-dialog__footer {
199+
align-items: center;
200+
color: var(--sm-muted);
201+
display: flex;
202+
flex-wrap: wrap;
203+
gap: 0.25rem 1rem;
204+
justify-content: space-between;
205+
min-height: 3.5rem;
206+
padding: 0.375rem 1.25rem;
207+
}
208+
209+
.video-dialog__footer a {
210+
align-items: center;
211+
display: inline-flex;
212+
gap: 0.5rem;
213+
min-height: 2.75rem;
214+
}
215+
216+
@media (max-width: 39.99rem) {
217+
.video-dialog__header { padding: 0.75rem; }
218+
.video-dialog__footer { padding-inline: 0.75rem; }
219+
}

assets/js/modules/video.js

Lines changed: 86 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
// Videos start as linked posters. A poster with data-video upgrades to an
2-
// inline player; all other posters remain ordinary YouTube links. Nothing is
3-
// requested from YouTube until someone chooses a video.
1+
// Linked posters work without JavaScript. Only explicitly marked homepage
2+
// posters upgrade to the cinema dialog; legacy inline players stay independent.
3+
// No embed is requested until someone chooses a video.
44

55
const PARAMS = 'autoplay=1&rel=0';
66

@@ -21,6 +21,8 @@ function embed(facade) {
2121
}
2222

2323
function ready() {
24+
setupCinema();
25+
2426
for (const facade of document.querySelectorAll('.video__facade[data-video]')) {
2527
facade.addEventListener('click', (event) => {
2628
event.preventDefault();
@@ -29,4 +31,85 @@ function ready() {
2931
}
3032
}
3133

34+
function setupCinema() {
35+
const dialog = document.querySelector('.home .video-dialog');
36+
if (!dialog || typeof dialog.showModal !== 'function') return;
37+
38+
const title = dialog.querySelector('#video-dialog-title');
39+
const screen = dialog.querySelector('.video-dialog__screen');
40+
const close = dialog.querySelector('.video-dialog__close');
41+
const youtube = dialog.querySelector('a');
42+
if (!title || !screen || !close || !youtube) return;
43+
44+
let trigger;
45+
let scroll;
46+
let pressedBackdrop = false;
47+
48+
// Native dialog handles modal focus containment and Escape. Removing the
49+
// iframe on every close stops both playback and background network activity.
50+
dialog.addEventListener('close', () => {
51+
screen.replaceChildren();
52+
trigger?.focus({ preventScroll: true });
53+
if (scroll) window.scrollTo({ ...scroll, behavior: 'instant' });
54+
trigger = null;
55+
scroll = null;
56+
});
57+
close.addEventListener('click', () => dialog.close());
58+
59+
const outside = (event) => {
60+
const rect = dialog.getBoundingClientRect();
61+
return event.target === dialog && (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom);
62+
};
63+
dialog.addEventListener('pointerdown', (event) => { pressedBackdrop = outside(event); });
64+
dialog.addEventListener('click', (event) => {
65+
if (pressedBackdrop && outside(event)) dialog.close();
66+
pressedBackdrop = false;
67+
});
68+
69+
for (const facade of document.querySelectorAll('.home #watch .video__facade[data-video-dialog]')) {
70+
// Derive the embed only from the existing, exact YouTube destination.
71+
const id = facade.getAttribute('href')?.match(/^https:\/\/www\.youtube\.com\/watch\?v=([\w-]{11})$/)?.[1];
72+
const name = facade.closest('.media-card')?.querySelector('h3')?.textContent.trim();
73+
if (!id || !name) continue;
74+
75+
facade.setAttribute('role', 'button');
76+
facade.setAttribute('aria-haspopup', 'dialog');
77+
facade.setAttribute('aria-controls', dialog.id);
78+
facade.setAttribute('aria-label', `Play ${name} (opens video dialog)`);
79+
facade.addEventListener('keydown', (event) => {
80+
if (event.key === ' ' && !event.altKey && !event.ctrlKey && !event.metaKey && !event.shiftKey) {
81+
event.preventDefault();
82+
if (!event.repeat) facade.click();
83+
}
84+
});
85+
facade.addEventListener('click', (event) => {
86+
// Preserve Cmd/Ctrl-click, Shift-click, middle-click and context menus.
87+
if (event.defaultPrevented || event.button !== 0 || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return;
88+
if (dialog.open) return;
89+
90+
title.textContent = name;
91+
youtube.href = facade.href;
92+
const position = { left: window.scrollX, top: window.scrollY };
93+
try {
94+
dialog.showModal();
95+
} catch {
96+
// If enhancement is unavailable, the original link still navigates.
97+
return;
98+
}
99+
event.preventDefault();
100+
trigger = facade;
101+
scroll = position;
102+
close.focus({ preventScroll: true });
103+
104+
const iframe = document.createElement('iframe');
105+
iframe.title = name;
106+
iframe.src = `https://www.youtube-nocookie.com/embed/${id}?${PARAMS}&playsinline=1`;
107+
iframe.allow = 'autoplay; encrypted-media; picture-in-picture; fullscreen';
108+
iframe.referrerPolicy = 'strict-origin-when-cross-origin';
109+
iframe.allowFullscreen = true;
110+
screen.replaceChildren(iframe);
111+
});
112+
}
113+
}
114+
32115
export { ready };

bin/check

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,11 @@ Dir.chdir(root) do
6161
abort "Homepage has an empty or JavaScript-only link" if hrefs.any? { |href| href.empty? || href.match?(/\Ajavascript:/i) }
6262

6363
# A single homepage-wide policy prevents isolated CTA/footer regressions.
64+
# Only these five linked posters may enhance into the homepage cinema.
65+
video_ids = %w[F7fe9pa8OeE 9SDkU5VDQEQ 5JPYJfN7HY0 qBKMe8AatY0 KO2T0oET9go]
66+
video_hrefs = video_ids.map { |id| "https://www.youtube.com/watch?v=#{id}" }
67+
cinema_links = tags.select { |name, link| name == "a" && link.key?("data-video-dialog") }.map(&:last)
68+
abort "Only the five YouTube posters may open the cinema" unless cinema_links.map { |link| link["href"] } == video_hrefs && cinema_links.all? { |link| link["class"] == "video__facade" && link["data-video-dialog"] == "true" }
6469
link_counts = Hash.new(0)
6570
tags.select { |name, _attributes| name == "a" }.each do |_name, link|
6671
href = link.fetch("href", "")
@@ -83,7 +88,7 @@ Dir.chdir(root) do
8388
abort "Keep #{kind} link behavior unchanged: #{href}" if link.key?("target")
8489
end
8590
end
86-
puts "PASS: all #{link_counts[:destination]} homepage destinations open safe, labeled new tabs; #{link_counts[:iso]} ISO downloads, #{link_counts[:same_page]} home/section links and #{link_counts[:email]} email link retain native behavior."
91+
puts "PASS: #{link_counts[:destination]} safe, labeled new-tab destinations/fallbacks (only five video posters may enhance into the cinema); #{link_counts[:iso]} ISO downloads, #{link_counts[:same_page]} home/section links and #{link_counts[:email]} email link retain native behavior."
8792

8893
resources = tags.flat_map { |_name, attributes| attributes.values_at("href", "src") }.compact
8994
local_paths = resources.select { |value| value.start_with?("/") && !value.start_with?("//") }.map { |value| value.split(/[?#]/, 2).first }.uniq
@@ -94,7 +99,6 @@ Dir.chdir(root) do
9499
abort "Missing homepage link/asset: #{path}" unless exists
95100
end
96101

97-
video_ids = %w[F7fe9pa8OeE 9SDkU5VDQEQ 5JPYJfN7HY0 qBKMe8AatY0 KO2T0oET9go]
98102
video_ids.each do |id|
99103
abort "Missing static video link: #{id}" unless hrefs.include?("https://www.youtube.com/watch?v=#{id}")
100104
end
@@ -138,5 +142,6 @@ Dir.chdir(root) do
138142

139143
puts run!("node", "tests/home-themes.mjs").strip
140144
puts run!("node", "tests/home-reduced-motion.mjs").strip
145+
puts run!("node", "tests/home-video.mjs").strip
141146
puts "PASS: Ruby/JS syntax, whitespace, public-file guard, #{licenses.length} license notices, #{ids.length} unique IDs, #{local_paths.length} local targets, five videos and static content."
142147
end

0 commit comments

Comments
 (0)