you-wrapped 0.2.0 → 0.3.1

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/SKILL.md CHANGED
@@ -85,8 +85,49 @@ Write the portrait as JSON matching the schema in `prompt.md`, then:
85
85
  npx you-wrapped --render portrait.json --out ./wrapped
86
86
  ```
87
87
 
88
- Produces `wrapped.html` and a redacted `wrapped.share.html`. Tell them to read the share
89
- copy before posting it they should verify their own scrub.
88
+ Writes `wrapped.html` **and** the redacted `wrapped.share.html`, then opens the first in
89
+ their browser automatically. Pass `--no-open` to suppress that.
90
+
91
+ Tell them to read the share copy before posting it — they should verify their own scrub.
92
+ Redaction removes emails, phones, addresses, links, tokens, coordinates, and every name
93
+ harvested from their contacts. It does **not** catch place names or company names you
94
+ wrote into the prose yourself — so keep those out of the portrait text if they'd identify
95
+ someone, or tell the user which ones remain.
96
+
97
+ ## Phase 5 — offer to go deeper (every machine is different)
98
+
99
+ The standard sweep is a floor, not a ceiling. `signals.json` contains an `unexplored`
100
+ block listing sources that were **detected on this machine but deliberately not read** —
101
+ plus a folder inventory. Use it to make a *specific* offer, never a generic menu.
102
+
103
+ After delivering the portrait, tell them what else is available and what it would add.
104
+ Name the actual apps found, with a one-line reason each:
105
+
106
+ > "There's more here if you want it. I found Obsidian (24 MB of notes), WhatsApp,
107
+ > Signal, a Lightroom catalog, and 175 MB of your past Claude sessions. Your notes
108
+ > would sharpen the private-voice read; WhatsApp is often where family lives; the
109
+ > Claude sessions show what you actually ask for help with. Want any of those?"
110
+
111
+ Also offer, when relevant:
112
+
113
+ - **Other connectors** they have but you didn't sweep — Linear, GitHub, Notion, Figma,
114
+ Spotify, Strava, banking exports. Check what's actually available before offering.
115
+ - **Deeper on one source** rather than wider: a single relationship over five years, one
116
+ project end-to-end, or their sent mail alone.
117
+ - **A specific question** they bring. The best second pass is usually answering something
118
+ they've wondered about themselves, not more breadth.
119
+ - **Their creative output** — the films, repos, designs, or writing they actually made.
120
+ Data about a person is not the same as the work they produced.
121
+
122
+ Rules for going deeper:
123
+
124
+ - **Read only what they ask for.** Detection is not permission. Never open a newly
125
+ discovered source without an explicit yes.
126
+ - **Say what each source would and wouldn't tell you** before they decide.
127
+ - **Re-render when you learn something material** — a portrait that ignores a major new
128
+ source is worse than one that never had it.
129
+ - If a source is encrypted at rest (Granola, Signal, some note apps), say so plainly and
130
+ point at the connector or export path instead of attempting to break it.
90
131
 
91
132
  ---
92
133
 
package/bin/cli.mjs CHANGED
@@ -7,6 +7,7 @@ import { analyze } from '../src/analyze.mjs';
7
7
  import { SYSTEM, buildUserPrompt } from '../src/prompt.mjs';
8
8
  import { render } from '../src/render.mjs';
9
9
  import { scrubDeep, coarsenPlaces, harvestNames, labelFor } from '../src/scrub.mjs';
10
+ import { discover } from '../src/discover.mjs';
10
11
  import { installSkill, skillInstalled, checkFullDiskAccess, openPrivacyPane, hostApp, nodeOk, resolveInvocation, hasClaudeCLI, insideClaude, launchClaude } from '../src/setup.mjs';
11
12
 
12
13
  const args = process.argv.slice(2);
@@ -33,6 +34,7 @@ ${c.b}you-wrapped${c.x} — a portrait of your year, from the record your Mac al
33
34
  --days N how far back to read (default 400)
34
35
  --out DIR where to write (default ./you-wrapped-out)
35
36
  --no-share skip the redacted share copy
37
+ --no-open don't open the result in your browser
36
38
  --yes skip prompts
37
39
  --no-launch don't hand off to Claude Code when done
38
40
 
@@ -77,17 +79,42 @@ if (has('--install-skill')) {
77
79
  process.exit(0);
78
80
  }
79
81
 
80
- // ── render-only path
82
+ // ── render-only path (this is what the Claude Code skill calls)
81
83
  if (has('--render')) {
82
84
  const p = JSON.parse(readFileSync(resolve(val('--render')), 'utf8'));
83
- // pick up signals.json if it's sitting next to the portrait
84
- if (!p.__signals) {
85
+ mkdirSync(OUT, { recursive: true });
86
+
87
+ // Pull signals from alongside the portrait so charts and the stat line are real.
88
+ let src = p.__signals;
89
+ if (!src) {
85
90
  const sp = join(OUT, 'signals.json');
86
- if (existsSync(sp)) { try { p.__signals = JSON.parse(readFileSync(sp, 'utf8')).sources; } catch {} }
91
+ if (existsSync(sp)) { try { src = JSON.parse(readFileSync(sp, 'utf8')).sources; } catch {} }
87
92
  }
88
- mkdirSync(OUT, { recursive: true });
89
- writeFileSync(join(OUT, 'wrapped.html'), render(p, { stats: {} }));
93
+ p.__signals = src || {};
94
+ const sm = p.__signals.messages || {};
95
+ const line = [
96
+ sm.sent && `${sm.sent.toLocaleString()} messages you sent`,
97
+ p.__signals.notes?.count && `${p.__signals.notes.count.toLocaleString()} notes`,
98
+ p.__signals.browser && `${((p.__signals.browser.desktopQueries || 0) + (p.__signals.browser.mobileQueries || 0)).toLocaleString()} searches`,
99
+ p.__signals.photos?.geotagged && `${p.__signals.photos.geotagged.toLocaleString()} geotagged frames`,
100
+ ].filter(Boolean).join(' · ');
101
+ const stats = { line, footer: line ? `Assembled ${new Date().toISOString().slice(0, 10)} from ${line}.` : '' };
102
+
103
+ writeFileSync(join(OUT, 'wrapped.html'), render(p, { stats }));
90
104
  log(`${c.g}✓${c.x} ${join(OUT, 'wrapped.html')}`);
105
+
106
+ // The share copy is promised everywhere else — produce it here too.
107
+ if (!has('--no-share')) {
108
+ const names = harvestNames(null, sm.topPeople, sm.topChats);
109
+ const shared = { ...scrubDeep(p, names), __signals: p.__signals };
110
+ writeFileSync(join(OUT, 'wrapped.share.html'), render(shared, { shareMode: true, stats }));
111
+ log(`${c.g}✓${c.x} ${join(OUT, 'wrapped.share.html')} ${c.d}(redacted)${c.x}`);
112
+ }
113
+
114
+ if (!has('--no-open')) {
115
+ try { execFileSync('open', [join(OUT, 'wrapped.html')]); log(`${c.d} opened in your browser${c.x}`); }
116
+ catch { log(`${c.d} open it: ${join(OUT, 'wrapped.html')}${c.x}`); }
117
+ }
91
118
  process.exit(0);
92
119
  }
93
120
 
@@ -150,7 +177,9 @@ photos.ok ? done(`photos — ${photos.count.toLocaleString()} (metadata only, no
150
177
  // ── 2. analyze
151
178
  step('analyzing');
152
179
  const profile = analyze({ messages, notes, browser, photos }, { days: DAYS });
153
- done('analyzed');
180
+ // what else is on this machine that we did NOT read — the agent offers these
181
+ profile.unexplored = discover();
182
+ done(`analyzed — ${profile.unexplored.apps.length} more sources detected but unread`);
154
183
 
155
184
  // ── 3. evidence pack (stays local unless you synthesize)
156
185
  const m = profile.sources.messages || {};
@@ -228,7 +257,7 @@ if (portrait) {
228
257
  const shared = { ...scrubDeep(portrait, names), __signals: profile.sources };
229
258
  writeFileSync(join(OUT, 'wrapped.share.html'), render(shared, { shareMode: true, stats: { line: statLine, footer: stats.footer } }));
230
259
  }
231
- try { execFileSync('open', [join(OUT, 'wrapped.html')]); } catch {}
260
+ if (!has('--no-open')) { try { execFileSync('open', [join(OUT, 'wrapped.html')]); } catch {} }
232
261
  }
233
262
 
234
263
  // ── done
@@ -241,7 +270,7 @@ ${portrait ? ` ${c.d}wrapped.html${c.x} your portrait
241
270
  ${c.d}wrapped.share.html${c.x} redacted copy, safe to post` : ''}
242
271
 
243
272
  ${c.d}${statLine}${c.x}
244
- `);
273
+ ${profile.unexplored.apps.length ? `${c.d} ${profile.unexplored.apps.length} other sources on this machine were detected but not read.\n Ask Claude to go deeper and it will offer them.${c.x}\n` : ''}`);
245
274
 
246
275
  // ── 6. hand off to Claude Code
247
276
  // The skill was written to disk above, and a *new* claude process loads skills
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "you-wrapped",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "Turn the record your Mac already keeps into a portrait of your year. Runs entirely on your machine.",
5
5
  "type": "module",
6
6
  "bin": { "you-wrapped": "./bin/cli.mjs" },
@@ -0,0 +1,86 @@
1
+ // Everyone's machine is different. This looks for sources the CLI does NOT read,
2
+ // so the agent can offer a specific "want to go deeper?" instead of a generic menu.
3
+ // Existence and size only — nothing here opens or parses a file.
4
+
5
+ import { existsSync, statSync, readdirSync } from 'node:fs';
6
+ import { homedir } from 'node:os';
7
+ import { join } from 'node:path';
8
+
9
+ const H = homedir();
10
+ const p = (...x) => join(H, ...x);
11
+
12
+ const CANDIDATES = [
13
+ // note & knowledge apps
14
+ { id: 'obsidian', label: 'Obsidian vault', path: p('Library/Application Support/obsidian'), why: 'long-form notes and daily journals' },
15
+ { id: 'notion', label: 'Notion (local cache)', path: p('Library/Application Support/Notion'), why: 'docs and databases you own' },
16
+ { id: 'bear', label: 'Bear notes', path: p('Library/Group Containers/9K33E3U3T4.net.shinyfrog.bear'), why: 'personal notes' },
17
+ { id: 'craft', label: 'Craft', path: p('Library/Containers/com.lukilabs.lukiapp'), why: 'documents' },
18
+ { id: 'devonthink',label: 'DEVONthink', path: p('Library/Application Support/DEVONthink 3'), why: 'research archive' },
19
+
20
+ // meetings & comms
21
+ { id: 'granola', label: 'Granola', path: p('Library/Application Support/Granola'), why: 'meeting notes — what you say out loud (encrypted locally; use the MCP connector)' },
22
+ { id: 'slack', label: 'Slack (desktop)', path: p('Library/Application Support/Slack'), why: 'where many people ideate, distinct from where they decide' },
23
+ { id: 'discord', label: 'Discord', path: p('Library/Application Support/discord'), why: 'community and side-project conversation' },
24
+ { id: 'whatsapp', label: 'WhatsApp', path: p('Library/Group Containers/group.net.whatsapp.WhatsApp.shared'), why: 'often where family and non-US contacts live' },
25
+ { id: 'signal', label: 'Signal', path: p('Library/Application Support/Signal'), why: 'private conversation' },
26
+ { id: 'mail', label: 'Apple Mail', path: p('Library/Mail'), why: 'sent mail is a distinct professional voice' },
27
+
28
+ // creative & work output
29
+ { id: 'lightroom', label: 'Lightroom catalog', path: p('Pictures/Lightroom Library.lrlibrary'), why: 'what you photograph, and how you edit it' },
30
+ { id: 'photos', label: 'Photos library', path: p('Pictures/Photos Library.photoslibrary'), why: 'faces, places, and who you spend time with' },
31
+ { id: 'resolve', label: 'DaVinci Resolve', path: p('Library/Application Support/Blackmagic Design/DaVinci Resolve'), why: 'projects and timelines you cut' },
32
+ { id: 'xcode', label: 'Xcode', path: p('Library/Developer/Xcode'), why: 'what you build' },
33
+ { id: 'music', label: 'Music library', path: p('Music/Music/Music Library.musiclibrary'), why: 'taste and listening rhythm' },
34
+ { id: 'books', label: 'Apple Books', path: p('Library/Containers/com.apple.iBooksX'), why: 'what you read and highlight' },
35
+
36
+ // agent & shell history
37
+ { id: 'claude', label: 'Past Claude sessions', path: p('.claude/projects'), why: 'what you actually ask for help with' },
38
+ { id: 'cursor', label: 'Cursor history', path: p('Library/Application Support/Cursor'), why: 'how you work with AI in code' },
39
+ { id: 'zsh', label: 'Shell history', path: p('.zsh_history'), why: 'the commands you actually run' },
40
+ ];
41
+
42
+ // Folders worth naming if they're large — where someone's real output lives.
43
+ const FOLDERS = ['Desktop', 'Documents', 'Downloads', 'Movies', 'Music', 'Pictures', 'Developer', 'Projects', 'Code'];
44
+
45
+ function dirBytes(dir, depth = 0, budget = { n: 0 }) {
46
+ // Deliberately shallow: this is a size hint, not an audit.
47
+ if (depth > 2 || budget.n > 4000) return 0;
48
+ let total = 0;
49
+ let entries;
50
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return 0; }
51
+ for (const e of entries) {
52
+ if (budget.n++ > 4000) break;
53
+ if (e.name.startsWith('.')) continue;
54
+ const full = join(dir, e.name);
55
+ try {
56
+ if (e.isDirectory()) total += dirBytes(full, depth + 1, budget);
57
+ else total += statSync(full).size;
58
+ } catch {}
59
+ }
60
+ return total;
61
+ }
62
+
63
+ export function discover() {
64
+ const found = [];
65
+ for (const c of CANDIDATES) {
66
+ if (!existsSync(c.path)) continue;
67
+ let size = 0;
68
+ try { size = statSync(c.path).isDirectory() ? dirBytes(c.path) : statSync(c.path).size; } catch {}
69
+ found.push({ id: c.id, label: c.label, why: c.why, approxBytes: size });
70
+ }
71
+
72
+ const folders = [];
73
+ for (const f of FOLDERS) {
74
+ const dir = p(f);
75
+ if (!existsSync(dir)) continue;
76
+ let count = 0;
77
+ try { count = readdirSync(dir).filter(x => !x.startsWith('.')).length; } catch {}
78
+ if (count > 0) folders.push({ name: f, entries: count });
79
+ }
80
+
81
+ return {
82
+ apps: found.sort((a, b) => b.approxBytes - a.approxBytes),
83
+ folders: folders.sort((a, b) => b.entries - a.entries),
84
+ note: 'Detected but NOT read. Offer these to the user; read only what they ask for.',
85
+ };
86
+ }
package/src/prompt.mjs CHANGED
@@ -40,6 +40,8 @@ RULES:
40
40
  ownership — those are the only two levers that move a capacity-constrained person.
41
41
  - If the data is thin in some area, say so. An honest gap beats a confident fabrication.
42
42
  - Match their voice. If their messages are short and profane, don't write them a corporate memo.
43
+ - After the portrait, offer to go deeper using the "unexplored" block in the signals —
44
+ name the specific sources found on THIS machine. Read nothing new without an explicit yes.
43
45
 
44
46
  OUTPUT: a JSON object matching the schema you are given. No prose outside the JSON.`;
45
47
 
package/src/render.mjs CHANGED
@@ -234,7 +234,7 @@ export function render(p, { shareMode = false, stats = {} } = {}) {
234
234
 
235
235
  const slides = cards.map((c, i) => {
236
236
  const t = THEMES[i % THEMES.length];
237
- return `<section class="card ${c.kind}" data-i="${i}"
237
+ return `<section class="card k-${c.kind}" data-i="${i}"
238
238
  style="--bg:${t.bg};--fg:${t.fg};--ac:${t.ac}">
239
239
  <div class="inner">${c.html}</div></section>`;
240
240
  }).join('\n');
@@ -265,15 +265,18 @@ body{font-family:var(--sans);-webkit-font-smoothing:antialiased;overscroll-behav
265
265
  }
266
266
 
267
267
  .card{position:absolute;inset:0;background:var(--bg);color:var(--fg);
268
- display:flex;align-items:center;padding:calc(var(--pad) + 34px) var(--pad) calc(var(--pad) + 26px);
268
+ overflow-y:auto;overflow-x:hidden;-webkit-overflow-scrolling:touch;
269
+ overscroll-behavior:contain;scrollbar-width:none;
269
270
  opacity:0;pointer-events:none;transform:scale(1.03);
270
271
  transition:opacity .42s ease,transform .52s cubic-bezier(.2,.75,.3,1)}
272
+ .card::-webkit-scrollbar{display:none}
271
273
  .card.on{opacity:1;pointer-events:auto;transform:none}
272
274
  /* paper grain — keeps flat color from looking like a CSS default */
273
- .card::after{content:"";position:absolute;inset:0;pointer-events:none;opacity:.055;
275
+ .inner::after{content:"";position:absolute;inset:0;pointer-events:none;opacity:.055;z-index:-1;
274
276
  background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='120' height='120'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='3'/></filter><rect width='120' height='120' filter='url(%23n)'/></svg>")}
275
- .inner{position:relative;z-index:1;width:100%;max-height:100%;overflow-y:auto;scrollbar-width:none}
276
- .inner::-webkit-scrollbar{display:none}
277
+ .inner{position:relative;z-index:1;width:100%;min-height:100%;
278
+ display:flex;flex-direction:column;justify-content:center;
279
+ padding:calc(var(--pad) + 34px) var(--pad) calc(var(--pad) + 26px)}
277
280
 
278
281
  .eyebrow{font-family:var(--mono);font-size:11px;letter-spacing:.18em;text-transform:uppercase;
279
282
  opacity:.62;margin-bottom:20px}
@@ -289,6 +292,14 @@ blockquote::before{content:"“"}blockquote::after{content:"”"}
289
292
  .sub{font-family:var(--mono);font-size:12.5px;line-height:1.7;margin-top:26px;opacity:.72;max-width:32ch}
290
293
  .small{font-size:14.5px;line-height:1.55;margin-top:16px;opacity:.72;max-width:38ch}
291
294
  .cite{font-family:var(--mono);font-size:11.5px;margin-top:18px;opacity:.6}
295
+ #deck[data-more="1"]::after{content:"";position:absolute;left:0;right:0;bottom:0;height:78px;
296
+ z-index:6;pointer-events:none;
297
+ background:linear-gradient(to top,var(--fade) 10%,color-mix(in srgb,var(--fade) 65%,transparent) 55%,transparent)}
298
+ .more{position:absolute;right:20px;bottom:14px;z-index:7;color:var(--fadefg);
299
+ font-family:var(--mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;
300
+ opacity:0;transition:opacity .25s;pointer-events:none;display:flex;align-items:center;gap:6px}
301
+ #deck[data-more="1"] .more{opacity:.75;animation:bob 1.8s ease-in-out infinite}
302
+ @keyframes bob{0%,100%{transform:translateY(0)}50%{transform:translateY(3px)}}
292
303
  .hint{position:absolute;left:var(--pad);bottom:calc(var(--pad) + 4px);font-family:var(--mono);font-size:11px;
293
304
  letter-spacing:.16em;text-transform:uppercase;opacity:.5;animation:pulse 2.4s ease-in-out infinite}
294
305
  @keyframes pulse{0%,100%{opacity:.28}50%{opacity:.72}}
@@ -326,8 +337,8 @@ blockquote::before{content:"“"}blockquote::after{content:"”"}
326
337
  #deck.dark #bars i{background:rgba(0,0,0,.22)}
327
338
  #deck.dark #bars i.done,#deck.dark #bars i.now{background:rgba(0,0,0,.82)}
328
339
 
329
- .tapzone{position:absolute;top:0;bottom:0;width:34%;z-index:8;cursor:pointer}
330
- .tapzone.l{left:0}.tapzone.r{right:0;width:66%}
340
+ .tapzone{position:absolute;top:0;bottom:0;width:20%;z-index:8;cursor:pointer}
341
+ .tapzone.l{left:0}.tapzone.r{right:0;width:22%}
331
342
  #badge{position:absolute;bottom:10px;left:0;right:0;text-align:center;z-index:9;
332
343
  font-family:var(--mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;
333
344
  color:rgba(255,255,255,.4)}
@@ -342,7 +353,7 @@ blockquote::before{content:"“"}blockquote::after{content:"”"}
342
353
  .evq::before{content:"“"}.evq::after{content:"”"}
343
354
  .evq cite{display:block;font-family:var(--mono);font-size:10px;font-style:normal;font-weight:400;
344
355
  letter-spacing:.06em;opacity:.55;margin-top:7px}
345
- .card.chart-card .inner,.card.data .inner{padding-top:4px}
356
+ .card.k-chart .inner,.card.k-data .inner{padding-top:4px}
346
357
  ${CHART_CSS}
347
358
 
348
359
  /* ── long-read mode ── */
@@ -409,6 +420,7 @@ body.reading #stage,body.reading #badge{display:none}
409
420
  <div id="bars">${bars}</div>
410
421
  ${slides}
411
422
  <div class="hint" id="hint">tap or press → to begin</div>
423
+ <div class="more" id="more">scroll ↓</div>
412
424
  <div class="tapzone l" data-nav="-1" aria-hidden="true"></div>
413
425
  <div class="tapzone r" data-nav="1" aria-hidden="true"></div>
414
426
  </div></div>
@@ -425,12 +437,26 @@ const LIGHT=new Set(cards.map((c,k)=>{
425
437
  const v=parseInt(h.slice(0,2),16)*.299+parseInt(h.slice(2,4),16)*.587+parseInt(h.slice(4,6),16)*.114;
426
438
  return v>140?k:-1;
427
439
  }).filter(k=>k>=0));
440
+ function measure(){
441
+ cards.forEach(c=>{ c.dataset.more = c.scrollHeight > c.clientHeight + 4 ? '1' : '0'; });
442
+ }
443
+ function syncMore(){
444
+ const c=cards[i];
445
+ const more = c.dataset.more==='1' && c.scrollTop + c.clientHeight < c.scrollHeight - 6;
446
+ deck.dataset.more = more ? '1' : '0';
447
+ // fade + cue inherit the active card's colours
448
+ const cs=getComputedStyle(c);
449
+ deck.style.setProperty('--fade', cs.backgroundColor);
450
+ deck.style.setProperty('--fadefg', cs.color);
451
+ }
428
452
  function show(k){
429
453
  const h=document.getElementById('hint'); if(h) h.style.display = k===0 ? '' : 'none';
430
454
  i=Math.max(0,Math.min(N-1,k));
431
455
  cards.forEach((c,x)=>c.classList.toggle('on',x===i));
432
456
  bars.forEach((b,x)=>{b.classList.toggle('done',x<i);b.classList.toggle('now',x===i)});
433
457
  deck.classList.toggle('dark',LIGHT.has(i));
458
+ cards[i].scrollTop=0;
459
+ requestAnimationFrame(syncMore);
434
460
  }
435
461
  document.querySelectorAll('[data-nav]').forEach(z=>
436
462
  z.addEventListener('click',()=>show(i+ +z.dataset.nav)));
@@ -451,12 +477,18 @@ addEventListener('keydown',e=>{
451
477
  if(e.key==='ArrowRight'||e.key===' ') {e.preventDefault();show(i+1)}
452
478
  if(e.key==='ArrowLeft') {e.preventDefault();show(i-1)}
453
479
  });
454
- let x0=null;
455
- deck.addEventListener('touchstart',e=>x0=e.touches[0].clientX,{passive:true});
480
+ let x0=null,y0=null;
481
+ deck.addEventListener('touchstart',e=>{x0=e.touches[0].clientX;y0=e.touches[0].clientY},{passive:true});
456
482
  deck.addEventListener('touchend',e=>{
457
- if(x0===null)return; const dx=e.changedTouches[0].clientX-x0;
458
- if(Math.abs(dx)>44) show(i+(dx<0?1:-1)); x0=null;
483
+ if(x0===null)return;
484
+ const dx=e.changedTouches[0].clientX-x0, dy=e.changedTouches[0].clientY-y0;
485
+ // only treat it as a page flip if the gesture is clearly horizontal
486
+ if(Math.abs(dx)>44 && Math.abs(dx)>Math.abs(dy)*1.5) show(i+(dx<0?1:-1));
487
+ x0=y0=null;
459
488
  },{passive:true});
460
- show(0);
489
+ cards.forEach(c=>c.addEventListener('scroll',syncMore,{passive:true}));
490
+ addEventListener('resize',()=>{measure();syncMore()});
491
+ if(document.fonts?.ready) document.fonts.ready.then(()=>{measure();syncMore()});
492
+ measure(); show(0);
461
493
  </script></body></html>`;
462
494
  }