@sprid/cli 0.1.2
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.
- package/CHANGELOG.md +12 -0
- package/LICENSE +14 -0
- package/README-post.md +615 -0
- package/README.md +249 -0
- package/SECURITY.md +18 -0
- package/bin.mjs +12 -0
- package/package.json +47 -0
- package/src/args.mjs +134 -0
- package/src/browser.mjs +28 -0
- package/src/cli.mjs +257 -0
- package/src/commands/auth.mjs +192 -0
- package/src/commands/completion.mjs +90 -0
- package/src/commands/connect.mjs +586 -0
- package/src/commands/docs.mjs +53 -0
- package/src/commands/family.mjs +96 -0
- package/src/commands/init.mjs +137 -0
- package/src/commands/local-tools.mjs +31 -0
- package/src/commands/marketing-review.mjs +106 -0
- package/src/commands/mcp.mjs +53 -0
- package/src/commands/misc.mjs +94 -0
- package/src/commands/pinterest.mjs +120 -0
- package/src/commands/plan.mjs +230 -0
- package/src/commands/post.mjs +41 -0
- package/src/commands/reviews.mjs +149 -0
- package/src/commands/setup.mjs +220 -0
- package/src/commands/status.mjs +217 -0
- package/src/commands/studio.mjs +69 -0
- package/src/commands/update.mjs +69 -0
- package/src/creds.mjs +126 -0
- package/src/docs/commands.mjs +152 -0
- package/src/docs/guides.generated.mjs +1178 -0
- package/src/docs/help.mjs +77 -0
- package/src/docs/index.d.mts +18 -0
- package/src/docs/index.mjs +45 -0
- package/src/docs/queries.d.mts +11 -0
- package/src/docs/queries.mjs +77 -0
- package/src/endpoint.mjs +17 -0
- package/src/evidence.mjs +15 -0
- package/src/format.mjs +71 -0
- package/src/http.mjs +117 -0
- package/src/pending.mjs +27 -0
- package/src/post/cli.mjs +2285 -0
- package/src/post/json-worker.mjs +12 -0
- package/src/post/preview-server.mjs +58 -0
- package/src/post/preview.mjs +660 -0
- package/src/post/recipes/screen.mjs +290 -0
- package/src/post/recipes/stills.mjs +146 -0
- package/src/post/rules/platform-rules.d.mts +27 -0
- package/src/post/rules/platform-rules.mjs +164 -0
- package/src/post/screen/captions.mjs +131 -0
- package/src/post/screen/compose.mjs +284 -0
- package/src/post/screen/input.mjs +163 -0
- package/src/post/screen/sim.mjs +443 -0
- package/src/post/screen/simkit.swift +328 -0
- package/src/profiles.mjs +61 -0
- package/src/release.ts +2 -0
- package/src/screenshots.ts +1 -0
- package/src/updates.mjs +152 -0
|
@@ -0,0 +1,660 @@
|
|
|
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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
30
|
+
|
|
31
|
+
const scriptJson = value => JSON.stringify(value).replace(/</g, '\\u003c');
|
|
32
|
+
|
|
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
|
+
*/
|
|
40
|
+
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
|
+
|
|
44
|
+
return `<!doctype html>
|
|
45
|
+
<html lang="en">
|
|
46
|
+
<meta charset="utf-8">
|
|
47
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
48
|
+
<title>${esc(account)} — posts</title>
|
|
49
|
+
<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; } }
|
|
174
|
+
</style>
|
|
175
|
+
|
|
176
|
+
<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>
|
|
200
|
+
</div>
|
|
201
|
+
</div>
|
|
202
|
+
|
|
203
|
+
<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();
|
|
209
|
+
}
|
|
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; }
|
|
393
|
+
});
|
|
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")];
|
|
409
|
+
}
|
|
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);
|
|
419
|
+
});
|
|
420
|
+
post.querySelectorAll(".dot").forEach((d, i) => d.classList.toggle("on", i === at));
|
|
421
|
+
post.dataset.slide = at;
|
|
422
|
+
}
|
|
423
|
+
|
|
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);
|
|
430
|
+
}
|
|
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
|
+
}
|
|
444
|
+
}
|
|
445
|
+
visual = n;
|
|
446
|
+
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}\`;
|
|
457
|
+
}
|
|
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();
|
|
466
|
+
}
|
|
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();
|
|
491
|
+
});
|
|
492
|
+
}
|
|
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() {
|
|
536
|
+
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());
|
|
541
|
+
}
|
|
542
|
+
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" });
|
|
547
|
+
}
|
|
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);
|
|
558
|
+
}
|
|
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
|
+
function toggleSound() {
|
|
569
|
+
muted = !muted;
|
|
570
|
+
document.getElementById("sound").textContent = muted ? "🔇 Sound off" : "🔊 Sound on";
|
|
571
|
+
sync();
|
|
572
|
+
}
|
|
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
|
+
}
|
|
605
|
+
});
|
|
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
|
+
`;
|
|
612
|
+
}
|
|
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(" · ");
|
|
627
|
+
}
|
|
628
|
+
|
|
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">✓</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>`;
|
|
660
|
+
}
|