@sprid/cli 1.0.0 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,660 +1,421 @@
1
- // The preview — one page for looking at a whole batch before any of it is real.
2
- //
3
- // **A post here is a reel or a carousel.** Both are one card in the grid and
4
- // one post in the feed; the reel plays and the carousel advances, and the rest
5
- // of the page cannot tell them apart.
6
- //
7
- // **The registry already knows everything this needs**, which is the reason it
8
- // belongs in the package rather than in a consuming repo's one-off script: a
9
- // manifest carries the lane, the length, the loudness, the check result and the
10
- // Sprid state, and the caption sits next to it verbatim. So the preview is a
11
- // pure function of `social/` plus whatever media is currently on disk, and it
12
- // costs nothing to throw away and regenerate.
13
- //
14
- // Two views, because they answer two different questions:
15
- //
16
- // GRID is this a batch? Everything at once, small, scannable. What you read
17
- // here is variety, repetition, an odd one out, a caption that ran long.
18
- // FEED is this a post? One reel at a time at reading size with its caption
19
- // under it, arrow keys between them. It is the only view that answers
20
- // "would I stop on this", because that is the shape a reader meets.
21
- //
22
- // **A card shows the caption and nothing derived from it.** An earlier hand
23
- // written version of this page split the caption's first line into a title and
24
- // printed the remainder as a subtitle above the caption itself, so every card
25
- // carried the same text twice and neither copy was the thing that ships. The
26
- // caption IS the record; there is no second description to show.
27
-
28
- const esc = (s) =>
29
- String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
30
-
1
+ import { SAFE_ZONE_BANDS } from "./rules/video-safe-zones.mjs";
2
+ // A self-contained local review: artwork first, post details on demand.
3
+ // Inline icons and system fonts keep the saved page usable offline.
4
+ const esc = (value) => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
31
5
  const scriptJson = value => JSON.stringify(value).replace(/</g, '\\u003c');
6
+ const secs = ms => `${(ms / 1000).toFixed(1)}s`;
7
+ const ratio = item => /^\d+(?:\.\d+)?[:/]\d+(?:\.\d+)?$/.test(item.aspect ?? '') ? item.aspect.replace(':', '/') : '9/16';
8
+ const hasMedia = item => Boolean(item.src || item.slides?.length);
9
+
10
+ function issues(item) {
11
+ return [
12
+ ...(item.checks === 'fail' ? item.failures?.length ? item.failures : ['Checks failed'] : []),
13
+ ...(!hasMedia(item) ? ['Media not on disk'] : []),
14
+ ...(!item.caption ? ['No caption'] : []),
15
+ ...(item.stale ? ['File differs from its record'] : []),
16
+ ...(item.captionDrifted ? ['Caption changed since draft'] : []),
17
+ ];
18
+ }
32
19
 
33
- const mb = (n) => `${(n / 1e6).toFixed(1)} MB`;
34
- const secs = (ms) => `${(ms / 1000).toFixed(1)}s`;
35
-
36
- /**
37
- * @param {{account: string, items: Array, generatedAt: string}} data
38
- * @returns {string} a single self-contained HTML file
39
- */
20
+ const paths = {
21
+ grid: '<rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/>',
22
+ list: '<path d="M9 5h12M9 12h12M9 19h12M3 5h.01M3 12h.01M3 19h.01"/>',
23
+ search: '<circle cx="10.5" cy="10.5" r="7.5"/><path d="m16 16 5 5"/>',
24
+ chevron: '<path d="m9 5 7 7-7 7"/>',
25
+ down: '<path d="m6 9 6 6 6-6"/>',
26
+ close: '<path d="m6 6 12 12M6 18 18 6"/>',
27
+ play: '<path d="m8 4 12 8-12 8Z"/>',
28
+ pause: '<path d="M8 5v14M16 5v14"/>',
29
+ copy: '<rect x="8" y="8" width="13" height="13" rx="2"/><path d="M16 8V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h3"/>',
30
+ check: '<path d="m5 12 4 4L19 6"/>',
31
+ warning: '<path d="m10.3 3.8-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.7-3.2l-8-14a2 2 0 0 0-3.4 0Z"/><path d="M12 9v4M12 17h.01"/>',
32
+ layers: '<rect x="3" y="7" width="14" height="14" rx="2"/><path d="M7 3h12a2 2 0 0 1 2 2v12"/>',
33
+ volume: '<path d="m11 5-6 4H2v6h3l6 4ZM15 8a6 6 0 0 1 0 8M18 5a10 10 0 0 1 0 14"/>',
34
+ muted: '<path d="m11 5-6 4H2v6h3l6 4ZM17 9l5 6M17 15l5-6"/>',
35
+ moon: '<path d="M21 12.8A9 9 0 1 1 11.2 3 7 7 0 0 0 21 12.8Z"/>',
36
+ sun: '<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M2 12h2M20 12h2M5 5l1.5 1.5M17.5 17.5l1.5 1.5M5 19l1.5-1.5M17.5 6.5l1.5-1.5"/>',
37
+ feed: '<rect x="6" y="2" width="12" height="20" rx="2"/><path d="m10 8 5 4-5 4Z"/>',
38
+ safe: '<rect x="3" y="2" width="18" height="20" rx="2"/><path d="M3 18h18M17 9v9M5 20l2-2M10 20l2-2M15 20l2-2M19 13l2-2M19 17l2-2"/>',
39
+ clock: '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>',
40
+ code: '<path d="m8 6-6 6 6 6M16 6l6 6-6 6M14 3l-4 18"/>',
41
+ help: '<circle cx="12" cy="12" r="9"/><path d="M9.5 9a2.5 2.5 0 0 1 5 .5c0 1.5-2.5 2-2.5 3.5M12 17h.01"/>',
42
+ };
43
+ const icon = name => `<svg class="icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths[name]}</svg>`;
44
+ const iconButton = (id, name, label, extra = '') => `<button type="button" id="${id}" class="icon-button" title="${label}" aria-label="${label}" ${extra}>${icon(name)}</button>`;
45
+
46
+ /** Render one portable HTML page from the local media registry. */
40
47
  export function renderPreview({ account, items, generatedAt }) {
41
- const lanes = [...new Set(items.map((i) => i.lane).filter(Boolean))].sort();
42
- const playable = items.filter((i) => i.src || i.slides);
43
-
48
+ const lanes = [...new Set(items.map(item => item.lane).filter(Boolean))].sort();
49
+ const data = items.map(item => ({ ...item, ar: ratio(item), issues: issues(item) }));
50
+ const attentionCount = data.filter(item => item.issues.length).length;
44
51
  return `<!doctype html>
45
52
  <html lang="en">
53
+ <head>
46
54
  <meta charset="utf-8">
47
55
  <meta name="viewport" content="width=device-width,initial-scale=1">
48
- <title>${esc(account)} — posts</title>
56
+ <title>${esc(account)} posts | Sprid</title>
49
57
  <style>
50
- :root {
51
- color-scheme: dark;
52
- --bg:#0d0d0f; --card:#17171a; --line:#26262b; --fg:#ededf0; --dim:#8a8a94;
53
- --accent:#7fd1a8; --bad:#e2696b; --warn:#e0b45f;
54
- /* One post's width. Everything in the feed is measured off it, including
55
- how much of the neighbours shows at the edges. */
56
- --pw: min(400px, 78vw, calc((100vh - 270px) * 9 / 16));
57
- }
58
- * { box-sizing:border-box; }
59
- body { margin:0; background:var(--bg); color:var(--fg);
60
- font:14px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",Inter,sans-serif; }
61
- code, .mono { font-family:ui-monospace,SFMono-Regular,Menlo,monospace; }
62
-
63
- /* ── header ───────────────────────────────────────────────────────────── */
64
- header { position:sticky; top:0; z-index:20; background:rgba(13,13,15,.86);
65
- backdrop-filter:blur(10px); border-bottom:1px solid var(--line);
66
- padding:12px 20px; display:flex; gap:12px; align-items:center; flex-wrap:wrap; }
67
- h1 { font-size:15px; margin:0; font-weight:600; letter-spacing:.01em; }
68
- .dim { color:var(--dim); }
69
- .spacer { flex:1; }
70
- input[type=search] { background:#1c1c20; border:1px solid var(--line); color:var(--fg);
71
- padding:6px 10px; border-radius:7px; width:240px; font:inherit; }
72
- input[type=search]:focus { outline:none; border-color:#444; }
73
- button { background:#1c1c20; border:1px solid var(--line); color:var(--fg);
74
- padding:6px 11px; border-radius:7px; cursor:pointer; font:inherit; }
75
- button:hover { border-color:#3a3a42; }
76
- button.on { background:var(--fg); color:#111; border-color:var(--fg); }
77
-
78
- /* ── grid ───────────────────────────────────────────────────────────────
79
- A card is a frame, a slug, a length and the caption. Everything else was
80
- decoration: the lane is already a filter chip, the loudness is the gate's
81
- business and the gate reports it, and a passing check is not information,
82
- so it is a tick with the detail in its tooltip. */
83
- .grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(230px,1fr));
84
- gap:26px 18px; padding:20px; align-items:start; }
85
- .card { margin:0; display:flex; flex-direction:column; gap:7px; min-width:0; }
86
- .card[hidden] { display:none; }
87
- .frame { position:relative; aspect-ratio:var(--ar, 9/16); background:#131317; cursor:pointer; }
88
- .frame video, .frame img { width:100%; height:100%; object-fit:cover; display:block; }
89
- /* A deck shows one slide at a time; hovering the card walks the set, which is
90
- the fastest way to see whether it holds together as one register. */
91
- .frame img { position:absolute; inset:0; opacity:0; }
92
- .frame img.on { opacity:1; }
93
- .count { position:absolute; top:6px; right:6px; background:rgba(0,0,0,.55); color:#fff;
94
- font:10.5px/1 ui-monospace,Menlo,monospace; padding:3px 6px; border-radius:99px; }
95
- .frame .gone { position:absolute; inset:0; display:grid; place-items:center;
96
- color:var(--dim); font-size:12px; text-align:center; padding:16px; }
97
- figcaption { display:flex; flex-direction:column; gap:5px; min-width:0; }
98
- /* One row, always. A slug is an identifier you copy, not prose you read, so
99
- it truncates, carries the whole thing in its tooltip, and copies on click. */
100
- .slug { font:11.5px/1.45 ui-monospace,Menlo,monospace; color:var(--accent);
101
- white-space:nowrap; overflow:hidden; text-overflow:ellipsis; cursor:copy; }
102
- .slug[data-copied]::after, .build[data-copied]::after { content:" copied"; color:var(--dim); }
103
- .line { display:flex; align-items:center; gap:8px; color:var(--dim); font-size:11px; flex-wrap:wrap; }
104
- .tick { color:var(--accent); cursor:help; }
105
- .fail { color:var(--bad); }
106
- .warn { color:var(--warn); }
107
- .card .caption { white-space:pre-wrap; font-size:12.5px; line-height:1.55; color:#b9b9c2;
108
- max-height:7.8em; overflow:hidden; cursor:pointer;
109
- mask-image:linear-gradient(#000 72%, transparent); }
110
- .card .caption.tall { max-height:none; mask-image:none; }
111
- .nocap { color:var(--bad); font-size:12px; }
112
- /* How this file was made. One row, the whole command in the tooltip: it is
113
- the difference between a batch you can rebuild and one you can only keep. */
114
- .build { font:10.5px/1.5 ui-monospace,Menlo,monospace; color:#6d6d78;
115
- white-space:nowrap; overflow:hidden; text-overflow:ellipsis; cursor:copy; }
116
- .copy { align-self:flex-start; font-size:11px; padding:3px 8px; opacity:0; transition:opacity .12s; }
117
- .card:hover .copy { opacity:1; }
118
- /* ── feed ─────────────────────────────────────────────────────────────── */
119
- /* **Sideways between reels, up and down inside one.** The two axes carry two
120
- different movements and giving each its own is what makes the feed read as
121
- a reader's feed: a swipe or an arrow moves to the next post, a scroll reads
122
- the caption of the one you are on. Stacking both on the vertical axis meant
123
- scrolling to finish a caption was also the gesture for "next". */
124
- #feed { position:fixed; inset:0; z-index:40; background:#0a0a0c; display:none;
125
- overflow-x:auto; overflow-y:hidden; scroll-snap-type:x mandatory; gap:34px; }
126
- #feed.open { display:flex; }
127
- /* The two rails that let the first and last post sit centred. They are flex
128
- items because a pseudo-element on a flex container is one. */
129
- #feed::before, #feed::after { content:""; flex:0 0 calc((100vw - var(--pw)) / 2); }
130
- /* **min-width:0, or the head sets the width.** A flex item's automatic
131
- minimum is its content, and a nowrap slug is wider than a post: without
132
- this, posts with long slugs came out wider than the others, so no two snap
133
- points were the same distance apart and the feed settled off-centre. */
134
- .post { flex:0 0 var(--pw); min-width:0; scroll-snap-align:center; scroll-snap-stop:always;
135
- height:100vh; padding:64px 0 18px; display:flex; flex-direction:column;
136
- /* The neighbours are visible and clearly not the subject. */
137
- opacity:.22; transition:opacity .18s ease; }
138
- .post.current { opacity:1; }
139
- /* No radius, no border: the frame is the reel, and anything drawn around it
140
- is a thing the reader will never see. */
141
- /* **Reading the caption slides the reel up.** The video is the whole post
142
- until you start reading, and then it is a thumbnail of what you are reading
143
- about; --shift is set from the caption's own scroll, so one gesture does
144
- both. The margin (rather than a transform) is what makes the caption box
145
- actually grow into the space, instead of the video sliding over it. */
146
- .post video, .post .deck { width:100%; height:auto; aspect-ratio:var(--ar, 9/16);
147
- background:#131317; display:block; position:relative;
148
- margin-top:calc(-1 * var(--shift, 0px)); }
149
- .post .deck img { position:absolute; inset:0; width:100%; height:100%; object-fit:contain; opacity:0; }
150
- .post .deck img.on { opacity:1; }
151
- /* Which slide you are on, and how many are left. Instagram's dots, because
152
- the thing they answer - "is this deck too long" - is a real review question. */
153
- .dots { display:flex; gap:5px; justify-content:center; padding:8px 0 0; }
154
- .dot { width:5px; height:5px; border-radius:99px; background:#3a3a42; cursor:pointer; }
155
- .dot.on { background:var(--fg); }
156
- /* overflow-x:clip rather than the default: a box with overflow-y:auto counts
157
- as a horizontal scroller too, and it was swallowing sideways swipes that
158
- belong to the feed. Clip is not a scroll container, so they chain. */
159
- .post .body { flex:1; min-height:0; overflow-y:auto; overflow-x:clip;
160
- padding:12px 2px 0; overscroll-behavior-y:contain; }
161
- .post .body::-webkit-scrollbar { width:0; }
162
- .post .caption { white-space:pre-wrap; font-size:13.5px; line-height:1.6; color:#dcdce2;
163
- max-height:none; mask-image:none; }
164
- #feedbar { position:fixed; top:0; left:0; right:0; z-index:50; display:flex; gap:12px;
165
- align-items:center; padding:10px 16px; background:rgba(10,10,12,.92);
166
- backdrop-filter:blur(8px); border-bottom:1px solid var(--line);
167
- pointer-events:none; }
168
- #feedbar > * { pointer-events:auto; }
169
- #feedpos { color:var(--dim); font-size:12px; }
170
- .keys { color:var(--dim); font-size:11.5px; }
171
- .keys kbd { background:#1c1c20; border:1px solid var(--line); border-bottom-width:2px;
172
- border-radius:4px; padding:0 4px; font:11px ui-monospace,Menlo,monospace; }
173
- @media (max-width:640px) { .keys { display:none; } }
58
+ :root { color-scheme:light dark; --paper:#F1EEE7; --raised:#FBFAF7; --block:#E6E2D8; --ink:#151412; --soft:#3A3833; --muted:#6E6A61; --line:#DAD6CB; --hover:rgba(21,20,18,.06); --negative:#B3261E; --caution:#B7791F; --positive:#3B7A2A; --scrim:rgba(21,20,18,.45); --pw:min(400px,78vw,calc((100dvh - 230px)*9/16)); }
59
+ @media(prefers-color-scheme:dark) { :root:not([data-theme="light"]) { --paper:#141414; --raised:#202020; --block:#242424; --ink:#F3F3F3; --soft:#D4D4D4; --muted:#A3A3A3; --line:#303030; --hover:rgba(243,243,243,.07); --negative:#F27E74; --caution:#E8B84A; --positive:#8FD46E; --scrim:rgba(0,0,0,.55); } }
60
+ :root[data-theme="dark"] { --paper:#141414; --raised:#202020; --block:#242424; --ink:#F3F3F3; --soft:#D4D4D4; --muted:#A3A3A3; --line:#303030; --hover:rgba(243,243,243,.07); --negative:#F27E74; --caution:#E8B84A; --positive:#8FD46E; --scrim:rgba(0,0,0,.55); }
61
+ :root[data-theme="light"] { color-scheme:light; } :root[data-theme="dark"] { color-scheme:dark; }
62
+ * { box-sizing:border-box; } [hidden] { display:none !important; }
63
+ body { margin:0; background:var(--paper); color:var(--ink); font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; }
64
+ button,input,select { font:inherit; color:inherit; } button { cursor:pointer; border:0; background:none; } button:disabled { opacity:.3; cursor:default; }
65
+ button,a,input,select,summary { -webkit-tap-highlight-color:transparent; } :focus-visible { outline:2px solid var(--ink); outline-offset:3px; }
66
+ .icon { flex:none; display:block; } .icon-button { width:36px; height:36px; display:inline-flex; align-items:center; justify-content:center; border-radius:50%; flex:none; }
67
+ .icon-button:hover:not(:disabled) { background:var(--hover); } .muted { color:var(--muted); } .spacer { flex:1; }
68
+ header { position:sticky; top:0; z-index:10; background:var(--paper); padding:24px 24px 16px; }
69
+ .heading { display:flex; align-items:center; gap:12px; margin-bottom:20px; }
70
+ h1 { margin:0; font-size:28px; line-height:1.2; font-weight:650; letter-spacing:-.8px; } .account { font-size:14px; color:var(--muted); }
71
+ .local { display:inline-flex; align-items:center; gap:6px; color:var(--muted); font-size:12px; } .local::before { content:""; width:5px; height:5px; border-radius:50%; background:currentColor; }
72
+ .toolbar { display:flex; gap:12px; align-items:center; }
73
+ .filters { display:flex; gap:8px; align-items:center; min-width:0; } .pill { display:inline-flex; align-items:center; gap:7px; white-space:nowrap; height:36px; padding:0 13px; border-radius:999px; background:var(--block); font-size:13px; }
74
+ .pill:hover { background:var(--hover); } .pill[aria-pressed="true"] { background:var(--ink); color:var(--paper); } .pill .icon { width:16px; height:16px; }
75
+ .count { color:var(--muted); font-size:12px; font-variant-numeric:tabular-nums; } .pill[aria-pressed="true"] .count { color:inherit; opacity:.7; }
76
+ .select-wrap { position:relative; } select { appearance:none; border:0; border-radius:999px; background:var(--block); padding:0 34px 0 13px; max-width:230px; height:36px; cursor:pointer; font-size:13px; } .select-wrap .icon { position:absolute; right:12px; top:10px; pointer-events:none; width:16px; height:16px; }
77
+ .search { display:flex; align-items:center; gap:8px; padding:0 12px; height:36px; border-radius:999px; background:var(--block); width:230px; }
78
+ .search .icon { width:16px; height:16px; color:var(--muted); } .search input { background:none; border:0; outline:none; width:100%; min-width:0; font-size:13px; } .search:focus-within { box-shadow:0 0 0 1px var(--muted); } input::placeholder { color:var(--muted); }
79
+ .view-switch { display:flex; padding:3px; background:var(--block); border-radius:999px; gap:2px; } .view-switch .icon-button { width:30px; height:30px; } .view-switch [aria-pressed="true"] { background:var(--raised); }
80
+ #result-count { white-space:nowrap; min-width:50px; text-align:right; }
81
+ .grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:4px; padding:0 16px 16px; }
82
+ @media(min-width:640px) { .grid { grid-template-columns:repeat(3,minmax(0,1fr)); } }
83
+ @media(min-width:768px) { .grid { grid-template-columns:repeat(4,minmax(0,1fr)); } }
84
+ @media(min-width:1024px) { .grid { grid-template-columns:repeat(5,minmax(0,1fr)); } }
85
+ @media(min-width:1280px) { .grid { grid-template-columns:repeat(6,minmax(0,1fr)); } }
86
+ @media(min-width:1536px) { .grid { grid-template-columns:repeat(8,minmax(0,1fr)); } }
87
+ .card { min-width:0; margin:0; position:relative; aspect-ratio:9/16; }
88
+ .frame { position:absolute; width:100%; top:50%; transform:translateY(-50%); aspect-ratio:var(--ar); max-height:100%; overflow:hidden; background:var(--block); cursor:pointer; padding:0; border-radius:0; }
89
+ .frame video,.frame img { width:100%; height:100%; display:block; object-fit:contain; } .frame img { position:absolute; inset:0; opacity:0; } .frame img.on { opacity:1; }
90
+ .frame::after { content:""; position:absolute; inset:50% 0 0; background:linear-gradient(transparent,rgba(0,0,0,.68)); opacity:0; transition:opacity .18s; pointer-events:none; }
91
+ .card:hover .frame::after,.card:focus-within .frame::after { opacity:1; }
92
+ .tile-kind { position:absolute; top:10px; right:10px; color:#fff; background:rgba(0,0,0,.45); border-radius:99px; padding:4px 7px; display:flex; align-items:center; gap:5px; font-size:11px; } .tile-kind .icon { width:13px; height:13px; }
93
+ .tile-info { position:absolute; z-index:1; left:12px; bottom:12px; right:12px; color:#fff; text-align:left; opacity:0; transform:translateY(5px); transition:opacity .18s,transform .18s; }
94
+ .card:hover .tile-info,.card:focus-within .tile-info { opacity:1; transform:none; } .tile-slug { font-size:13px; font-weight:550; display:block; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } .tile-meta { font-size:11px; opacity:.8; display:block; margin-top:3px; }
95
+ .tile-alert { position:absolute; bottom:10px; left:10px; display:flex; align-items:center; gap:5px; background:var(--raised); color:var(--negative); padding:5px 8px; border-radius:99px; font-size:11px; z-index:2; } .tile-alert .icon { width:14px; height:14px; } .card:hover .tile-alert { bottom:auto; top:10px; }
96
+ .gone { width:100%; height:100%; display:flex; align-items:center; justify-content:center; flex-direction:column; gap:12px; padding:24px; color:var(--muted); text-align:center; }
97
+ .gone .icon { width:28px; height:28px; } .gone strong { font-size:13px; font-weight:500; } .gone span { font-size:11px; overflow-wrap:anywhere; }
98
+ .list-head { display:none; } .grid.rows { display:flex; flex-direction:column; gap:0; padding:0 24px; }
99
+ .grid.rows .card { aspect-ratio:auto; display:grid; grid-template-columns:56px minmax(0,1fr) 180px 90px 130px; gap:18px; align-items:center; min-height:88px; padding:10px 0; border-bottom:1px solid var(--line); }
100
+ .grid.rows .frame { position:relative; top:auto; transform:none; width:44px; height:64px; aspect-ratio:auto; background:none; border-radius:3px; } .grid.rows .frame::after,.grid.rows .tile-kind,.grid.rows .tile-info,.grid.rows .tile-alert { display:none; }
101
+ .row-info,.row-lane,.row-length,.row-state { display:none; } .grid.rows .row-info { display:block; text-align:left; padding:0; min-width:0; } .row-title { display:block; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:14px; font-weight:500; } .row-slug { display:block; color:var(--muted); font-size:12px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
102
+ .grid.rows .row-lane,.grid.rows .row-length,.grid.rows .row-state { display:flex; align-items:center; gap:6px; font-size:12px; color:var(--muted); } .grid.rows .row-length { justify-content:flex-end; font-variant-numeric:tabular-nums; } .row-state .icon { width:15px; height:15px; } .grid.rows .row-state.attention { color:var(--negative); }
103
+ .list-head.visible { display:grid; grid-template-columns:56px minmax(0,1fr) 180px 90px 130px; gap:18px; padding:0 24px 8px; color:var(--muted); font-size:12px; } .list-head span:nth-child(4) { text-align:right; }
104
+ .empty { padding:80px 24px; text-align:center; color:var(--muted); } .empty p { margin:0 0 16px; } footer { padding:14px 24px 24px; color:var(--muted); font-size:11px; }
105
+ #toast { position:fixed; bottom:24px; left:50%; transform:translateX(-50%); z-index:100; background:var(--ink); color:var(--paper); padding:10px 18px; border-radius:999px; font-size:13px; box-shadow:0 4px 24px var(--scrim); }
106
+ #viewer { position:fixed; inset:0; z-index:30; background:var(--scrim); display:flex; align-items:center; justify-content:center; padding:28px; }
107
+ .viewer-shell { width:min(1160px,100%); height:min(900px,calc(100dvh - 56px)); background:var(--raised); border-radius:16px; overflow:hidden; display:flex; flex-direction:column; box-shadow:0 16px 80px var(--scrim); }
108
+ .viewer-bar { height:64px; display:flex; align-items:center; gap:8px; padding:12px 16px; flex:none; } .viewer-title { min-width:0; flex:1; } #viewer-slug { font-size:14px; font-weight:550; overflow:hidden; white-space:nowrap; text-overflow:ellipsis; } #viewer-position { font-size:12px; color:var(--muted); }
109
+ #copy-caption { display:none; } .feed #copy-caption { display:inline-flex; }
110
+ #posts { flex:1; min-height:0; overflow:hidden; position:relative; } .post { display:none; height:100%; } .post.current { display:grid; grid-template-columns:minmax(0,1fr) 360px; }
111
+ .stage { min-height:0; min-width:0; background:var(--paper); display:flex; flex-direction:column; align-items:center; justify-content:center; gap:14px; padding:20px; }
112
+ .media { width:100%; min-height:0; flex:1; position:relative; display:flex; align-items:center; justify-content:center; overflow:hidden; } .media video { max-width:100%; max-height:100%; aspect-ratio:var(--ar); display:block; }
113
+ .safe-zones { position:absolute; pointer-events:none; z-index:2; overflow:hidden; } .safe-zone-band { position:absolute; background:repeating-linear-gradient(45deg,transparent 0 5px,color-mix(in srgb,var(--caution) 45%,transparent) 5px 7px); } #safe-zones[aria-pressed="true"] { color:var(--caution); background:var(--hover); }
114
+ .deck { position:relative; aspect-ratio:var(--ar); height:100%; max-width:100%; max-height:100%; } .deck img { position:absolute; inset:0; width:100%; height:100%; object-fit:contain; opacity:0; } .deck img.on { opacity:1; }
115
+ .slide-nav { position:absolute; top:50%; transform:translateY(-50%); background:rgba(0,0,0,.45); color:#fff; width:32px; height:32px; } .slide-nav:hover { background:rgba(0,0,0,.65)!important; } .slide-prev { left:8px; } .slide-next { right:8px; }
116
+ .prev .icon,.slide-prev .icon { transform:rotate(180deg); }
117
+ .dots { display:flex; align-items:center; justify-content:center; gap:3px; flex:none; } .dot { padding:8px; border-radius:50%; } .dot::before { content:""; display:block; width:5px; height:5px; border-radius:50%; background:var(--muted); opacity:.35; } .dot[aria-current="true"]::before { opacity:1; background:var(--ink); }
118
+ .body { min-height:0; overflow-y:auto; padding:24px; } .detail-title { display:flex; align-items:center; justify-content:space-between; margin-bottom:16px; font-size:15px; font-weight:550; } .caption { white-space:pre-wrap; overflow-wrap:anywhere; line-height:1.65; font-size:14px; color:var(--soft); } .no-caption { color:var(--negative); }
119
+ .state { display:flex; gap:7px; align-items:center; margin:24px 0 0; font-size:12px; color:var(--muted); } .state .icon { width:16px; height:16px; } .issues { padding:14px; border-radius:10px; margin-top:24px; background:var(--paper); font-size:12px; color:var(--negative); } .issues p { margin:6px 0; }
120
+ .details { margin-top:24px; font-size:12px; color:var(--muted); } summary { cursor:pointer; list-style:none; display:flex; align-items:center; gap:7px; } summary .icon { width:15px; height:15px; } details[open] summary .icon { transform:rotate(90deg); }
121
+ .metadata { margin:14px 0 0; } .metadata div { display:flex; gap:16px; justify-content:space-between; padding:6px 0; } dt,dd { margin:0; } dd { color:var(--soft); text-align:right; overflow-wrap:anywhere; min-width:0; } .build { margin-top:12px; width:100%; display:flex; gap:10px; align-items:flex-start; padding:12px; text-align:left; background:var(--paper); border-radius:8px; } .build code { flex:1; min-width:0; overflow-wrap:anywhere; font:11px/1.6 ui-monospace,monospace; } .build .icon { width:14px; height:14px; margin-top:2px; }
122
+ #help { position:fixed; top:72px; right:24px; z-index:60; background:var(--raised); padding:20px; border-radius:12px; box-shadow:0 8px 40px var(--scrim); width:290px; } #help p { margin:0 0 14px; font-weight:550; } #help div { display:flex; align-items:center; justify-content:space-between; margin-top:10px; font-size:12px; color:var(--muted); } kbd { font:11px ui-monospace,monospace; background:var(--block); padding:3px 6px; border-radius:4px; color:var(--ink); }
123
+ #viewer.feed { padding:0; background:var(--paper); } .feed .viewer-shell { width:100%; height:100dvh; border-radius:0; box-shadow:none; background:var(--paper); } .feed #posts { display:flex; overflow-x:auto; overflow-y:hidden; scroll-snap-type:x mandatory; gap:34px; } .feed #posts::before,.feed #posts::after { content:""; flex:0 0 calc((100vw - var(--pw))/2 - 34px); } .feed .post { display:flex; flex-direction:column; flex:0 0 var(--pw); min-width:0; scroll-snap-align:center; scroll-snap-stop:always; opacity:.25; transition:opacity .18s; } .feed .post.current { opacity:1; }
124
+ .feed .stage { display:block; flex:none; padding:0; background:none; margin-top:calc(-1 * var(--shift,0px)); } .feed .media { width:100%; aspect-ratio:var(--ar); overflow:visible; } .feed .media video,.feed .deck { width:100%; height:100%; object-fit:contain; }
125
+ .feed .body { padding:16px 0 24px; flex:1; overflow-x:clip; overscroll-behavior-y:contain; } .feed .detail-title,.feed .details,.feed .state,.feed .issues { display:none; } .feed .dots { padding-top:8px; }
126
+ @media(hover:none) { .tile-info { opacity:1; transform:none; } .frame::after { opacity:1; } .tile-alert { top:10px; bottom:auto; } }
127
+ @media(max-width:800px) { header { padding:20px 16px 12px; } .heading { margin-bottom:16px; } .toolbar { flex-wrap:wrap; gap:10px; } .filters { flex:1; } .search { width:180px; } #result-count { display:none; } .grid.rows .card,.list-head.visible { grid-template-columns:44px minmax(0,1fr) 100px; gap:12px; } .grid.rows .row-lane,.grid.rows .row-length,.list-head span:nth-child(3),.list-head span:nth-child(4) { display:none; } .grid.rows { padding:0 16px; } .list-head.visible { padding:0 16px 8px; } #viewer { padding:12px; } .viewer-shell { height:calc(100dvh - 24px); } .post.current { grid-template-columns:minmax(0,1fr) 300px; } .body { padding:20px; } }
128
+ @media(max-width:600px) { h1 { font-size:25px; } .local { display:none; } .toolbar { display:grid; grid-template-columns:minmax(0,1fr) auto auto; gap:10px 8px; } .search { order:0; width:100%; } .toolbar .spacer { display:none; } .filters { grid-column:1/-1; gap:6px; overflow-x:auto; padding:2px; } select { max-width:170px; } .pill { padding:0 10px; } .pill-label { display:none; } #viewer { padding:0; } .viewer-shell { height:100dvh; border-radius:0; } .viewer-bar { height:60px; padding:10px 8px; gap:2px; } .viewer-bar .icon-button { width:32px; height:32px; } #posts { overflow-y:auto; } .post.current { display:flex; flex-direction:column; height:auto; min-height:100%; } .stage { height:64dvh; flex:none; padding:12px; } .body { overflow:visible; padding:20px; } .feed .post.current,.feed .post { height:100%; min-height:0; } .feed .stage { height:auto; } .feed .body { overflow-y:auto; } #help { top:70px; right:12px; } }
129
+ @media(prefers-reduced-motion:reduce) { *,*::before,*::after { scroll-behavior:auto!important; transition:none!important; } }
174
130
  </style>
175
-
131
+ </head>
132
+ <body>
176
133
  <header>
177
- <h1>${esc(account)} <span class="dim">posts</span></h1>
178
- <span class="dim">${items.length} post${items.length === 1 ? "" : "s"}${
179
- lanes.length > 1 ? ` · ${lanes.map((l) => `${items.filter((i) => i.lane === l).length} ${esc(l)}`).join(" · ")}` : ""
180
- }</span>
181
- <input type="search" id="q" placeholder="filter slug or caption">
182
- ${lanes.length > 1 ? `<span id="lanes">${lanes.map((l) => `<button data-lane="${esc(l)}">${esc(l)}</button>`).join("")}</span>` : ""}
183
- <button id="failsonly">failing only</button>
184
- <span class="spacer"></span>
185
- <button id="tofeed">Feed view</button>
186
- <span class="keys"><kbd>f</kbd> feed · <kbd>/</kbd> filter</span>
187
- </header>
188
-
189
- <div class="grid" id="grid">
190
- ${items.map(card).join("\n")}
191
- </div>
192
-
193
- <div id="feed">
194
- <div id="feedbar">
195
- <button id="tofeedclose">← Grid</button>
196
- <span id="feedpos"></span>
197
- <button id="sound">🔇 Sound off</button>
198
- <button id="copyfeed">Copy caption</button>
199
- <span class="keys"><kbd>←</kbd><kbd>→</kbd> posts · <kbd>↑</kbd><kbd>↓</kbd> caption · <kbd>[</kbd><kbd>]</kbd> slides · <kbd>space</kbd> pause · <kbd>m</kbd> sound · <kbd>esc</kbd> back</span>
134
+ <div class="heading"><h1>Posts</h1><span class="account">${esc(account)}</span><span class="spacer"></span><span class="local" title="Local files. Nothing is published from this preview.">Local preview</span>${iconButton('grid-sound', 'muted', 'Unmute previews (M)', 'aria-pressed="false"')}${iconButton('help-toggle', 'help', 'Keyboard shortcuts', 'aria-expanded="false" aria-controls="help"')}${iconButton('theme', 'moon', 'Switch theme')}</div>
135
+ <div class="toolbar">
136
+ <div class="filters"><button class="pill" id="all" aria-pressed="true">All <span class="count">${items.length}</span></button><button class="pill" id="attention" aria-pressed="false" title="Failed checks, missing media or captions, and changed files" aria-label="Needs attention">${icon('warning')}<span class="pill-label">Needs attention</span><span class="count">${attentionCount}</span></button>
137
+ ${lanes.length > 1 ? `<label class="select-wrap"><select id="lane" aria-label="Filter by lane"><option value="">All lanes</option>${lanes.map(lane => `<option value="${esc(lane)}">${esc(lane)} (${items.filter(item => item.lane === lane).length})</option>`).join('')}</select>${icon('down')}</label>` : ''}
138
+ </div>
139
+ <span class="spacer"></span><span class="count" id="result-count"></span>
140
+ <label class="search">${icon('search')}<input id="q" type="search" aria-label="Search posts" placeholder="Search posts"></label>
141
+ <div class="view-switch" role="group" aria-label="Post views">${iconButton('grid-view', 'grid', 'Grid view', 'aria-pressed="true"')}${iconButton('list-view', 'list', 'List view', 'aria-pressed="false"')}</div>
142
+ ${iconButton('tofeed', 'feed', 'Feed view (F)')}
200
143
  </div>
144
+ </header>
145
+ <div class="list-head" id="list-head" aria-hidden="true"><span></span><span>Post</span><span>Lane</span><span>Length</span><span>State</span></div>
146
+ <main class="grid" id="grid" aria-label="Posts">${data.map(card).join('\n')}</main>
147
+ <div class="empty" id="empty" hidden><p>No posts match these filters.</p><button class="pill" id="reset">Clear filters</button></div>
148
+ <footer>Updated ${esc(generatedAt)} · <code>sprid post preview</code></footer>
149
+ <div id="viewer" role="dialog" aria-modal="true" aria-label="Post preview" hidden>
150
+ <div class="viewer-shell"><div class="viewer-bar">
151
+ ${iconButton('previous', 'chevron', 'Previous post')}
152
+ ${iconButton('next', 'chevron', 'Next post')}
153
+ <div class="viewer-title"><div id="viewer-slug"></div><div id="viewer-position"></div></div>
154
+ ${iconButton('playback', 'pause', 'Pause (Space)')}${iconButton('sound', 'muted', 'Turn sound on (M)', 'aria-pressed="false"')}${iconButton('safe-zones', 'safe', 'Safe zones: approximate platform interface', 'aria-pressed="false"')}${iconButton('copy-caption', 'copy', 'Copy caption')}${iconButton('viewer-mode', 'feed', 'Feed view')}${iconButton('close', 'close', 'Close preview (Escape)')}
155
+ </div><div id="posts"></div></div>
201
156
  </div>
202
-
157
+ <div id="help" hidden><p>Keyboard shortcuts</p><div>Search <kbd>/</kbd></div><div>Feed view <kbd>F</kbd></div><div>Previous / next post <kbd>← →</kbd></div><div>Previous / next slide <kbd>[ ]</kbd></div><div>Pause / play <kbd>Space</kbd></div><div>Sound <kbd>M</kbd></div><div>Close <kbd>Esc</kbd></div></div>
158
+ <div id="toast" role="status" aria-live="polite" hidden></div>
203
159
  <script>
204
- // Live reload, when this page is being served rather than opened as a file.
205
- // The current reel is in the hash, so a refresh comes back to where you were.
206
- if (location.protocol.startsWith("http")) {
207
- const es = new EventSource("/__reload");
208
- es.onmessage = () => location.reload();
160
+ const ITEMS = ${scriptJson(data)};
161
+ const SAFE_ZONE_BANDS = ${scriptJson(SAFE_ZONE_BANDS)};
162
+ const ICONS = ${scriptJson(Object.fromEntries(Object.keys(paths).map(name => [name, icon(name)])))};
163
+ const escapeHtml = ${esc.toString()};
164
+ const $ = id => document.getElementById(id);
165
+ const grid = $('grid'), viewer = $('viewer'), posts = $('posts');
166
+ let filtered = ITEMS.map((_, n) => n), selected = 0, mode = 'detail', muted = true, deckTimer = null, originFocus = null, toastTimer, scrollTimer, view = 'grid';
167
+ const reducedMotion = matchMedia('(prefers-reduced-motion:reduce)');
168
+ const preferredDark = matchMedia('(prefers-color-scheme:dark)');
169
+ let theme;
170
+ try { theme = localStorage.getItem('sprid-preview-theme'); } catch {}
171
+ function paintTheme() {
172
+ if (theme === 'light' || theme === 'dark') document.documentElement.dataset.theme = theme;
173
+ const dark = theme ? theme === 'dark' : preferredDark.matches;
174
+ $('theme').innerHTML = ICONS[dark ? 'sun' : 'moon'];
175
+ $('theme').title = $('theme').ariaLabel = dark ? 'Switch to light theme' : 'Switch to dark theme';
209
176
  }
210
- const ITEMS = ${scriptJson(
211
- playable.map((i) => ({
212
- slug: i.slug,
213
- lane: i.lane,
214
- kind: i.kind ?? "reel",
215
- src: i.src,
216
- slides: i.slides ?? null,
217
- ar: cssRatio(i),
218
- caption: i.caption ?? "",
219
- meta: metaLine(i),
220
- })),
221
- )};
222
- const ALL = ${scriptJson(items.map((i) => ({ slug: i.slug, hasSrc: !!i.src })))};
223
-
224
- /* ── grid behaviour ─────────────────────────────────────────────────────── */
225
- const grid = document.getElementById("grid");
226
- const q = document.getElementById("q");
227
- let laneFilter = null, failsOnly = false;
228
-
229
- function applyFilter() {
230
- const needle = q.value.trim().toLowerCase();
231
- for (const card of grid.children) {
232
- const hay = card.dataset.hay;
233
- const ok =
234
- (!needle || hay.includes(needle)) &&
235
- (!laneFilter || card.dataset.lane === laneFilter) &&
236
- (!failsOnly || card.dataset.checks === "fail");
237
- card.hidden = !ok;
238
- }
239
- }
240
- q.addEventListener("input", applyFilter);
241
- document.getElementById("lanes")?.addEventListener("click", (e) => {
242
- const b = e.target.closest("button"); if (!b) return;
243
- laneFilter = laneFilter === b.dataset.lane ? null : b.dataset.lane;
244
- for (const x of e.currentTarget.children) x.classList.toggle("on", x.dataset.lane === laneFilter);
245
- applyFilter();
246
- });
247
- document.getElementById("failsonly").addEventListener("click", (e) => {
248
- failsOnly = !failsOnly; e.target.classList.toggle("on", failsOnly); applyFilter();
249
- });
250
-
251
- // Hover previews the clip, click opens it in the feed. Both muted: a grid that
252
- // makes noise is a grid nobody leaves open.
253
- for (const card of grid.children) {
254
- const frame = card.querySelector(".frame");
255
- const v = card.querySelector("video");
256
- const imgs = [...card.querySelectorAll(".frame img")];
257
- frame.addEventListener("click", () => openFeed(card.dataset.slug));
258
- if (v) {
259
- frame.addEventListener("mouseenter", () => { v.play().catch(() => {}); });
260
- frame.addEventListener("mouseleave", () => { v.pause(); v.currentTime = 0; });
261
- } else if (imgs.length > 1) {
262
- let t = null, at = 0;
263
- frame.addEventListener("mouseenter", () => {
264
- t = setInterval(() => {
265
- imgs[at].classList.remove("on");
266
- at = (at + 1) % imgs.length;
267
- imgs[at].classList.add("on");
268
- }, 700);
269
- });
270
- frame.addEventListener("mouseleave", () => {
271
- clearInterval(t);
272
- imgs[at].classList.remove("on"); at = 0; imgs[0].classList.add("on");
273
- });
274
- }
275
- }
276
- grid.addEventListener("click", (e) => {
277
- const b = e.target.closest("button"); if (!b) return;
278
- if (b.dataset.copy != null) {
279
- navigator.clipboard.writeText(b.closest("figcaption").querySelector(".caption").textContent);
280
- b.textContent = "Copied"; setTimeout(() => (b.textContent = "Copy caption"), 1200);
281
- }
282
- });
283
- grid.addEventListener("click", (e) => {
284
- const cap = e.target.closest(".caption");
285
- if (cap) cap.classList.toggle("tall");
286
- for (const sel of [".slug", ".build"]) {
287
- const el = e.target.closest(sel);
288
- if (!el) continue;
289
- navigator.clipboard.writeText(el.title);
290
- el.dataset.copied = "1";
291
- setTimeout(() => delete el.dataset.copied, 1000);
292
- }
293
- });
294
-
295
- /* ── feed ───────────────────────────────────────────────────────────────── */
296
- const feed = document.getElementById("feed");
297
- const pos = document.getElementById("feedpos");
298
- let built = false, index = 0, visual = -1, muted = true, settling = false, settleTimer = null;
299
-
300
- /**
301
- * Centre one post, by arithmetic.
302
- *
303
- * **scrollIntoView({ inline: "center" }) does not centre this container**: with
304
- * the two rails that let the first and last post sit in the middle, it settled
305
- * a full rail short every time, so the reel you had selected sat off to the
306
- * right and its neighbour held the centre. The sum is three terms long and it
307
- * is right in every browser.
308
- */
309
- function scrollToIndex(behavior) {
310
- const el = feed.querySelector(\`.post[data-n="\${index}"]\`);
311
- if (!el) return;
312
- settling = true;
313
- clearTimeout(settleTimer);
314
- settleTimer = setTimeout(() => (settling = false), behavior === "smooth" ? 520 : 140);
315
- feed.scrollTo({ left: el.offsetLeft - (feed.clientWidth - el.clientWidth) / 2, behavior });
316
- }
317
-
318
- function build() {
319
- if (built) return; built = true;
320
- feed.insertAdjacentHTML(
321
- "beforeend",
322
- ITEMS.map((it, n) => \`
323
- <section class="post" data-n="\${n}" data-slug="\${it.slug}" data-kind="\${it.kind}" style="--ar:\${it.ar}">
324
- \${
325
- it.kind === "carousel"
326
- ? \`<div class="deck">\${it.slides.map((src, i) => \`<img alt="" data-src="\${src}" class="\${i === 0 ? "on" : ""}">\`).join("")}</div>
327
- <div class="dots">\${it.slides.map((_, i) => \`<span class="dot \${i === 0 ? "on" : ""}" data-i="\${i}"></span>\`).join("")}</div>\`
328
- : \`<video preload="none" loop playsinline muted data-src="\${it.src}"></video>\`
329
- }
330
- <div class="body"><div class="caption"></div></div>
331
- </section>\`).join(""),
332
- );
333
- // The caption goes in as text, never as markup: it is prose from a file and
334
- // an ampersand in it must survive.
335
- feed.querySelectorAll(".post").forEach((p, n) => { p.querySelector(".caption").textContent = ITEMS[n].caption; });
336
- // A tap on a neighbour goes to it, which is what a half-visible thing invites.
337
- // On the post you are already on, a tap on a deck steps through its slides.
338
- feed.addEventListener("click", (e) => {
339
- const p = e.target.closest(".post");
340
- if (!p) return;
341
- if (Number(p.dataset.n) !== index) return go(Number(p.dataset.n) - index);
342
- const dot = e.target.closest(".dot");
343
- if (dot) return showSlide(p, Number(dot.dataset.i));
344
- const deck = e.target.closest(".deck");
345
- if (deck) {
346
- const r = deck.getBoundingClientRect();
347
- stepSlide(p, e.clientX - r.left < r.width / 2 ? -1 : 1);
348
- }
349
- });
350
-
351
- // Only the posts near the viewport hold a src. Seventy-one <video src> at
352
- // once is a browser that stalls before it plays anything.
353
- // **Which post is the current one is answered twice, at two speeds.**
354
- // The look of it - the bright one, the one that plays, the name in the bar -
355
- // changes the moment a new post takes the middle of the screen, because a
356
- // selection that waits for the scroll to stop feels like the page lagging
357
- // behind your hand. The expensive half (loading and dropping video sources,
358
- // the address in the URL) waits for the scroll to settle, since doing that
359
- // for every post a flick passes over is work nobody sees.
360
- let scrollTimer = null, raf = 0;
361
- feed.addEventListener("scroll", () => {
362
- if (!raf) raf = requestAnimationFrame(() => { raf = 0; markCurrent(nearest()); });
363
- clearTimeout(scrollTimer);
364
- scrollTimer = setTimeout(settle, 90);
365
- });
366
- if ("onscrollend" in feed) feed.addEventListener("scrollend", settle);
367
-
368
- // **A sideways gesture is the browser's, and only the browser's.** Taking it
369
- // over cost the trackpad its momentum and its snap - a two-finger swipe moved
370
- // the feed in dead little steps - so the handler now touches the vertical
371
- // half only, where it has something to add: down reads the caption of the
372
- // post you are on, wherever the pointer happens to be. The caption box is
373
- // kept out of the way of the other axis in CSS (overflow-x:clip and
374
- // overscroll-behavior-y), not here.
375
- feed.addEventListener(
376
- "wheel",
377
- (e) => {
378
- if (Math.abs(e.deltaX) >= Math.abs(e.deltaY)) return;
379
- e.preventDefault();
380
- nudge(e.deltaY);
381
- },
382
- { passive: false },
383
- );
384
- }
385
-
386
- /** The post whose centre is nearest the middle of the scrollport. */
387
- function nearest() {
388
- const mid = feed.scrollLeft + feed.clientWidth / 2;
389
- let best = 0, bestD = Infinity;
390
- feed.querySelectorAll(".post").forEach((p, n) => {
391
- const d = Math.abs(p.offsetLeft + p.clientWidth / 2 - mid);
392
- if (d < bestD) { bestD = d; best = n; }
177
+ paintTheme(); preferredDark.addEventListener('change', paintTheme);
178
+ $('theme').onclick = () => { theme = (theme ? theme === 'dark' : preferredDark.matches) ? 'light' : 'dark'; try { localStorage.setItem('sprid-preview-theme', theme); } catch {} paintTheme(); };
179
+ if (location.protocol.startsWith('http')) { const es = new EventSource('/__reload'); es.onmessage = () => location.reload(); }
180
+ function toast(message) { $('toast').textContent = message; $('toast').hidden = false; clearTimeout(toastTimer); toastTimer = setTimeout(() => $('toast').hidden = true, 1800); }
181
+ async function copy(text, label = 'Caption copied') { try { await navigator.clipboard.writeText(text); toast(label); } catch { toast('Copy unavailable. Select and copy the text.'); } }
182
+ function filter() {
183
+ const needle = $('q').value.trim().toLowerCase(), lane = $('lane')?.value;
184
+ const attention = $('attention').getAttribute('aria-pressed') === 'true';
185
+ filtered = [];
186
+ [...grid.children].forEach((card, n) => {
187
+ const item = ITEMS[n];
188
+ card.hidden = Boolean((needle && !(item.slug + ' ' + (item.lane || '') + ' ' + (item.caption || '')).toLowerCase().includes(needle)) || (lane && item.lane !== lane) || (attention && !item.issues.length));
189
+ if (!card.hidden) filtered.push(n);
393
190
  });
394
- return best;
395
- }
396
-
397
- /**
398
- * A deck plays too - it just plays slowly.
399
- *
400
- * **The current carousel advances by itself**, at reading pace, because the
401
- * question a batch review asks of a deck is whether it holds together as one
402
- * set, and that is a thing you see rather than click through. Space stops it,
403
- * the same key that pauses a reel, so the feed has one idea of "playing".
404
- */
405
- let deckTimer = null;
406
-
407
- function slidesOf(post) {
408
- return [...post.querySelectorAll(".deck img")];
191
+ $('result-count').textContent = filtered.length + (filtered.length === 1 ? ' post' : ' posts');
192
+ $('empty').hidden = filtered.length > 0; $('tofeed').disabled = !filtered.length;
409
193
  }
410
-
411
- function showSlide(post, n) {
412
- const imgs = slidesOf(post);
413
- if (!imgs.length) return;
414
- const at = (n + imgs.length) % imgs.length;
415
- imgs.forEach((img, i) => {
416
- // A slide is fetched when it is about to be needed, not all at once.
417
- if (!img.src && Math.abs(i - at) <= 1) img.src = img.dataset.src;
418
- img.classList.toggle("on", i === at);
194
+ $('q').oninput = filter; if ($('lane')) $('lane').onchange = filter;
195
+ $('attention').onclick = () => { $('attention').setAttribute('aria-pressed', 'true'); $('all').setAttribute('aria-pressed', 'false'); filter(); };
196
+ $('all').onclick = () => { $('attention').setAttribute('aria-pressed', 'false'); $('all').setAttribute('aria-pressed', 'true'); filter(); };
197
+ $('reset').onclick = () => { $('q').value = ''; if ($('lane')) $('lane').value = ''; $('all').click(); };
198
+ function setView(next) { view = next; grid.classList.toggle('rows', view === 'list'); $('list-head').classList.toggle('visible', view === 'list'); $('grid-view').setAttribute('aria-pressed', String(view === 'grid')); $('list-view').setAttribute('aria-pressed', String(view === 'list')); }
199
+ $('grid-view').onclick = () => setView('grid'); $('list-view').onclick = () => setView('list');
200
+ $('help-toggle').onclick = () => { $('help').hidden = !$('help').hidden; $('help-toggle').setAttribute('aria-expanded', String(!$('help').hidden)); };
201
+
202
+ // Load only nearby grid media; pausing a clip never leaves sound behind.
203
+ const observer = new IntersectionObserver(entries => entries.forEach(entry => {
204
+ const frame = entry.target, video = frame.querySelector('video');
205
+ if (entry.isIntersecting) {
206
+ frame.querySelectorAll('[data-src]').forEach(media => { if (!media.getAttribute('src')) media.src = media.dataset.src; });
207
+ } else video?.pause();
208
+ }), { rootMargin:'300px' });
209
+ for (const card of grid.children) {
210
+ const frame = card.querySelector('.frame'), video = frame.querySelector('video');
211
+ observer.observe(frame);
212
+ let hoverTimer = null, slide = 0;
213
+ const images = [...frame.querySelectorAll('img')];
214
+ frame.addEventListener('mouseenter', () => {
215
+ if (reducedMotion.matches || view !== 'grid') return;
216
+ if (video) { video.muted = muted; video.play().catch(() => {}); }
217
+ else if (images.length > 1) hoverTimer = setInterval(() => { images[slide].classList.remove('on'); slide = (slide + 1) % images.length; images[slide].classList.add('on'); }, 900);
419
218
  });
420
- post.querySelectorAll(".dot").forEach((d, i) => d.classList.toggle("on", i === at));
421
- post.dataset.slide = at;
219
+ frame.addEventListener('mouseleave', () => { if (video) { video.pause(); video.currentTime = 0; } clearInterval(hoverTimer); if (images.length) { images[slide].classList.remove('on'); slide = 0; images[0].classList.add('on'); } });
220
+ frame.onclick = () => open(Number(card.dataset.n), 'detail');
221
+ card.querySelector('.row-info').onclick = () => open(Number(card.dataset.n), 'detail');
422
222
  }
423
223
 
424
- const stepSlide = (post, d) => showSlide(post, Number(post.dataset.slide ?? 0) + d);
425
-
426
- function playDeck(post) {
427
- clearInterval(deckTimer);
428
- showSlide(post, Number(post.dataset.slide ?? 0));
429
- deckTimer = setInterval(() => stepSlide(post, 1), 2200);
224
+ function postMarkup(item, n) {
225
+ const e = escapeHtml;
226
+ const media = item.slides?.length ? '<div class="deck">' + item.slides.map((src, i) => '<img data-src="' + e(src) + '" alt="Slide ' + (i + 1) + ' of ' + item.slides.length + '" class="' + (i === 0 ? 'on' : '') + '">').join('') +
227
+ (item.slides.length > 1 ? '<button class="icon-button slide-nav slide-prev" aria-label="Previous slide">' + ICONS.chevron + '</button><button class="icon-button slide-nav slide-next" aria-label="Next slide">' + ICONS.chevron + '</button>' : '') + '</div>' : item.src ? '<video data-src="' + e(item.src) + '" preload="none" loop playsinline muted controls aria-label="' + e(item.slug) + '"></video>' : '<div class="gone">' + ICONS.warning + '<strong>Media not on disk</strong><span>' + e(item.source) + '</span></div>';
228
+ const guide = item.src && !item.slides?.length ? '<div class="safe-zones" hidden aria-hidden="true">' + SAFE_ZONE_BANDS.map(b => '<div class="safe-zone-band" data-band="' + b.key + '" style="left:' + (b.left*100) + '%;top:' + (b.top*100) + '%;width:' + (b.width*100) + '%;height:' + (b.height*100) + '%"></div>').join('') + '</div>' : '';
229
+ const dots = item.slides?.length > 1 ? '<div class="dots" aria-label="Slides">' + item.slides.map((_, i) => '<button class="dot" data-slide="' + i + '" aria-label="Slide ' + (i + 1) + '" aria-current="' + (i === 0) + '"></button>').join('') + '</div>' : '';
230
+ const rows = [['Lane', item.lane], [item.kind === 'carousel' ? 'Slides' : 'Length', item.kind === 'carousel' ? item.slides?.length || item.slideCount : item.durationMs ? (item.durationMs / 1000).toFixed(1) + 's' : null], ['File size', item.sizeBytes ? (item.sizeBytes / 1e6).toFixed(1) + ' MB' : null], ['Loudness', typeof item.lufs === 'number' ? item.lufs.toFixed(1) + ' LUFS' : null], ['Aspect ratio', item.aspect], ['Local ID', item.slug]];
231
+ const state = item.spridStatus && item.spridStatus !== 'none' ? item.spridStatus : 'Local only';
232
+ return '<section class="post" data-n="' + n + '" style="--ar:' + item.ar + '"><div class="stage"><div class="media">' + media + guide + '</div>' + dots + '</div><div class="body"><div class="detail-title">Caption<button class="icon-button post-copy" aria-label="Copy caption" title="Copy caption">' + ICONS.copy + '</button></div><div class="caption' + (!item.caption ? ' no-caption' : '') + '">' + e(item.caption || 'No caption in the registry.') + '</div><div class="state">' + ICONS.layers + e(state) + '</div>' + (item.issues.length ? '<div class="issues">' + item.issues.map(issue => '<p>' + e(issue) + '</p>').join('') + '</div>' : '') + '<details class="details"><summary>' + ICONS.chevron + 'File details</summary><dl class="metadata">' + rows.filter(([, value]) => value != null && value !== '').map(([label, value]) => '<div><dt>' + label + '</dt><dd>' + e(value) + '</dd></div>').join('') + '<div><dt>Checks</dt><dd>' + e(item.checks || 'Not checked') + '</dd></div></dl>' + (item.checks === 'pass' && item.passed?.length ? '<p>' + e(item.passed.join(' · ')) + '</p>' : '') + (item.build ? '<button class="build" aria-label="Copy build command" title="Copy build command"><code>' + e(item.build) + '</code>' + ICONS.copy + '</button>' : '') + '</details></div></section>';
430
233
  }
431
-
432
- /** The fast half: what is bright, what plays, what the bar says. Two elements. */
433
- function markCurrent(n) {
434
- if (n === visual) return;
435
- const prev = feed.querySelector(\`.post[data-n="\${visual}"]\`);
436
- if (prev) {
437
- prev.classList.remove("current");
438
- prev.querySelector("video")?.pause();
439
- // A post you left goes back to being a reel, not a page of text.
440
- if (prev.style.getPropertyValue("--shift") !== "0px") {
441
- prev.style.setProperty("--shift", "0px");
442
- prev.querySelector(".body").scrollTop = 0;
443
- }
234
+ let safeZones = false;
235
+ function paintSafeZones() {
236
+ $('safe-zones').setAttribute('aria-pressed', String(safeZones));
237
+ for (const post of posts.children) {
238
+ const video = post.querySelector('video'), overlay = post.querySelector('.safe-zones');
239
+ if (!overlay || !video) continue;
240
+ overlay.hidden = !safeZones;
241
+ if (!safeZones || !video.getClientRects().length) continue;
242
+ // Use the contained picture, so letterboxing never stretches the guide.
243
+ const media = post.querySelector('.media').getBoundingClientRect(), box = video.getBoundingClientRect();
244
+ const ratio = video.videoWidth && video.videoHeight ? video.videoWidth / video.videoHeight : Number(ITEMS[Number(post.dataset.n)].ar.split('/')[0]) / Number(ITEMS[Number(post.dataset.n)].ar.split('/')[1]);
245
+ const width = Math.min(box.width, box.height * ratio), height = width / ratio;
246
+ Object.assign(overlay.style, {left:(box.left - media.left + (box.width - width)/2)+'px', top:(box.top - media.top + (box.height - height)/2)+'px', width:width+'px', height:height+'px'});
444
247
  }
445
- visual = n;
248
+ }
249
+ const safeZoneResize = new ResizeObserver(paintSafeZones);
250
+ $('safe-zones').onclick = () => { safeZones = !safeZones; paintSafeZones(); };
251
+ let queue = [];
252
+ function open(n, nextMode) {
253
+ if (viewer.hidden) originFocus = document.activeElement;
254
+ queue = filtered.includes(n) ? [...filtered] : [n];
255
+ selected = queue.indexOf(n); mode = nextMode;
256
+ safeZoneResize.disconnect();
257
+ grid.querySelectorAll('video').forEach(v => v.pause());
258
+ posts.querySelectorAll('video').forEach(v => v.pause());
446
259
  clearInterval(deckTimer);
447
- const cur = feed.querySelector(\`.post[data-n="\${n}"]\`);
448
- if (cur) {
449
- cur.classList.add("current");
450
- const v = cur.querySelector("video");
451
- if (v) {
452
- v.muted = muted;
453
- if (v.src) v.play().catch(() => {});
454
- } else playDeck(cur);
455
- }
456
- pos.textContent = \`\${n + 1} / \${ITEMS.length} · \${ITEMS[n].slug} · \${ITEMS[n].meta}\`;
260
+ posts.innerHTML = queue.map(n => postMarkup(ITEMS[n], n)).join('');
261
+ viewer.hidden = false; viewer.classList.toggle('feed', mode === 'feed');
262
+ posts.querySelectorAll('.media').forEach(media => safeZoneResize.observe(media));
263
+ posts.querySelectorAll('video').forEach(video => video.addEventListener('loadedmetadata', paintSafeZones));
264
+ document.body.style.overflow = 'hidden'; grid.inert = true; document.querySelector('header').inert = true;
265
+ $('help').hidden = true; $('help-toggle').setAttribute('aria-expanded', 'false');
266
+ $('previous').classList.add('prev');
267
+ $('viewer-mode').innerHTML = ICONS[mode === 'feed' ? 'grid' : 'feed'];
268
+ $('viewer-mode').ariaLabel = $('viewer-mode').title = mode === 'feed' ? 'Post details' : 'Feed view';
269
+ activate(); if (mode === 'feed') scrollToSelected('instant'); $('close').focus();
457
270
  }
458
-
459
- /** The settled half: the address, and which files are loaded. */
460
- function settle() {
461
- if (settling) return;
462
- const n = nearest();
463
- if (n === index) return;
464
- index = n;
465
- sync();
271
+ function section() { return posts.querySelector('.post[data-n="' + queue[selected] + '"]'); }
272
+ function slideTo(post, n) {
273
+ const images = [...post.querySelectorAll('.deck img')]; if (!images.length) return;
274
+ const at = (n + images.length) % images.length;
275
+ images.forEach((img, i) => { if (Math.abs(i - at) <= 1 && !img.getAttribute('src')) img.src = img.dataset.src; img.classList.toggle('on', i === at); });
276
+ post.querySelectorAll('.dot').forEach((dot, i) => dot.setAttribute('aria-current', String(i === at)));
277
+ post.dataset.slide = at;
466
278
  }
467
-
468
- function sync() {
469
- if (feed.classList.contains("open")) history.replaceState(null, "", "#" + encodeURIComponent(ITEMS[index].slug));
470
- markCurrent(index);
471
- feed.querySelectorAll(".post").forEach((p, n) => {
472
- const near = Math.abs(n - index) <= 2;
473
- const v = p.querySelector("video");
474
- if (!v) {
475
- // A deck near the middle shows its first slide, so a neighbour is a
476
- // picture rather than a grey box.
477
- if (near) showSlide(p, Number(p.dataset.slide ?? 0));
478
- return;
479
- }
480
- if (near && !v.src) {
481
- v.preload = "metadata";
482
- v.src = v.dataset.src;
483
- // A paused <video> paints nothing until it has been somewhere, so a
484
- // neighbour showed as a black rectangle. Nudging it a frame in gives the
485
- // peek an actual image.
486
- v.addEventListener("loadedmetadata", () => { if (v.paused) v.currentTime = 0.05; }, { once: true });
487
- }
488
- if (!near && v.src) { v.pause(); v.removeAttribute("src"); v.load(); }
489
- v.muted = muted;
490
- if (n === index) v.play().catch(() => {}); else v.pause();
279
+ function stepSlide(post, delta) { clearInterval(deckTimer); deckTimer = null; slideTo(post, Number(post.dataset.slide || 0) + delta); paintPlayback(); }
280
+ function playDeck(post) { clearInterval(deckTimer); deckTimer = setInterval(() => slideTo(post, Number(post.dataset.slide || 0) + 1), 2200); paintPlayback(); }
281
+ function paintPlayback() { const video = section()?.querySelector('video'); const paused = video ? video.paused : !deckTimer; $('playback').innerHTML = ICONS[paused ? 'play' : 'pause']; $('playback').ariaLabel = $('playback').title = paused ? 'Play (Space)' : 'Pause (Space)'; }
282
+ function prepareMedia(trim = false) {
283
+ const current = section();
284
+ if (!current) return;
285
+ const gap = parseFloat(getComputedStyle(posts).columnGap) || 0;
286
+ const buffer = current.clientWidth + gap;
287
+ // Include every visible card and one more beyond each edge, at both the
288
+ // current scroll position and its destination. Keep outgoing frames until rest.
289
+ const radius = mode === 'feed' ? Math.ceil((posts.clientWidth + current.clientWidth) / (2 * buffer)) : 0;
290
+ const viewport = posts.getBoundingClientRect();
291
+ [...posts.children].forEach((post, at) => {
292
+ const box = post.getBoundingClientRect();
293
+ const inView = mode === 'feed' && box.right > viewport.left - buffer && box.left < viewport.right + buffer;
294
+ const keep = Math.abs(at - selected) <= radius || inView;
295
+ const video = post.querySelector('video');
296
+ if (video) {
297
+ if (keep && !video.getAttribute('src')) { video.preload = 'auto'; video.src = video.dataset.src; }
298
+ else if (trim && !keep && video.getAttribute('src')) { video.removeAttribute('src'); video.load(); }
299
+ } else if (keep) slideTo(post, Number(post.dataset.slide || 0));
491
300
  });
492
301
  }
493
-
494
- /**
495
- * Scrolling a post, in that order: the reel gives way, then the text moves.
496
- *
497
- * **The first line of a caption is the one that has to stay put.** It carries
498
- * the architect, the building and the year, and a plain scroll took it off the
499
- * top of the box while the video slid away, so the reader lost the label of
500
- * what they were reading. So the first ~55% of the video's height is spent
501
- * shrinking the video, with the caption held still, and only what is left over
502
- * scrolls the text.
503
- */
504
- function nudge(dy) {
505
- const post = feed.querySelector(\`.post[data-n="\${index}"]\`);
506
- if (!post) return;
507
- const body = post.querySelector(".body");
508
- const video = post.querySelector("video");
509
- const max = video.getBoundingClientRect().height * 0.55;
510
- let shift = parseFloat(post.style.getPropertyValue("--shift") || 0);
511
-
512
- if (dy > 0) {
513
- const use = Math.min(dy, Math.max(0, max - shift));
514
- shift += use;
515
- body.scrollTop += dy - use;
516
- } else {
517
- const room = body.scrollTop;
518
- const use = Math.min(-dy, room);
519
- body.scrollTop -= use;
520
- shift = Math.max(0, shift + dy + use);
521
- }
522
- post.style.setProperty("--shift", shift + "px");
523
- }
524
-
525
- function openFeed(slug) {
526
- build();
527
- const n = ITEMS.findIndex((i) => i.slug === slug);
528
- index = n < 0 ? 0 : n;
529
- visual = -1;
530
- feed.classList.add("open");
531
- document.body.style.overflow = "hidden";
532
- sync();
533
- feed.querySelector(\`.post[data-n="\${index}"]\`).scrollIntoView({ behavior: "instant", inline: "center", block: "nearest" });
534
- }
535
- function closeFeed() {
302
+ function activate() {
536
303
  clearInterval(deckTimer); deckTimer = null;
537
- if (location.hash) history.replaceState(null, "", location.pathname);
538
- feed.classList.remove("open");
539
- document.body.style.overflow = "";
540
- feed.querySelectorAll("video").forEach((v) => v.pause());
304
+ const current = section();
305
+ prepareMedia(mode !== 'feed');
306
+ [...posts.children].forEach((post, at) => {
307
+ const active = at === selected;
308
+ post.classList.toggle('current', active); post.inert = !active && mode !== 'feed'; post.querySelector('.body').inert = !active;
309
+ const video = post.querySelector('video');
310
+ if (video) {
311
+ video.muted = muted; video.controls = active;
312
+ if (active && !reducedMotion.matches) video.play().catch(() => {}); else video.pause();
313
+ }
314
+ if (!active) { post.style.setProperty('--shift','0px'); post.querySelector('.body').scrollTop = 0; }
315
+ });
316
+ const item = ITEMS[queue[selected]];
317
+ $('viewer-slug').textContent = item.slug;
318
+ $('viewer-position').textContent = (selected + 1) + ' / ' + queue.length + (item.lane ? ' · ' + item.lane : '');
319
+ $('previous').disabled = selected === 0; $('next').disabled = selected === queue.length - 1;
320
+ $('sound').disabled = !item.src; $('playback').disabled = !item.src && !(item.slides?.length > 1); $('copy-caption').disabled = !item.caption;
321
+ current.querySelector('.post-copy').disabled = !item.caption;
322
+ if (mode === 'feed' && item.slides?.length > 1 && !reducedMotion.matches) playDeck(current);
323
+ $('safe-zones').disabled = !current.querySelector('video'); paintSafeZones();
324
+ paintPlayback(); history.replaceState(null,'','#' + encodeURIComponent(item.slug));
541
325
  }
326
+ function scrollToSelected(behavior) { const post = section(); posts.scrollTo({ left:post.offsetLeft - (posts.clientWidth - post.clientWidth)/2, behavior:reducedMotion.matches ? 'instant' : behavior }); }
542
327
  function go(delta) {
543
- const next = Math.min(ITEMS.length - 1, Math.max(0, index + delta));
544
- if (next === index) return;
545
- index = next; sync();
546
- feed.querySelector(\`.post[data-n="\${index}"]\`).scrollIntoView({ behavior: "smooth", inline: "center", block: "nearest" });
328
+ const next = Math.max(0,Math.min(queue.length - 1,selected + delta)); if (next === selected) return;
329
+ selected = next; activate(); if (mode === 'feed') scrollToSelected('smooth'); else posts.scrollTop = 0;
547
330
  }
548
-
549
- document.getElementById("tofeed").addEventListener("click", () => openFeed(ITEMS[0]?.slug));
550
-
551
- // **The hash is the address of one reel.** It is what makes a card in this page
552
- // something you can send to somebody, and it is how the feed survives a reload
553
- // in the middle of a review.
554
- function fromHash() {
555
- const slug = decodeURIComponent(location.hash.slice(1));
556
- if (!slug) return closeFeed();
557
- if (ITEMS.some((i) => i.slug === slug)) openFeed(slug);
331
+ function close() {
332
+ clearInterval(deckTimer); deckTimer = null; clearTimeout(scrollTimer);
333
+ posts.querySelectorAll('video').forEach(video => { video.pause(); video.removeAttribute('src'); video.load(); });
334
+ viewer.hidden = true; posts.innerHTML = '';
335
+ document.body.style.overflow = ''; grid.inert = false; document.querySelector('header').inert = false;
336
+ if (location.hash) history.replaceState(null,'',location.pathname + location.search);
337
+ originFocus?.focus();
338
+ }
339
+ $('previous').onclick = () => go(-1); $('next').onclick = () => go(1); $('close').onclick = close;
340
+ $('viewer-mode').onclick = () => open(queue[selected], mode === 'detail' ? 'feed' : 'detail');
341
+ $('tofeed').onclick = () => { if (filtered.length) open(filtered[0],'feed'); };
342
+ $('copy-caption').onclick = () => copy(ITEMS[queue[selected]].caption);
343
+ function paintSound() {
344
+ for (const id of ['grid-sound', 'sound']) {
345
+ const button = $(id);
346
+ button.innerHTML = ICONS[muted ? 'muted' : 'volume'];
347
+ button.ariaLabel = button.title = muted ? 'Unmute previews (M)' : 'Mute previews (M)';
348
+ button.setAttribute('aria-pressed', String(!muted));
349
+ }
558
350
  }
559
- addEventListener("hashchange", fromHash);
560
- addEventListener("DOMContentLoaded", fromHash);
561
- fromHash();
562
- document.getElementById("tofeedclose").addEventListener("click", closeFeed);
563
- document.getElementById("sound").addEventListener("click", toggleSound);
564
- document.getElementById("copyfeed").addEventListener("click", (e) => {
565
- navigator.clipboard.writeText(ITEMS[index].caption);
566
- e.target.textContent = "Copied"; setTimeout(() => (e.target.textContent = "Copy caption"), 1200);
567
- });
568
351
  function toggleSound() {
569
352
  muted = !muted;
570
- document.getElementById("sound").textContent = muted ? "🔇 Sound off" : "🔊 Sound on";
571
- sync();
353
+ document.querySelectorAll('video').forEach(video => video.muted = muted);
354
+ paintSound();
572
355
  }
573
-
574
- addEventListener("keydown", (e) => {
575
- if (e.target.tagName === "INPUT") { if (e.key === "Escape") e.target.blur(); return; }
576
- const open = feed.classList.contains("open");
577
- if (e.key === "/") { e.preventDefault(); q.focus(); return; }
578
- if (e.key === "f" && !open) return openFeed(ITEMS[0]?.slug);
579
- if (!open) return;
580
- if (e.key === "Escape") return closeFeed();
581
- if (e.key === "ArrowRight" || e.key === "j") { e.preventDefault(); go(1); }
582
- if (e.key === "ArrowLeft" || e.key === "k") { e.preventDefault(); go(-1); }
583
- // Up and down belong to the caption of the post you are on, not to the feed.
584
- if (e.key === "ArrowDown" || e.key === "ArrowUp" || e.key === "PageDown" || e.key === "PageUp") {
585
- const body = feed.querySelector(\`.post[data-n="\${index}"] .body\`);
586
- if (!body) return;
587
- e.preventDefault();
588
- const step = e.key.startsWith("Page") ? body.clientHeight * 0.8 : 90;
589
- nudge(e.key === "ArrowUp" || e.key === "PageUp" ? -step : step);
590
- }
591
- if (e.key === "m") toggleSound();
592
- if (e.key === " ") {
593
- e.preventDefault();
594
- const post = feed.querySelector(\`.post[data-n="\${index}"]\`);
595
- const v = post.querySelector("video");
596
- if (v) return void (v.paused ? v.play().catch(() => {}) : v.pause());
597
- if (deckTimer) { clearInterval(deckTimer); deckTimer = null; } else playDeck(post);
598
- }
599
- // A deck steps by hand with the bracket keys, since the arrows belong to the
600
- // feed and to the caption.
601
- if (e.key === "[" || e.key === "]") {
602
- const post = feed.querySelector(\`.post[data-n="\${index}"]\`);
603
- if (post.dataset.kind === "carousel") { clearInterval(deckTimer); deckTimer = null; stepSlide(post, e.key === "]" ? 1 : -1); }
604
- }
356
+ $('grid-sound').onclick = $('sound').onclick = toggleSound;
357
+ paintSound();
358
+ function togglePlayback() { const post = section(), video = post.querySelector('video'); if (video) { if (video.paused) video.play().catch(() => {}); else video.pause(); } else if (post.querySelectorAll('.deck img').length > 1) { if (deckTimer) { clearInterval(deckTimer); deckTimer = null; paintPlayback(); } else playDeck(post); } }
359
+ $('playback').onclick = togglePlayback;
360
+ viewer.addEventListener('click', e => {
361
+ if (e.target === viewer) return close();
362
+ const post = e.target.closest('.post'); if (!post) return;
363
+ if (!post.classList.contains('current')) return go(queue.indexOf(Number(post.dataset.n)) - selected);
364
+ if (e.target.closest('.post-copy')) return copy(ITEMS[queue[selected]].caption);
365
+ if (e.target.closest('.build')) return copy(ITEMS[queue[selected]].build,'Build command copied');
366
+ const dot = e.target.closest('.dot'); if (dot) { clearInterval(deckTimer); deckTimer = null; slideTo(post,Number(dot.dataset.slide)); paintPlayback(); }
367
+ if (e.target.closest('.slide-prev')) stepSlide(post,-1);
368
+ if (e.target.closest('.slide-next')) stepSlide(post,1);
605
369
  });
606
- </script>
607
- <p class="dim" style="padding:0 20px 28px;font-size:11.5px">
608
- built ${esc(generatedAt)} · <span class="mono">sprid post preview</span>
609
- </p>
610
- </html>
611
- `;
370
+ posts.addEventListener('play', paintPlayback, true); posts.addEventListener('pause', paintPlayback, true);
371
+ // Horizontal scrolling owns post navigation. Vertical scrolling reads the caption.
372
+ posts.addEventListener('scroll', () => {
373
+ if (mode !== 'feed' || viewer.hidden) return;
374
+ prepareMedia();
375
+ clearTimeout(scrollTimer); scrollTimer = setTimeout(() => {
376
+ const mid = posts.scrollLeft + posts.clientWidth/2;
377
+ let best = selected, distance = Infinity;
378
+ [...posts.children].forEach((post, n) => { const d = Math.abs(post.offsetLeft + post.clientWidth/2 - mid); if (d < distance) { best = n; distance = d; } });
379
+ if (best !== selected) { selected = best; activate(); }
380
+ prepareMedia(true);
381
+ }, 100);
382
+ });
383
+ function nudge(dy) {
384
+ const post = section(), body = post.querySelector('.body'), media = post.querySelector('.media');
385
+ const max = media.getBoundingClientRect().height * .55;
386
+ let shift = parseFloat(post.style.getPropertyValue('--shift') || 0);
387
+ if (dy > 0) { const use = Math.min(dy,Math.max(0,max-shift)); shift += use; body.scrollTop += dy-use; }
388
+ else { const use = Math.min(-dy,body.scrollTop); body.scrollTop -= use; shift = Math.max(0,shift+dy+use); }
389
+ post.style.setProperty('--shift',shift+'px');
612
390
  }
613
-
614
- /** A deck's shape is its own; a reel is 9:16 and says so by not saying it. */
615
- const cssRatio = (i) => (i.aspect ? i.aspect.replace(":", "/") : "9/16");
616
-
617
- function metaLine(i) {
618
- const size =
619
- i.kind === "carousel"
620
- ? [`${i.slideCount ?? 0} slides`, i.aspect].filter(Boolean).join(" · ")
621
- : i.durationMs
622
- ? secs(i.durationMs)
623
- : null;
624
- return [size, i.sizeBytes ? mb(i.sizeBytes) : null, typeof i.lufs === "number" ? `${i.lufs.toFixed(1)} LUFS` : null]
625
- .filter(Boolean)
626
- .join(" · ");
391
+ posts.addEventListener('wheel', e => { if (mode !== 'feed' || Math.abs(e.deltaX) >= Math.abs(e.deltaY) || e.ctrlKey) return; e.preventDefault(); nudge(e.deltaY); }, {passive:false});
392
+ function fromHash() { let slug; try { slug = decodeURIComponent(location.hash.slice(1)); } catch { return; } if (!slug) { if (!viewer.hidden) close(); return; } const n = ITEMS.findIndex(item => item.slug === slug); if (n >= 0) open(n,'detail'); }
393
+ addEventListener('hashchange',fromHash);
394
+ addEventListener('resize', () => { if (!viewer.hidden && mode === 'feed') { prepareMedia(); scrollToSelected('instant'); } });
395
+ addEventListener('keydown', e => {
396
+ if (e.key === 'Escape') { $('help').hidden = true; $('help-toggle').setAttribute('aria-expanded','false'); if (!viewer.hidden) close(); else if (e.target.tagName === 'INPUT') e.target.blur(); return; }
397
+ if (['INPUT','SELECT','TEXTAREA'].includes(e.target.tagName) || e.ctrlKey || e.metaKey || e.altKey) return;
398
+ if (viewer.hidden) { if (e.key === 'm') toggleSound(); if (e.key === '/') { e.preventDefault(); $('q').focus(); } if (e.key === 'f' && filtered.length) open(filtered[0],'feed'); return; }
399
+ if (e.key === 'Tab') { const focusable = [...viewer.querySelectorAll('button:not(:disabled),video[controls],summary')].filter(el => el.getClientRects().length && !el.closest('[inert]') && (!el.closest('.post') || el.closest('.post.current'))); const first = focusable[0], last = focusable.at(-1); if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } return; }
400
+ if (e.key === 'ArrowRight' || e.key === 'j') { e.preventDefault(); go(1); } if (e.key === 'ArrowLeft' || e.key === 'k') { e.preventDefault(); go(-1); }
401
+ if (e.key === '[' || e.key === ']') { e.preventDefault(); stepSlide(section(), e.key === ']' ? 1 : -1); }
402
+ if (e.key === 'm' && !$('sound').disabled) toggleSound();
403
+ if (e.key === ' ') { e.preventDefault(); togglePlayback(); }
404
+ if (mode === 'feed' && ['ArrowUp','ArrowDown','PageUp','PageDown'].includes(e.key)) { e.preventDefault(); const step = e.key.startsWith('Page') ? section().querySelector('.body').clientHeight*.8 : 90; nudge(e.key === 'ArrowUp' || e.key === 'PageUp' ? -step : step); }
405
+ });
406
+ filter(); fromHash();
407
+ </script>
408
+ </body>
409
+ </html>`;
627
410
  }
628
411
 
629
- function card(i) {
630
- const hay = `${i.slug} ${i.lane ?? ""} ${i.caption ?? ""}`.toLowerCase();
631
- const marks = [];
632
- if (i.checks === "pass")
633
- marks.push(`<span class="tick" title="${esc((i.passed ?? []).join(" · ") || "checks")} pass">&#10003;</span>`);
634
- if (i.checks === "fail") marks.push(`<span class="fail">${esc(i.failures.join(", ") || "checks fail")}</span>`);
635
- if (!i.checks) marks.push(`<span title="run check">?</span>`);
636
- if (i.spridStatus && i.spridStatus !== "none") marks.push(`<span>${esc(i.spridStatus)}</span>`);
637
- if (i.captionDrifted) marks.push(`<span class="warn">caption changed since draft</span>`);
638
- if (i.stale) marks.push(`<span class="warn">file differs from its record</span>`);
639
-
640
- const frame = i.slides
641
- ? i.slides.map((src, n) => `<img src="${esc(src)}" alt="" class="${n === 0 ? "on" : ""}" loading="lazy">`).join("") +
642
- `<span class="count">${i.slides.length}</span>`
643
- : i.src
644
- ? `<video src="${esc(i.src)}" preload="metadata" muted loop playsinline></video>`
645
- : `<div class="gone">media not on disk<br><span class="mono">${esc(i.source ?? "")}</span></div>`;
646
-
647
- return `<figure class="card" data-slug="${esc(i.slug)}" data-lane="${esc(i.lane ?? "")}" data-checks="${i.checks ?? "none"}" data-hay="${esc(hay)}" style="--ar:${cssRatio(i)}">
648
- <div class="frame">${frame}</div>
649
- <figcaption>
650
- <div class="slug" title="${esc(i.slug)}">${esc(i.slug)}</div>
651
- <div class="line">${esc(i.kind === "carousel" ? `${i.slideCount ?? 0} slides` : i.durationMs ? secs(i.durationMs) : "")}${marks.length ? " " + marks.join(" ") : ""}</div>
652
- ${
653
- i.caption
654
- ? `<div class="caption">${esc(i.caption)}</div><button class="copy" data-copy>Copy caption</button>`
655
- : `<div class="nocap">no caption in the registry</div>`
656
- }
657
- ${i.build ? `<div class="build" title="${esc(i.build)}">${esc(i.build)}</div>` : ""}
658
- </figcaption>
659
- </figure>`;
412
+ function card(item, n) {
413
+ const carousel = Boolean(item.slides?.length);
414
+ const length = carousel ? `${item.slides.length} slide${item.slides.length === 1 ? '' : 's'}` : item.durationMs ? secs(item.durationMs) : '';
415
+ const media = carousel ? item.slides.map((src, i) => `<img data-src="${esc(src)}" alt="" class="${i === 0 ? 'on' : ''}" loading="lazy">`).join('') : item.src ? `<video data-src="${esc(item.src)}" preload="metadata" muted loop playsinline></video>` : `<span class="gone">${icon('warning')}<strong>Media not on disk</strong><span>${esc(item.slug)}</span></span>`;
416
+ const state = item.issues.length ? 'Needs attention' : item.spridStatus && item.spridStatus !== 'none' ? item.spridStatus : item.checks === 'pass' ? 'Checked' : 'Not checked';
417
+ return `<article class="card" data-n="${n}" style="--ar:${item.ar}">
418
+ <button class="frame" aria-label="Preview ${esc(item.slug)}" title="${esc(item.slug)}">${media}<span class="tile-kind">${icon(carousel ? 'layers' : 'play')}${esc(length)}</span><span class="tile-info"><span class="tile-slug">${esc(item.slug)}</span><span class="tile-meta">${esc(item.lane || '')}</span></span>${item.issues.length ? `<span class="tile-alert" title="${esc(item.issues.join(' · '))}">${icon('warning')}${item.issues.length}</span>` : ''}</button>
419
+ <button class="row-info" aria-label="Open ${esc(item.slug)}"><span class="row-title">${esc(item.caption?.split('\n')[0] || item.slug)}</span><span class="row-slug">${esc(item.slug)}</span></button><span class="row-lane">${esc(item.lane || '')}</span><span class="row-length">${esc(length)}</span><span class="row-state${item.issues.length ? ' attention' : ''}">${icon(item.issues.length ? 'warning' : item.checks === 'pass' ? 'check' : 'clock')}${esc(state)}</span>
420
+ </article>`;
660
421
  }