you-wrapped 0.1.0 → 0.2.0

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/README.md CHANGED
@@ -36,6 +36,9 @@ you-wrapped-out/
36
36
  portrait.json the analysis (with --synthesize)
37
37
  wrapped.html your portrait (with --synthesize)
38
38
  wrapped.share.html redacted, safe to post
39
+
40
+ wrapped.html has two modes: swipeable story cards (arrows, tap, or swipe) and a
41
+ full long-read with every finding at length. Press R to switch.
39
42
  ```
40
43
 
41
44
  ## Usage
package/SKILL.md CHANGED
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: you-wrapped
3
- description: Build an evidence-grounded portrait of the user from everything you can reach — their Mac (iMessage, Notes, browser, photo metadata) plus any connected services (Gmail, Calendar, Drive, Slack, Granola, Linear). Use when someone asks you to "understand me", wants a personal year-in-review or "wrapped", a self-audit, or asks what their own data says about them.
3
+ description: Build an evidence-grounded portrait of the user from everything you can reach — their Mac (iMessage, Notes, browser, photo metadata) plus any connected services (Gmail, Calendar, Drive, Slack, Granola, Linear). Use when someone asks you to "understand me", wants a personal year-in-review or "wrapped", a self-audit, asks what their own data says about them, or says "do a you-wrapped on me".
4
4
  ---
5
5
 
6
6
  # you-wrapped
@@ -17,17 +17,9 @@ connectors alone misses what they write at 1am.
17
17
  ## Phase 1 — local extraction (the CLI)
18
18
 
19
19
  ```bash
20
- # Published (once it's on npm):
21
20
  npx you-wrapped --days 400 --out ./wrapped
22
-
23
- # Not published yet — run from a local checkout:
24
- npx /path/to/you-wrapped --days 400 --out ./wrapped
25
21
  ```
26
22
 
27
- **Verify which form applies before running.** `npm view you-wrapped version` — if that
28
- 404s, the package is unpublished and you must use the local path. Do not run a bare
29
- `npx you-wrapped` against an unclaimed name.
30
-
31
23
  Reads iMessage (including `attributedBody` — ~44% of messages, invisible to naive
32
24
  scrapers), Apple Notes, Contacts, Chrome + Safari history split by device, and photo
33
25
  metadata. Writes `signals.json`, `evidence.local.md`, `prompt.md`.
@@ -63,8 +55,9 @@ Don't summarize `signals.json` back to them. Counts are not insight. Work the me
63
55
  many domains. State it in one sentence, then show it in four unrelated places. If no single
64
56
  trait explains most of the data, say so rather than inventing one.
65
57
 
66
- **2. Find the scissor.** Two trends moving in opposite directions that shouldn't.
67
- Commitment language rising while requests-for-help fall is the common one. Quantify it.
58
+ **2. Find the scissor.** Two trends moving in opposite directions that shouldn't — output up while
59
+ recovery down, breadth up while depth down, promises up while asks down. Find whichever pair *this*
60
+ data actually shows rather than reaching for a template. Quantify it.
68
61
 
69
62
  **3. Find a dated collision.** Cross-reference sources for a specific day where two
70
63
  commitments overlapped and one broke — photo GPS against a written regret, travel against a
@@ -89,7 +82,7 @@ it.** Let them. This is the part that earns the rest.
89
82
  Write the portrait as JSON matching the schema in `prompt.md`, then:
90
83
 
91
84
  ```bash
92
- npx /path/to/you-wrapped --render portrait.json --out ./wrapped
85
+ npx you-wrapped --render portrait.json --out ./wrapped
93
86
  ```
94
87
 
95
88
  Produces `wrapped.html` and a redacted `wrapped.share.html`. Tell them to read the share
package/bin/cli.mjs CHANGED
@@ -7,7 +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 { installSkill, skillInstalled, checkFullDiskAccess, openPrivacyPane, hostApp, nodeOk, resolveInvocation } from '../src/setup.mjs';
10
+ import { installSkill, skillInstalled, checkFullDiskAccess, openPrivacyPane, hostApp, nodeOk, resolveInvocation, hasClaudeCLI, insideClaude, launchClaude } from '../src/setup.mjs';
11
11
 
12
12
  const args = process.argv.slice(2);
13
13
  const has = f => args.includes(f);
@@ -34,6 +34,7 @@ ${c.b}you-wrapped${c.x} — a portrait of your year, from the record your Mac al
34
34
  --out DIR where to write (default ./you-wrapped-out)
35
35
  --no-share skip the redacted share copy
36
36
  --yes skip prompts
37
+ --no-launch don't hand off to Claude Code when done
37
38
 
38
39
  ${c.d}Everything runs locally. Nothing is uploaded unless you pass --synthesize,
39
40
  and even then only aggregate signals and short quotes are sent.${c.x}
@@ -79,6 +80,11 @@ if (has('--install-skill')) {
79
80
  // ── render-only path
80
81
  if (has('--render')) {
81
82
  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
+ const sp = join(OUT, 'signals.json');
86
+ if (existsSync(sp)) { try { p.__signals = JSON.parse(readFileSync(sp, 'utf8')).sources; } catch {} }
87
+ }
82
88
  mkdirSync(OUT, { recursive: true });
83
89
  writeFileSync(join(OUT, 'wrapped.html'), render(p, { stats: {} }));
84
90
  log(`${c.g}✓${c.x} ${join(OUT, 'wrapped.html')}`);
@@ -213,11 +219,13 @@ if (has('--synthesize')) {
213
219
  // ── 5. render
214
220
  if (portrait) {
215
221
  const stats = { line: statLine, footer: `Assembled ${new Date().toISOString().slice(0, 10)} from ${statLine}.` };
216
- writeFileSync(join(OUT, 'wrapped.html'), render(portrait, { stats }));
222
+ // the renderer draws its charts from the real signals, not from the model's prose
223
+ const withSignals = { ...portrait, __signals: profile.sources };
224
+ writeFileSync(join(OUT, 'wrapped.html'), render(withSignals, { stats }));
217
225
 
218
226
  if (!has('--no-share')) {
219
227
  const names = harvestNames(contacts.data, m.topPeople, m.topChats);
220
- const shared = scrubDeep(portrait, names);
228
+ const shared = { ...scrubDeep(portrait, names), __signals: profile.sources };
221
229
  writeFileSync(join(OUT, 'wrapped.share.html'), render(shared, { shareMode: true, stats: { line: statLine, footer: stats.footer } }));
222
230
  }
223
231
  try { execFileSync('open', [join(OUT, 'wrapped.html')]); } catch {}
@@ -234,3 +242,19 @@ ${portrait ? ` ${c.d}wrapped.html${c.x} your portrait
234
242
 
235
243
  ${c.d}${statLine}${c.x}
236
244
  `);
245
+
246
+ // ── 6. hand off to Claude Code
247
+ // The skill was written to disk above, and a *new* claude process loads skills
248
+ // at startup — so launching now is what makes this a single command.
249
+ if (!portrait && !has('--no-launch')) {
250
+ if (insideClaude()) {
251
+ log(`${c.d} Already inside Claude Code — tell me "do a you-wrapped on me" and I'll read ${OUT}.${c.x}\n`);
252
+ } else if (hasClaudeCLI()) {
253
+ log(`${c.o} Handing off to Claude Code…${c.x}\n`);
254
+ try { launchClaude(OUT); }
255
+ catch { log(`${c.d} Couldn't launch. Run ${c.b}claude${c.x}${c.d} and say "do a you-wrapped on me".${c.x}\n`); }
256
+ } else {
257
+ log(` ${c.b}Next:${c.x} paste ${c.d}${join(OUT, 'prompt.md')}${c.x} into Claude.`);
258
+ log(` ${c.d}Or install Claude Code (claude.ai/code) and rerun for the one-command flow.${c.x}\n`);
259
+ }
260
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "you-wrapped",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
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" },
package/src/charts.mjs ADDED
@@ -0,0 +1,124 @@
1
+ // Inline SVG charts drawn from the real extracted signals — not from the model's
2
+ // prose. If the numbers aren't in signals.json, the chart doesn't render at all.
3
+
4
+ const esc = s => String(s ?? '').replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
5
+ const shortMonth = m => { const [y, mo] = m.split('-'); return `${['', 'JAN','FEB','MAR','APR','MAY','JUN','JUL','AUG','SEP','OCT','NOV','DEC'][+mo]} ’${y.slice(2)}`; };
6
+
7
+ /** Two-series line chart. Used for the commitment/delegation scissor. */
8
+ export function trendChart(series, keys, labels, colors) {
9
+ if (!series || series.length < 3) return '';
10
+ const W = 900, H = 300, PL = 46, PR = 20, PT = 18, PB = 34;
11
+ const all = series.flatMap(r => keys.map(k => r[k] ?? 0));
12
+ const lo = Math.max(0, Math.floor(Math.min(...all) - 1));
13
+ const hi = Math.ceil(Math.max(...all) + 1);
14
+ const x = i => PL + (i / (series.length - 1)) * (W - PL - PR);
15
+ const y = v => PT + (1 - (v - lo) / (hi - lo || 1)) * (H - PT - PB);
16
+ const path = k => series.map((r, i) => `${i ? 'L' : 'M'}${x(i).toFixed(1)},${y(r[k] ?? 0).toFixed(1)}`).join(' ');
17
+ const grid = [0, .25, .5, .75, 1].map(f => {
18
+ const v = lo + f * (hi - lo);
19
+ return `<line x1="${PL}" y1="${y(v)}" x2="${W - PR}" y2="${y(v)}" stroke="currentColor" opacity=".18"/>
20
+ <text x="${PL - 8}" y="${y(v) + 4}" text-anchor="end" class="ax">${v.toFixed(0)}</text>`;
21
+ }).join('');
22
+
23
+ return `<figure class="chart">
24
+ <svg viewBox="0 0 ${W} ${H}" role="img" aria-label="${esc(labels.join(' versus '))} over time">
25
+ ${grid}
26
+ ${keys.map((k, n) => `<path d="${path(k)}" fill="none" stroke="${colors[n]}"
27
+ stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round" class="ln"/>`).join('')}
28
+ ${keys.map((k, n) => {
29
+ const last = series[series.length - 1];
30
+ return `<circle cx="${x(series.length - 1)}" cy="${y(last[k] ?? 0)}" r="4" fill="${colors[n]}"/>`;
31
+ }).join('')}
32
+ <text x="${PL}" y="${H - 8}" class="ax">${esc(shortMonth(series[0].month))}</text>
33
+ <text x="${W - PR}" y="${H - 8}" text-anchor="end" class="ax">${esc(shortMonth(series[series.length - 1].month))}</text>
34
+ </svg>
35
+ <figcaption>${keys.map((k, n) =>
36
+ `<span><i style="background:${colors[n]}"></i>${esc(labels[n])} — ${series[0][k]} → ${series[series.length - 1][k]}</span>`).join('')}
37
+ <span class="unit">per 1,000 messages sent</span></figcaption>
38
+ </figure>`;
39
+ }
40
+
41
+ /** 24-bar histogram of when you actually send messages. */
42
+ export function hourChart(hours) {
43
+ if (!hours || hours.length !== 24) return '';
44
+ const max = Math.max(...hours) || 1;
45
+ const total = hours.reduce((a, b) => a + b, 0) || 1;
46
+ const bars = hours.map((v, h) => {
47
+ const pct = (v / max) * 100;
48
+ const night = h >= 22 || h <= 3;
49
+ return `<div class="hbar" style="--h:${pct.toFixed(1)}%" data-night="${night}">
50
+ <i></i><span>${h === 0 ? '12a' : h === 12 ? '12p' : h > 12 ? h - 12 : h}</span></div>`;
51
+ }).join('');
52
+ const late = hours.reduce((s, v, h) => s + ((h >= 22 || h <= 3) ? v : 0), 0);
53
+ return `<figure class="chart hours">
54
+ <div class="hgrid">${bars}</div>
55
+ <figcaption><span><i style="background:#FF4D3D"></i>10pm–3am — ${(late / total * 100).toFixed(1)}% of everything you send</span></figcaption>
56
+ </figure>`;
57
+ }
58
+
59
+ /** Where the body actually was, by photo GPS. */
60
+ export function placeTable(clusters, labelFor) {
61
+ if (!clusters?.length) return '';
62
+ const rows = clusters.slice(0, 8).map(c => {
63
+ const days = c.days || [];
64
+ return `<tr><td>${esc(labelFor(c.lat, c.lon))}</td>
65
+ <td class="num">${c.photos.toLocaleString()}</td>
66
+ <td class="dim">${days.length} day${days.length === 1 ? '' : 's'}</td>
67
+ <td class="dim mono">${esc(days[0] || '')}${days.length > 1 ? ` → ${esc(days[days.length - 1])}` : ''}</td></tr>`;
68
+ }).join('');
69
+ return `<table class="tbl"><thead><tr><th>place</th><th class="num">frames</th><th>days</th><th>range</th></tr></thead>
70
+ <tbody>${rows}</tbody></table>`;
71
+ }
72
+
73
+ /** Things written down repeatedly and never closed. */
74
+ export function recurringTable(items) {
75
+ if (!items?.length) return '';
76
+ const rows = items.slice(0, 10).map(r =>
77
+ `<tr><td>${esc(r.item)}</td><td class="num">${r.days.length}×</td>
78
+ <td class="dim mono">${esc(r.days[0])} → ${esc(r.days[r.days.length - 1])}</td></tr>`).join('');
79
+ return `<table class="tbl"><thead><tr><th>you wrote this down again and again</th><th class="num">times</th><th>span</th></tr></thead>
80
+ <tbody>${rows}</tbody></table>`;
81
+ }
82
+
83
+ /** Desktop mind vs phone mind. */
84
+ export function deviceSplit(b) {
85
+ if (!b || (!b.desktopQueries && !b.mobileQueries)) return '';
86
+ const m = b.mobileShare ?? 0;
87
+ return `<figure class="chart">
88
+ <div class="split"><div class="sd" style="width:${100 - m}%"><b>${b.desktopQueries?.toLocaleString() ?? 0}</b><span>desktop</span></div>
89
+ <div class="sm" style="width:${m}%"><b>${b.mobileQueries?.toLocaleString() ?? 0}</b><span>phone</span></div></div>
90
+ <figcaption><span class="unit">${m}% of your searching happens on your phone</span></figcaption>
91
+ </figure>`;
92
+ }
93
+
94
+ export const CHART_CSS = `
95
+ .chart{margin:26px 0 30px}
96
+ .chart svg{display:block;width:100%;height:auto;overflow:visible}
97
+ .chart .ln{stroke-dasharray:2400;stroke-dashoffset:2400;animation:draw 1.5s cubic-bezier(.3,.7,.3,1) forwards}
98
+ @keyframes draw{to{stroke-dashoffset:0}}
99
+ .ax{font-family:var(--mono);font-size:11px;fill:currentColor;opacity:.5}
100
+ .chart figcaption{display:flex;gap:16px;flex-wrap:wrap;font-family:var(--mono);font-size:10.5px;
101
+ opacity:.72;margin-top:11px;line-height:1.5}
102
+ .chart figcaption i{display:inline-block;width:20px;height:2px;vertical-align:middle;margin-right:8px}
103
+ .chart .unit{opacity:.55}
104
+ .hgrid{display:grid;grid-template-columns:repeat(24,1fr);gap:2px;align-items:end;height:132px}
105
+ .hbar{display:flex;flex-direction:column;justify-content:flex-end;height:100%;gap:6px}
106
+ .hbar i{display:block;height:var(--h);background:currentColor;opacity:.55;border-radius:1px;min-height:2px}
107
+ .hbar[data-night="true"] i{background:var(--ac);opacity:1}
108
+ .hbar span{font-family:var(--mono);font-size:8px;opacity:.45;text-align:center}
109
+ @media(max-width:640px){.hbar span{display:none}}
110
+ .split{display:flex;height:66px;border-radius:3px;overflow:hidden;border:1px solid currentColor}
111
+ .split>div{display:flex;flex-direction:column;justify-content:center;padding:0 16px;min-width:0}
112
+ .split b{font-family:var(--narrow);font-size:22px;font-weight:700}
113
+ .split span{font-family:var(--mono);font-size:10.5px;letter-spacing:.12em;text-transform:uppercase;opacity:.6}
114
+ .sd{background:transparent}.sm{background:var(--ac);color:var(--bg)}
115
+ .tbl{width:100%;border-collapse:collapse;margin:18px 0 8px;font-size:13.5px}
116
+ .tbl th{font-family:var(--mono);font-size:10.5px;letter-spacing:.13em;text-transform:uppercase;
117
+ opacity:.5;text-align:left;font-weight:400;padding:0 10px 9px 0;border-bottom:1px solid currentColor}
118
+ .tbl td{padding:9px 10px 9px 0;border-bottom:1px solid currentColor;border-color:color-mix(in srgb,currentColor 16%,transparent)}
119
+ .tbl .num{text-align:right;font-family:var(--mono);font-size:13px}
120
+ .tbl th.num{text-align:right}
121
+ .tbl .dim{opacity:.6;font-size:12.5px}
122
+ .tbl .mono{font-family:var(--mono);font-size:12px}
123
+ @media(prefers-reduced-motion:reduce){.chart .ln{stroke-dashoffset:0;animation:none}}
124
+ `;
package/src/prompt.mjs CHANGED
@@ -10,9 +10,10 @@ METHOD — follow it in order:
10
10
  domains. Find theirs. State it in one sentence. Then show it appearing in at least four unrelated
11
11
  places. If you cannot find one trait that explains most of the data, say so rather than inventing one.
12
12
 
13
- 2. FIND THE SCISSOR. Look for two trends that move in opposite directions and should not. (Common one:
14
- commitments rising while requests-for-help fall.) A scissor is where a person's stated intent and
15
- their actual behavior diverge over time. Quantify it.
13
+ 2. FIND THE SCISSOR. Look for two trends that move in opposite directions and should not — e.g. output
14
+ rising while recovery falls, breadth rising while depth falls, or promises rising while asks fall.
15
+ A scissor is where stated intent and actual behavior diverge over time. Find whichever pair the data
16
+ actually shows; don't force a template. Quantify it.
16
17
 
17
18
  3. FIND A DATED COLLISION. Somewhere in the data there is usually a specific day where two commitments
18
19
  overlapped and one broke. Cross-reference sources — location against written regret, calendar against
@@ -55,10 +56,10 @@ Write the portrait. Return only JSON:
55
56
  {
56
57
  "headline_quote": "their own most revealing line, verbatim",
57
58
  "headline_quote_source": "where and when",
58
- "one_trait": { "name": "...", "thesis": "one sentence", "domains": [{"label":"...","evidence":"..."}] },
59
- "scissor": { "thesis": "...", "detail": "...", "numbers": [{"label":"...","from":"...","to":"..."}] },
59
+ "one_trait": { "name": "...", "thesis": "one sentence", "domains": [{"label":"...","evidence":"..."}], "evidence": [{"quote":"their exact words","source":"iMessage|Notes|search|meeting","date":"YYYY-MM-DD"}] },
60
+ "scissor": { "thesis": "...", "detail": "...", "numbers": [{"label":"...","from":"...","to":"..."}], "evidence": [{"quote":"...","source":"...","date":"..."}] },
60
61
  "collision": { "date": "...", "thesis": "...", "detail": "...", "verification": "which two sources agree" },
61
- "insight_vs_capacity": { "thesis": "...", "predicted": "...", "happened": "...", "implication": "..." },
62
+ "insight_vs_capacity": { "thesis": "...", "predicted": "...", "happened": "...", "implication": "...", "evidence": [{"quote":"...","source":"...","date":"..."}] },
62
63
  "unseen_wins": [{"label":"...","detail":"..."}],
63
64
  "distortion": { "thesis": "...", "evidence": "..." },
64
65
  "human_thing": { "thesis": "...", "private": ["..."], "public": ["..."], "note": "why these are next to each other" },
package/src/render.mjs CHANGED
@@ -1,125 +1,462 @@
1
- // Renders the portrait JSON to a standalone HTML file. No external assets beyond
2
- // two webfonts, which degrade to system faces offline.
1
+ // Story-card renderer. Full-bleed vertical cards, tap/swipe/arrow to advance
2
+ // the format people already know from year-in-review recaps.
3
+ // Visual identity is deliberately its own: flat risograph color fields and heavy
4
+ // condensed type, not gradient blobs.
5
+
6
+ import { trendChart, hourChart, placeTable, recurringTable, deviceSplit, CHART_CSS } from './charts.mjs';
7
+ import { labelFor } from './scrub.mjs';
3
8
 
4
9
  const esc = s => String(s ?? '').replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
10
+ const has = v => v != null && v !== '' && !(Array.isArray(v) && !v.length);
11
+
12
+ /** Receipts. Every card that makes a claim shows where the claim came from. */
13
+ const cite = (src) => src ? `<p class="src">${esc(src)}</p>` : '';
14
+ const quotes = (list) => !list?.length ? '' : `<div class="ev">${list.slice(0, 3).map(e =>
15
+ `<blockquote class="evq">${esc(e.quote || e)}<cite>${esc(e.source || '')}${e.date ? ` · ${esc(e.date)}` : ''}</cite></blockquote>`).join('')}</div>`;
16
+
17
+ // Each card gets a flat duotone. Ordered so adjacent cards always contrast hard.
18
+ const THEMES = [
19
+ { bg: '#0E0E12', fg: '#F2EFE6', ac: '#E8FF43' }, // ink / acid
20
+ { bg: '#E8FF43', fg: '#0E0E12', ac: '#0E0E12' }, // acid
21
+ { bg: '#FF4D3D', fg: '#FFF5EF', ac: '#0E0E12' }, // vermilion
22
+ { bg: '#1B3BFF', fg: '#F2EFE6', ac: '#E8FF43' }, // ultramarine
23
+ { bg: '#F2EFE6', fg: '#0E0E12', ac: '#FF4D3D' }, // bone
24
+ { bg: '#00C48A', fg: '#04150F', ac: '#04150F' }, // green
25
+ { bg: '#FF8A3D', fg: '#1A0A00', ac: '#1A0A00' }, // amber
26
+ { bg: '#B14BFF', fg: '#F7F0FF', ac: '#E8FF43' }, // violet
27
+ { bg: '#0E0E12', fg: '#F2EFE6', ac: '#00C48A' },
28
+ { bg: '#FF2D78', fg: '#FFF0F6', ac: '#0E0E12' }, // magenta
29
+ ];
30
+
31
+ /** Build the card list from the portrait, skipping anything the model left empty. */
32
+ function buildCards(p, stats, shareMode, sig = {}) {
33
+ const M = sig.messages || {}, B = sig.browser || {}, PH = sig.photos || {}, NT = sig.notes || {};
34
+ const cards = [];
35
+ const add = (kind, html, opts = {}) => cards.push({ kind, html, ...opts });
36
+
37
+ add('cover', `
38
+ <div class="eyebrow">${esc(stats.year || new Date().getFullYear())}</div>
39
+ <h1 class="giant">YOU,<br>WRAPPED</h1>
40
+ <p class="sub">${esc(stats.line || '')}</p>
41
+ `);
42
+
43
+ if (has(p.headline_quote)) add('quote', `
44
+ <div class="eyebrow">you wrote this</div>
45
+ <blockquote>${esc(p.headline_quote)}</blockquote>
46
+ <p class="cite">${esc(p.headline_quote_source || '')}</p>`);
47
+
48
+ if (p.one_trait?.name) {
49
+ add('trait', `
50
+ <div class="eyebrow">the one thing</div>
51
+ <h2 class="big">${esc(p.one_trait.name)}</h2>
52
+ <p class="lede">${esc(p.one_trait.thesis)}</p>
53
+ ${quotes(p.one_trait.evidence)}`);
54
+ const d = p.one_trait.domains || [];
55
+ if (d.length) add('list', `
56
+ <div class="eyebrow">it shows up everywhere</div>
57
+ <ul class="ticks">${d.slice(0, 4).map(x =>
58
+ `<li><b>${esc(x.label)}</b><span>${esc(x.evidence)}</span></li>`).join('')}</ul>`);
59
+ }
60
+
61
+ if (p.scissor?.thesis) {
62
+ const n = p.scissor.numbers || [];
63
+ add('stat', `
64
+ <div class="eyebrow">the trend you can't see from inside</div>
65
+ <h2 class="mid">${esc(p.scissor.thesis)}</h2>
66
+ ${n.length ? `<div class="figures">${n.slice(0, 2).map(x => `
67
+ <div class="fig"><div class="fig-n">${esc(x.from)}<span class="arrow">→</span>${esc(x.to)}</div>
68
+ <div class="fig-l">${esc(x.label)}</div></div>`).join('')}</div>` : ''}
69
+ <p class="small">${esc(p.scissor.detail || '')}</p>
70
+ ${cite('measured across every message you sent')}`);
71
+
72
+ const chart = trendChart(M.series, ['commitment', 'delegation'],
73
+ ['promising ("I\'ll", "on it")', 'asking ("can you")'], ['currentColor', 'var(--ac)']);
74
+ if (chart) add('chart', `
75
+ <div class="eyebrow">ten months, month by month</div>
76
+ ${chart}
77
+ ${quotes(p.scissor?.evidence)}
78
+ ${cite(`from ${(M.sent || 0).toLocaleString()} messages you sent`)}`);
79
+ }
80
+
81
+ if (M.hours?.length === 24) add('chart', `
82
+ <div class="eyebrow">when you're actually working</div>
83
+ ${hourChart(M.hours)}
84
+ <p class="small">${esc(M.lateNightShare ?? 0)}% of what you send goes out between 10pm and 3am. ${esc(M.weekendShare ?? 0)}% lands on a weekend.</p>
85
+ ${cite('every timestamped message, bucketed by hour')}`);
86
+
87
+ if (p.collision?.date) {
88
+ add('collision', `
89
+ <div class="eyebrow">the day it collided</div>
90
+ <h2 class="date">${esc(p.collision.date)}</h2>
91
+ <p class="lede">${esc(p.collision.thesis)}</p>
92
+ <p class="small">${esc(p.collision.detail || '')}</p>
93
+ ${p.collision.verification ? `<p class="src">✓ ${esc(p.collision.verification)}</p>` : ''}`);
94
+ const places = placeTable(PH.clusters, labelFor);
95
+ if (places) add('data', `
96
+ <div class="eyebrow">where your body actually was</div>
97
+ ${places}
98
+ ${cite(`${(PH.geotagged || 0).toLocaleString()} geotagged frames · location never leaves this file`)}`);
99
+ }
100
+
101
+ if (p.insight_vs_capacity?.thesis) add('split', `
102
+ <div class="eyebrow">you already knew</div>
103
+ <div class="vs">
104
+ <div><span class="tag">you predicted</span><p>${esc(p.insight_vs_capacity.predicted)}</p></div>
105
+ <div><span class="tag alt">what happened</span><p>${esc(p.insight_vs_capacity.happened)}</p></div>
106
+ </div>
107
+ <p class="small">${esc(p.insight_vs_capacity.implication || '')}</p>
108
+ ${quotes(p.insight_vs_capacity.evidence)}`);
109
+
110
+ const dsplit = deviceSplit(B);
111
+ if (dsplit) add('chart', `
112
+ <div class="eyebrow">two different minds</div>
113
+ ${dsplit}
114
+ <p class="small">Desktop is where you produce. Your phone is where you think, look things up, and search at night.</p>
115
+ ${cite('unique search queries, split by device')}`);
116
+
117
+ const rec = recurringTable(NT.recurring);
118
+ if (rec) add('data', `
119
+ <div class="eyebrow">what you keep not finishing</div>
120
+ ${rec}
121
+ ${cite(`from ${(NT.count || 0).toLocaleString()} notes — items rewritten on 3+ separate days`)}`);
122
+
123
+ const wins = p.unseen_wins || [];
124
+ if (wins.length) add('list', `
125
+ <div class="eyebrow">what you've been discounting</div>
126
+ ${p.distortion?.evidence ? `<p class="small" style="margin-bottom:14px">${esc(p.distortion.evidence)}</p>` : ''}
127
+ <ul class="ticks">${wins.slice(0, 5).map(x =>
128
+ `<li><b>${esc(x.label)}</b><span>${esc(x.detail)}</span></li>`).join('')}</ul>`);
129
+
130
+ if (p.human_thing?.thesis) add('human', `
131
+ <div class="eyebrow">two things that rhyme</div>
132
+ <h2 class="mid">${esc(p.human_thing.thesis)}</h2>
133
+ <div class="vs">
134
+ <div><span class="tag">privately</span>${(p.human_thing.private || []).slice(0, 3).map(x => `<p>${esc(x)}</p>`).join('')}</div>
135
+ <div><span class="tag alt">publicly</span>${(p.human_thing.public || []).slice(0, 3).map(x => `<p>${esc(x)}</p>`).join('')}</div>
136
+ </div>`);
137
+
138
+ const acts = p.actions || [];
139
+ if (acts.length) add('list', `
140
+ <div class="eyebrow">two levers: reduce load, or hand it off</div>
141
+ <ul class="ticks num">${acts.slice(0, 4).map((a, i) =>
142
+ `<li><b>${String(i + 1).padStart(2, '0')} ${esc(a.title)}</b><span>${esc(a.body)}</span></li>`).join('')}</ul>`);
143
+
144
+ add('end', `
145
+ <h2 class="big">that's your year.</h2>
146
+ ${has(p.closing) ? `<p class="lede">${esc(p.closing)}</p>` : ''}
147
+ <div class="actions">
148
+ <button class="btn" data-read>read the full thing</button>
149
+ <button class="btn ghost" onclick="window.print()">save as PDF</button>
150
+ </div>
151
+ <p class="cite">npx you-wrapped</p>`);
152
+
153
+ return cards;
154
+ }
155
+
156
+ /**
157
+ * The long read. Cards are for swiping and sharing; this is where nothing gets
158
+ * dropped — every field the model returned, at full length.
159
+ */
160
+ function longForm(p, stats, shareMode) {
161
+ const S = (eyebrow, title, body) => !body ? '' : `
162
+ <section class="rs"><div class="rw">
163
+ <div class="reyebrow">${esc(eyebrow)}</div>
164
+ ${title ? `<h2>${esc(title)}</h2>` : ''}
165
+ ${body}
166
+ </div></section>`;
167
+ const paras = a => (a || []).map(x => `<p>${esc(x)}</p>`).join('');
168
+ const grid = items => !items?.length ? '' : `<div class="rgrid">${items.map(x =>
169
+ `<div class="rcell"><div class="rk">${esc(x.label)}</div><p>${esc(x.detail || x.evidence)}</p></div>`).join('')}</div>`;
170
+
171
+ return `
172
+ <div id="read" hidden>
173
+ <header class="rhead"><div class="rw">
174
+ <button class="btn back" data-story>← back to cards</button>
175
+ ${shareMode ? '<span class="rbadge">share copy · identifiers removed</span>' : ''}
176
+ <h1>You, Wrapped</h1>
177
+ <p class="rsub">${esc(stats.line || '')}</p>
178
+ ${has(p.headline_quote) ? `<blockquote class="rquote">“${esc(p.headline_quote)}”
179
+ <cite>${esc(p.headline_quote_source || '')}</cite></blockquote>` : ''}
180
+ </div></header>
181
+
182
+ ${S('the one thing', p.one_trait?.name, p.one_trait ? `
183
+ <p class="rlede">${esc(p.one_trait.thesis)}</p>${grid(p.one_trait.domains)}` : '')}
184
+
185
+ ${S('the measurement', p.scissor?.thesis, p.scissor ? `
186
+ <p>${esc(p.scissor.detail || '')}</p>
187
+ ${(p.scissor.numbers || []).length ? `<div class="rstats">${p.scissor.numbers.map(x =>
188
+ `<div class="rstat"><div class="rn">${esc(x.from)} → ${esc(x.to)}</div>
189
+ <div class="rl">${esc(x.label)}</div></div>`).join('')}</div>` : ''}` : '')}
190
+
191
+ ${S('the collision', p.collision?.date ? `${p.collision.date} — ${p.collision.thesis}` : '', p.collision ? `
192
+ <p>${esc(p.collision.detail || '')}</p>
193
+ ${p.collision.verification ? `<div class="rpull"><p>${esc(p.collision.verification)}</p></div>` : ''}` : '')}
194
+
195
+ ${S('why awareness isn’t the fix', p.insight_vs_capacity?.thesis, p.insight_vs_capacity ? `
196
+ <div class="rcols">
197
+ <div><h3>What you predicted</h3><p>${esc(p.insight_vs_capacity.predicted)}</p></div>
198
+ <div><h3 class="ac">What happened</h3><p>${esc(p.insight_vs_capacity.happened)}</p></div>
199
+ </div>
200
+ <p class="rlede">${esc(p.insight_vs_capacity.implication || '')}</p>` : '')}
201
+
202
+ ${S('hard to see from inside', p.distortion?.thesis, (p.unseen_wins || []).length ? `
203
+ ${p.distortion?.evidence ? `<p>${esc(p.distortion.evidence)}</p>` : ''}
204
+ ${grid(p.unseen_wins)}` : '')}
205
+
206
+ ${S('two things that rhyme', p.human_thing?.thesis, p.human_thing ? `
207
+ <div class="rcols">
208
+ <div><h3>Written privately</h3>${paras(p.human_thing.private)}</div>
209
+ <div><h3 class="ac">Made publicly</h3>${paras(p.human_thing.public)}</div>
210
+ </div>
211
+ <p class="rlede">${esc(p.human_thing.note || '')}</p>` : '')}
212
+
213
+ ${S('what actually moves', 'Two levers. Neither is trying harder.', (p.actions || []).length ? `
214
+ <p>Reduce load, or transfer ownership.</p>
215
+ ${p.actions.map((a, i) => `<div class="ract">
216
+ <div class="rnum">${String(a.n ?? i + 1).padStart(2, '0')}</div>
217
+ <div><h4>${esc(a.title)}</h4><p>${esc(a.body)}</p></div></div>`).join('')}` : '')}
218
+
219
+ ${S('the limits of this', 'What this can’t see.', (p.limits || []).length ? `
220
+ ${grid(p.limits)}
221
+ ${has(p.closing) ? `<p class="rlede">${esc(p.closing)}</p>` : ''}` : '')}
222
+
223
+ <footer class="rfoot"><div class="rw">
224
+ <p>${esc(stats.footer || '')}</p>
225
+ <p>Generated locally by <b>you-wrapped</b>. Your data never left this machine.</p>
226
+ <button class="btn back" data-story>← back to cards</button>
227
+ </div></footer>
228
+ </div>`;
229
+ }
5
230
 
6
231
  export function render(p, { shareMode = false, stats = {} } = {}) {
7
- const S = k => p?.[k] ?? {};
8
- const list = k => Array.isArray(p?.[k]) ? p[k] : [];
232
+ const cards = buildCards(p, stats, shareMode, p.__signals || {});
233
+ const n = cards.length;
9
234
 
10
- const sec = (n, eyebrow, inner) => `
11
- <section><div class="wrap">
12
- <div class="slate rv"><b>${n}</b><span>${esc(eyebrow)}</span></div>
13
- ${inner}
14
- </div></section>`;
235
+ const slides = cards.map((c, i) => {
236
+ const t = THEMES[i % THEMES.length];
237
+ return `<section class="card ${c.kind}" data-i="${i}"
238
+ style="--bg:${t.bg};--fg:${t.fg};--ac:${t.ac}">
239
+ <div class="inner">${c.html}</div></section>`;
240
+ }).join('\n');
15
241
 
16
- const cells = items => `<div class="grid two rv">${items.map(i =>
17
- `<div class="cell"><div class="k">${esc(i.label)}</div><p>${esc(i.detail || i.evidence)}</p></div>`).join('')}</div>`;
242
+ const bars = cards.map((_, i) => `<i data-b="${i}"></i>`).join('');
18
243
 
19
244
  return `<!DOCTYPE html><html lang="en"><head>
20
- <meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
21
- <title>${shareMode ? 'A year, assembled' : 'You — an assembly'}</title>
245
+ <meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
246
+ <title>${shareMode ? 'A year, wrapped' : 'You, Wrapped'}</title>
22
247
  <link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
23
- <link href="https://fonts.googleapis.com/css2?family=Manrope:wght@300;400;700;800&family=Geist+Mono:wght@400&display=swap" rel="stylesheet">
248
+ <link href="https://fonts.googleapis.com/css2?family=Archivo:wght@400;600;800;900&family=Archivo+Narrow:wght@700&family=Geist+Mono:wght@400&display=swap" rel="stylesheet">
24
249
  <style>
25
- :root{--ink:#0B0D10;--panel:#13171C;--line:#242B34;--bone:#E9E5DD;--dim:#868E99;--dimmer:#5A626C;--flare:#FF6B2C;--scope:#5CC8D7;
26
- --display:"Manrope","Helvetica Neue",Arial,sans-serif;--mono:"Geist Mono",ui-monospace,Menlo,monospace}
27
- *{box-sizing:border-box}body{margin:0;background:var(--ink);color:var(--bone);font-family:var(--display);font-weight:300;font-size:17px;line-height:1.62;-webkit-font-smoothing:antialiased}
28
- .wrap{max-width:1080px;margin:0 auto;padding:0 28px}
29
- section{padding:100px 0;border-top:1px solid var(--line)}section:first-of-type{border-top:none}
30
- .slate{font-family:var(--mono);font-size:11.5px;letter-spacing:.16em;text-transform:uppercase;color:var(--dimmer);display:flex;gap:14px;margin-bottom:26px;flex-wrap:wrap}
31
- .slate b{color:var(--flare);font-weight:400}
32
- h2{font-weight:800;font-size:clamp(27px,3.5vw,42px);line-height:1.1;letter-spacing:-.026em;margin:0 0 22px;max-width:22ch}
33
- p{margin:0 0 21px;max-width:64ch;color:#CFCBC3}p.lead{font-size:20.5px;color:var(--bone)}
34
- strong{font-weight:700;color:var(--bone)}em{font-style:normal;color:var(--flare)}
35
- .hero{padding:120px 0 92px}
36
- .quote{font-weight:300;font-size:clamp(29px,5vw,58px);line-height:1.14;letter-spacing:-.03em;margin:0 0 22px;max-width:19ch}
37
- .attrib{font-family:var(--mono);font-size:12.5px;color:var(--dim)}
38
- .rv{opacity:0;transform:translateY(16px);transition:opacity .8s cubic-bezier(.2,.7,.3,1),transform .8s cubic-bezier(.2,.7,.3,1)}.rv.in{opacity:1;transform:none}
39
- .grid{display:grid;gap:1px;background:var(--line);border:1px solid var(--line);border-radius:3px;overflow:hidden;margin:32px 0 10px}
40
- @media(min-width:720px){.grid.two{grid-template-columns:1fr 1fr}}
41
- .cell{background:var(--panel);padding:26px 24px}.cell p{margin:0;font-size:15.5px;color:#BFBBB4}
42
- .cell .k{font-family:var(--mono);font-size:11px;letter-spacing:.14em;text-transform:uppercase;color:var(--dimmer);margin-bottom:12px}
43
- .stats{display:grid;gap:1px;background:var(--line);border:1px solid var(--line);border-radius:3px;overflow:hidden;margin:32px 0;grid-template-columns:repeat(2,1fr)}
44
- @media(min-width:760px){.stats{grid-template-columns:repeat(4,1fr)}}
45
- .stat{background:var(--panel);padding:24px 20px}
46
- .stat .n{font-weight:800;font-size:30px;letter-spacing:-.03em;line-height:1}
47
- .stat .l{font-family:var(--mono);font-size:10.8px;letter-spacing:.1em;text-transform:uppercase;color:var(--dimmer);margin-top:10px;line-height:1.5}
48
- .two-col{display:grid;gap:34px}@media(min-width:860px){.two-col{grid-template-columns:1fr 1fr;gap:52px}}
49
- .two-col h3{font-family:var(--mono);font-size:11.5px;letter-spacing:.14em;text-transform:uppercase;margin:0 0 14px;font-weight:400}
50
- .act{border-top:1px solid var(--line);padding:24px 0;display:grid;gap:16px}@media(min-width:800px){.act{grid-template-columns:56px 1fr}}
51
- .act .num{font-family:var(--mono);font-size:12px;color:var(--flare)}.act p{margin:0;font-size:16px}
52
- .act h4{margin:0 0 8px;font-size:17px;font-weight:700}
53
- .pull{border-left:2px solid var(--flare);padding:4px 0 4px 24px;margin:30px 0;max-width:60ch}
54
- .pull p{font-size:19px;line-height:1.5;color:var(--bone);margin:0}
55
- footer{padding:70px 0 90px;border-top:1px solid var(--line)}
56
- footer p{font-family:var(--mono);font-size:12.5px;color:var(--dimmer);line-height:1.9;max-width:78ch}
57
- .badge{display:inline-block;font-family:var(--mono);font-size:11px;letter-spacing:.12em;text-transform:uppercase;
58
- border:1px solid var(--line);border-radius:2px;padding:5px 10px;color:var(--dim);margin-bottom:24px}
59
- @media(prefers-reduced-motion:reduce){.rv{opacity:1;transform:none;transition:none}}
60
- :focus-visible{outline:2px solid var(--flare);outline-offset:3px}
61
- </style></head><body>
250
+ *{box-sizing:border-box;margin:0;padding:0}
251
+ :root{--pad:clamp(20px,5.5cqw,40px);
252
+ --sans:"Archivo","Helvetica Neue",Arial,sans-serif;
253
+ --narrow:"Archivo Narrow","Archivo",sans-serif;
254
+ --mono:"Geist Mono",ui-monospace,Menlo,monospace}
255
+ html,body{height:100%;overflow:hidden;background:#08080A}
256
+ body{font-family:var(--sans);-webkit-font-smoothing:antialiased;overscroll-behavior:none}
257
+
258
+ /* phone-shaped on desktop, full-bleed on mobile */
259
+ #stage{position:fixed;inset:0;display:grid;place-items:center}
260
+ #deck{position:relative;width:100%;height:100%;overflow:hidden;background:#000;
261
+ container-type:inline-size;container-name:deck}
262
+ @media(min-width:760px){
263
+ #deck{width:min(430px,32vw);height:min(920px,92vh);border-radius:28px;
264
+ box-shadow:0 40px 120px rgba(0,0,0,.6),0 0 0 1px rgba(255,255,255,.08)}
265
+ }
266
+
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);
269
+ opacity:0;pointer-events:none;transform:scale(1.03);
270
+ transition:opacity .42s ease,transform .52s cubic-bezier(.2,.75,.3,1)}
271
+ .card.on{opacity:1;pointer-events:auto;transform:none}
272
+ /* paper grain — keeps flat color from looking like a CSS default */
273
+ .card::after{content:"";position:absolute;inset:0;pointer-events:none;opacity:.055;
274
+ 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
+
278
+ .eyebrow{font-family:var(--mono);font-size:11px;letter-spacing:.18em;text-transform:uppercase;
279
+ opacity:.62;margin-bottom:20px}
280
+ .giant{overflow-wrap:anywhere;font-family:var(--narrow);font-weight:700;font-size:clamp(40px,15.5cqw,92px);
281
+ line-height:.86;letter-spacing:-.02em;text-transform:uppercase}
282
+ .big{overflow-wrap:anywhere;font-weight:900;font-size:clamp(28px,9cqw,50px);line-height:1.02;letter-spacing:-.035em}
283
+ .mid{font-weight:800;font-size:clamp(22px,7cqw,36px);line-height:1.08;letter-spacing:-.028em}
284
+ .date{overflow-wrap:anywhere;font-family:var(--narrow);font-weight:700;font-size:clamp(34px,13cqw,68px);line-height:.92;
285
+ text-transform:uppercase;letter-spacing:-.01em;color:var(--ac)}
286
+ blockquote{font-weight:600;font-size:clamp(21px,6.6cqw,34px);line-height:1.16;letter-spacing:-.026em}
287
+ blockquote::before{content:"“"}blockquote::after{content:"”"}
288
+ .lede{font-size:clamp(15px,4.2cqw,18px);line-height:1.5;margin-top:20px;opacity:.92;max-width:34ch}
289
+ .sub{font-family:var(--mono);font-size:12.5px;line-height:1.7;margin-top:26px;opacity:.72;max-width:32ch}
290
+ .small{font-size:14.5px;line-height:1.55;margin-top:16px;opacity:.72;max-width:38ch}
291
+ .cite{font-family:var(--mono);font-size:11.5px;margin-top:18px;opacity:.6}
292
+ .hint{position:absolute;left:var(--pad);bottom:calc(var(--pad) + 4px);font-family:var(--mono);font-size:11px;
293
+ letter-spacing:.16em;text-transform:uppercase;opacity:.5;animation:pulse 2.4s ease-in-out infinite}
294
+ @keyframes pulse{0%,100%{opacity:.28}50%{opacity:.72}}
295
+
296
+ .figures{display:grid;gap:26px;margin:8px 0 4px}
297
+ .fig-n{font-family:var(--narrow);font-weight:700;font-size:clamp(32px,11.5cqw,58px);
298
+ line-height:1;letter-spacing:-.02em;display:flex;align-items:center;gap:10px;flex-wrap:wrap}
299
+ .arrow{color:var(--ac);font-size:.7em}
300
+ .fig-l{font-family:var(--mono);font-size:11.5px;letter-spacing:.06em;opacity:.72;margin-top:8px;
301
+ text-transform:uppercase;max-width:28ch;line-height:1.5}
62
302
 
63
- <section class="hero"><div class="wrap">
64
- ${shareMode ? '<div class="badge rv">Share copy · names and identifiers removed</div>' : ''}
65
- <div class="slate rv"><span>Assembly</span><span>·</span><span>${esc(stats.line || '')}</span></div>
66
- <p class="quote rv">“${esc(S('headline_quote') || p.headline_quote || '')}”</p>
67
- <p class="attrib rv">${esc(p.headline_quote_source || '')}</p>
68
- </div></section>
69
-
70
- ${p.one_trait ? sec('01', 'the forest', `
71
- <h2 class="rv">${esc(p.one_trait.name)}</h2>
72
- <p class="lead rv">${esc(p.one_trait.thesis)}</p>
73
- ${cells((p.one_trait.domains || []).map(d => ({ label: d.label, detail: d.evidence })))}`) : ''}
74
-
75
- ${p.scissor ? sec('02', 'the measurement', `
76
- <h2 class="rv">${esc(p.scissor.thesis)}</h2>
77
- <p class="rv">${esc(p.scissor.detail)}</p>
78
- <div class="stats rv">${(p.scissor.numbers || []).map(n =>
79
- `<div class="stat"><div class="n">${esc(n.from)} ${esc(n.to)}</div><div class="l">${esc(n.label)}</div></div>`).join('')}</div>`) : ''}
80
-
81
- ${p.collision ? sec('03', 'the collision', `
82
- <h2 class="rv">${esc(p.collision.date)} — ${esc(p.collision.thesis)}</h2>
83
- <p class="rv">${esc(p.collision.detail)}</p>
84
- <div class="pull rv"><p>${esc(p.collision.verification)}</p></div>`) : ''}
85
-
86
- ${p.insight_vs_capacity ? sec('04', 'why awareness isn’t the fix', `
87
- <h2 class="rv">${esc(p.insight_vs_capacity.thesis)}</h2>
88
- <div class="two-col">
89
- <div class="rv"><h3 style="color:var(--dim)">What you predicted</h3><p>${esc(p.insight_vs_capacity.predicted)}</p></div>
90
- <div class="rv"><h3 style="color:var(--flare)">What happened</h3><p>${esc(p.insight_vs_capacity.happened)}</p></div>
91
- </div>
92
- <p class="rv" style="margin-top:36px">${esc(p.insight_vs_capacity.implication)}</p>`) : ''}
93
-
94
- ${list('unseen_wins').length ? sec('05', 'hard to see from inside', `
95
- <h2 class="rv">${esc(p.distortion?.thesis || 'What the same year looks like from outside.')}</h2>
96
- ${p.distortion?.evidence ? `<p class="rv">${esc(p.distortion.evidence)}</p>` : ''}
97
- ${cells(list('unseen_wins'))}`) : ''}
98
-
99
- ${p.human_thing ? sec('06', 'the thing you may not have noticed', `
100
- <h2 class="rv">${esc(p.human_thing.thesis)}</h2>
101
- <div class="two-col">
102
- <div class="rv"><h3 style="color:var(--dim)">Written privately</h3>${(p.human_thing.private || []).map(x => `<p>${esc(x)}</p>`).join('')}</div>
103
- <div class="rv"><h3 style="color:var(--flare)">Made publicly</h3>${(p.human_thing.public || []).map(x => `<p>${esc(x)}</p>`).join('')}</div>
104
- </div>
105
- <p class="rv" style="margin-top:36px">${esc(p.human_thing.note)}</p>`) : ''}
106
-
107
- ${list('actions').length ? sec('07', 'what actually moves', `
108
- <h2 class="rv">Two levers. Neither is trying harder.</h2>
109
- <p class="rv">Reduce load, or transfer ownership.</p>
110
- <div class="rv">${list('actions').map(a =>
111
- `<div class="act"><div class="num">${String(a.n).padStart(2, '0')}</div><div><h4>${esc(a.title)}</h4><p>${esc(a.body)}</p></div></div>`).join('')}</div>`) : ''}
112
-
113
- ${list('limits').length ? sec('08', 'the limits of this', `
114
- <h2 class="rv">What this can’t see.</h2>
115
- ${cells(list('limits'))}
116
- ${p.closing ? `<p class="rv" style="margin-top:32px">${esc(p.closing)}</p>` : ''}`) : ''}
117
-
118
- <footer><div class="wrap"><p>${esc(stats.footer || '')}</p>
119
- <p>Generated locally by <strong>you-wrapped</strong>. Your data never left this machine.</p></div></footer>
303
+ .ticks{list-style:none;display:grid;gap:18px;margin-top:4px}
304
+ .ticks li{border-top:1.5px solid currentColor;padding-top:12px;opacity:.95}
305
+ .ticks b{display:block;font-size:15.5px;font-weight:800;letter-spacing:-.012em;margin-bottom:5px}
306
+ .ticks span{display:block;font-size:14px;line-height:1.45;opacity:.78}
307
+ .ticks.num b{font-family:var(--mono);font-weight:400;font-size:14px}
308
+
309
+ .vs{display:grid;gap:20px;margin-top:20px}
310
+ .vs>div{border-left:3px solid var(--ac);padding-left:14px}
311
+ .vs p{font-size:15px;line-height:1.45;margin-top:6px;opacity:.9}
312
+ .tag{font-family:var(--mono);font-size:10.5px;letter-spacing:.16em;text-transform:uppercase;opacity:.65}
313
+ .tag.alt{color:var(--ac);opacity:1}
314
+
315
+ .actions{display:flex;gap:10px;flex-wrap:wrap;margin-top:28px}
316
+ .btn{font-family:var(--mono);font-size:12px;letter-spacing:.1em;text-transform:uppercase;
317
+ background:var(--fg);color:var(--bg);border:0;border-radius:999px;padding:13px 20px;
318
+ cursor:pointer;text-decoration:none;display:inline-block}
319
+ .btn.ghost{background:transparent;color:var(--fg);box-shadow:inset 0 0 0 1.5px currentColor}
320
+
321
+ /* story progress bars */
322
+ #bars{position:absolute;top:12px;left:12px;right:12px;display:flex;gap:4px;z-index:9}
323
+ #bars i{flex:1;height:2.5px;background:rgba(255,255,255,.28);border-radius:2px;overflow:hidden}
324
+ #bars i.done{background:rgba(255,255,255,.9)}
325
+ #bars i.now{background:rgba(255,255,255,.9)}
326
+ #deck.dark #bars i{background:rgba(0,0,0,.22)}
327
+ #deck.dark #bars i.done,#deck.dark #bars i.now{background:rgba(0,0,0,.82)}
328
+
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%}
331
+ #badge{position:absolute;bottom:10px;left:0;right:0;text-align:center;z-index:9;
332
+ font-family:var(--mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;
333
+ color:rgba(255,255,255,.4)}
334
+ @media(max-width:759px){#badge{display:none}}
335
+
336
+ /* ── evidence on cards ── */
337
+ .src{font-family:var(--mono);font-size:10px;letter-spacing:.08em;text-transform:uppercase;
338
+ opacity:.5;margin-top:14px;line-height:1.55}
339
+ .ev{display:grid;gap:12px;margin-top:18px}
340
+ .evq{border-left:2.5px solid var(--ac);padding-left:13px;font-size:14.5px;line-height:1.42;
341
+ font-weight:500;opacity:.95}
342
+ .evq::before{content:""}.evq::after{content:"”"}
343
+ .evq cite{display:block;font-family:var(--mono);font-size:10px;font-style:normal;font-weight:400;
344
+ letter-spacing:.06em;opacity:.55;margin-top:7px}
345
+ .card.chart-card .inner,.card.data .inner{padding-top:4px}
346
+ ${CHART_CSS}
347
+
348
+ /* ── long-read mode ── */
349
+ body.reading{overflow:auto;background:#0E0E12}
350
+ body.reading #stage,body.reading #badge{display:none}
351
+ #read{color:#E9E5DD;font-weight:300;font-size:17px;line-height:1.62;padding-bottom:60px}
352
+ .rw{max-width:820px;margin:0 auto;padding:0 26px}
353
+ .rhead{padding:44px 0 52px}
354
+ .rhead h1{font-family:var(--narrow);font-weight:700;font-size:clamp(44px,9vw,76px);
355
+ line-height:.95;text-transform:uppercase;margin:26px 0 12px}
356
+ .rsub{font-family:var(--mono);font-size:12.5px;opacity:.6}
357
+ .rquote{font-size:clamp(21px,4.4vw,29px);line-height:1.24;font-weight:600;
358
+ letter-spacing:-.02em;margin-top:32px;border-left:3px solid #E8FF43;padding-left:20px}
359
+ .rquote cite{display:block;font-family:var(--mono);font-size:11.5px;font-weight:400;
360
+ font-style:normal;opacity:.55;margin-top:12px;letter-spacing:.04em}
361
+ .rs{padding:52px 0;border-top:1px solid #242B34}
362
+ .reyebrow{font-family:var(--mono);font-size:11px;letter-spacing:.18em;text-transform:uppercase;
363
+ color:#E8FF43;opacity:.8;margin-bottom:16px}
364
+ #read h2{font-weight:900;font-size:clamp(25px,4.6vw,38px);line-height:1.08;
365
+ letter-spacing:-.03em;margin-bottom:18px;max-width:24ch}
366
+ #read h3{font-family:var(--mono);font-size:11.5px;letter-spacing:.14em;text-transform:uppercase;
367
+ font-weight:400;opacity:.6;margin-bottom:10px}
368
+ #read h3.ac{color:#E8FF43;opacity:1}
369
+ #read h4{font-size:17px;font-weight:800;margin-bottom:6px}
370
+ #read p{margin-bottom:16px;max-width:66ch;color:#CFCBC3}
371
+ .rlede{font-size:19.5px;color:#E9E5DD}
372
+ .rgrid{display:grid;gap:1px;background:#242B34;border:1px solid #242B34;border-radius:3px;
373
+ overflow:hidden;margin-top:26px}
374
+ @media(min-width:700px){.rgrid{grid-template-columns:1fr 1fr}}
375
+ .rcell{background:#13171C;padding:24px 22px}
376
+ .rcell p{margin:0;font-size:15px;color:#BFBBB4}
377
+ .rk{font-family:var(--mono);font-size:11px;letter-spacing:.14em;text-transform:uppercase;
378
+ opacity:.5;margin-bottom:10px}
379
+ .rstats{display:grid;gap:1px;background:#242B34;border:1px solid #242B34;border-radius:3px;
380
+ overflow:hidden;margin:26px 0;grid-template-columns:repeat(auto-fit,minmax(180px,1fr))}
381
+ .rstat{background:#13171C;padding:22px 20px}
382
+ .rn{font-family:var(--narrow);font-weight:700;font-size:30px;letter-spacing:-.02em}
383
+ .rl{font-family:var(--mono);font-size:10.8px;letter-spacing:.1em;text-transform:uppercase;
384
+ opacity:.5;margin-top:9px;line-height:1.5}
385
+ .rcols{display:grid;gap:30px;margin:24px 0}
386
+ @media(min-width:820px){.rcols{grid-template-columns:1fr 1fr;gap:48px}}
387
+ .rpull{border-left:3px solid #E8FF43;padding-left:20px;margin:24px 0}
388
+ .rpull p{font-size:19px;color:#E9E5DD;margin:0}
389
+ .ract{display:grid;gap:14px;border-top:1px solid #242B34;padding:22px 0}
390
+ @media(min-width:700px){.ract{grid-template-columns:52px 1fr}}
391
+ .rnum{font-family:var(--mono);font-size:12px;color:#E8FF43}
392
+ .ract p{margin:0;font-size:16px}
393
+ .rfoot{padding:52px 0;border-top:1px solid #242B34}
394
+ .rfoot p{font-family:var(--mono);font-size:12.5px;opacity:.45;line-height:1.9}
395
+ .rbadge{font-family:var(--mono);font-size:10.5px;letter-spacing:.12em;text-transform:uppercase;
396
+ border:1px solid #242B34;border-radius:2px;padding:5px 10px;opacity:.6;margin-left:10px}
397
+ .btn.back{background:transparent;color:#E9E5DD;box-shadow:inset 0 0 0 1.5px #333A44;margin-bottom:8px}
398
+
399
+ @media(prefers-reduced-motion:reduce){.card{transition:none}.hint{animation:none}}
400
+ @media print{
401
+ html,body{height:auto;overflow:visible}#stage{position:static}
402
+ #deck{width:100%;height:auto;box-shadow:none;border-radius:0}
403
+ .card{position:static;opacity:1;transform:none;page-break-after:always;min-height:100vh}
404
+ #bars,.tapzone,#badge,.hint,.actions,.btn{display:none}
405
+ body.reading .card{display:none}
406
+ }
407
+ </style></head><body>
408
+ <div id="stage"><div id="deck">
409
+ <div id="bars">${bars}</div>
410
+ ${slides}
411
+ <div class="hint" id="hint">tap or press → to begin</div>
412
+ <div class="tapzone l" data-nav="-1" aria-hidden="true"></div>
413
+ <div class="tapzone r" data-nav="1" aria-hidden="true"></div>
414
+ </div></div>
415
+ <div id="badge">← → or tap · ${n} cards · press R to read</div>
416
+ ${longForm(p, stats, shareMode)}
120
417
  <script>
121
- const io=new IntersectionObserver(e=>e.forEach(x=>{if(x.isIntersecting){x.target.classList.add('in');io.unobserve(x.target)}}),{threshold:.14,rootMargin:'0px 0px -8% 0px'});
122
- document.querySelectorAll('.rv').forEach((el,i)=>{el.style.transitionDelay=Math.min(i,3)*70+'ms';io.observe(el)});
123
- addEventListener('load',()=>document.querySelectorAll('.hero .rv').forEach((el,i)=>setTimeout(()=>el.classList.add('in'),120+i*130)));
418
+ const N=${n}, deck=document.getElementById('deck');
419
+ const cards=[...document.querySelectorAll('.card')], bars=[...document.querySelectorAll('#bars i')];
420
+ let i=0;
421
+ // bar contrast has to follow the card, not the page
422
+ const LIGHT=new Set(cards.map((c,k)=>{
423
+ const bg=getComputedStyle(c).getPropertyValue('--bg').trim();
424
+ const h=bg.replace('#','');
425
+ const v=parseInt(h.slice(0,2),16)*.299+parseInt(h.slice(2,4),16)*.587+parseInt(h.slice(4,6),16)*.114;
426
+ return v>140?k:-1;
427
+ }).filter(k=>k>=0));
428
+ function show(k){
429
+ const h=document.getElementById('hint'); if(h) h.style.display = k===0 ? '' : 'none';
430
+ i=Math.max(0,Math.min(N-1,k));
431
+ cards.forEach((c,x)=>c.classList.toggle('on',x===i));
432
+ bars.forEach((b,x)=>{b.classList.toggle('done',x<i);b.classList.toggle('now',x===i)});
433
+ deck.classList.toggle('dark',LIGHT.has(i));
434
+ }
435
+ document.querySelectorAll('[data-nav]').forEach(z=>
436
+ z.addEventListener('click',()=>show(i+ +z.dataset.nav)));
437
+ // story ⇄ long read
438
+ const readEl=document.getElementById('read');
439
+ function setMode(reading){
440
+ document.body.classList.toggle('reading',reading);
441
+ readEl.hidden=!reading;
442
+ if(reading) window.scrollTo(0,0);
443
+ }
444
+ document.querySelectorAll('[data-read]').forEach(b=>b.addEventListener('click',()=>setMode(true)));
445
+ document.querySelectorAll('[data-story]').forEach(b=>b.addEventListener('click',()=>setMode(false)));
446
+
447
+ addEventListener('keydown',e=>{
448
+ const reading=document.body.classList.contains('reading');
449
+ if(e.key==='r'||e.key==='R'){setMode(!reading);return}
450
+ if(reading){ if(e.key==='Escape')setMode(false); return }
451
+ if(e.key==='ArrowRight'||e.key===' ') {e.preventDefault();show(i+1)}
452
+ if(e.key==='ArrowLeft') {e.preventDefault();show(i-1)}
453
+ });
454
+ let x0=null;
455
+ deck.addEventListener('touchstart',e=>x0=e.touches[0].clientX,{passive:true});
456
+ 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;
459
+ },{passive:true});
460
+ show(0);
124
461
  </script></body></html>`;
125
462
  }
package/src/scrub.mjs CHANGED
@@ -50,12 +50,22 @@ export function coarsenPlaces(clusters = []) {
50
50
  }
51
51
 
52
52
  const CITIES = [
53
- ['San Francisco', 37.77, -122.42, .4], ['Los Angeles', 34.05, -118.30, .6],
54
- ['New York', 40.74, -73.98, .4], ['Chicago', 41.88, -87.63, .6],
55
- ['Seattle', 47.61, -122.33, .4], ['Austin', 30.27, -97.74, .4],
56
- ['London', 51.51, -0.13, .4], ['Paris', 48.86, 2.35, .4],
57
- ['Istanbul', 41.01, 28.98, .6], ['Miami', 25.77, -80.19, .4],
58
- ['Tokyo', 35.68, 139.69, .5], ['Berlin', 52.52, 13.40, .4],
53
+ // Major metros, for coarsening coordinates to a city label in share mode.
54
+ ['New York', 40.71, -74.01, .5], ['Los Angeles', 34.05, -118.24, .6],
55
+ ['Chicago', 41.88, -87.63, .5], ['San Francisco', 37.77, -122.42, .4],
56
+ ['Seattle', 47.61, -122.33, .4], ['Austin', 30.27, -97.74, .4],
57
+ ['Denver', 39.74, -104.99, .4], ['Boston', 42.36, -71.06, .4],
58
+ ['Miami', 25.76, -80.19, .4], ['Atlanta', 33.75, -84.39, .4],
59
+ ['Toronto', 43.65, -79.38, .4], ['Mexico City', 19.43, -99.13, .5],
60
+ ['London', 51.51, -0.13, .5], ['Paris', 48.86, 2.35, .4],
61
+ ['Berlin', 52.52, 13.40, .4], ['Amsterdam', 52.37, 4.90, .3],
62
+ ['Madrid', 40.42, -3.70, .4], ['Rome', 41.90, 12.50, .4],
63
+ ['Lisbon', 38.72, -9.14, .3], ['Istanbul', 41.01, 28.98, .5],
64
+ ['Dubai', 25.20, 55.27, .4], ['Mumbai', 19.08, 72.88, .5],
65
+ ['Bangalore', 12.97, 77.59, .4], ['Singapore', 1.35, 103.82, .3],
66
+ ['Hong Kong', 22.32, 114.17, .3], ['Tokyo', 35.68, 139.69, .6],
67
+ ['Seoul', 37.57, 126.98, .4], ['Sydney', -33.87, 151.21, .4],
68
+ ['São Paulo', -23.55, -46.63, .5], ['Lagos', 6.52, 3.38, .4],
59
69
  ];
60
70
  export function labelFor(lat, lon) {
61
71
  for (const [name, a, b, r] of CITIES) if (Math.abs(lat - a) <= r && Math.abs(lon - b) <= r) return name;
package/src/setup.mjs CHANGED
@@ -77,6 +77,32 @@ export function hostApp() {
77
77
  return 'your terminal app';
78
78
  }
79
79
 
80
+ /** Is the Claude Code CLI on PATH? */
81
+ export function hasClaudeCLI() {
82
+ try { execSync('command -v claude', { stdio: 'pipe' }); return true; } catch { return false; }
83
+ }
84
+
85
+ /** Are we already running *inside* a Claude Code session? Don't nest one. */
86
+ export function insideClaude() {
87
+ return Boolean(process.env.CLAUDECODE || process.env.CLAUDE_CODE);
88
+ }
89
+
90
+ /**
91
+ * Hand off to a fresh Claude Code session with the work already queued.
92
+ * The new process starts after the skill is on disk, so it loads it.
93
+ */
94
+ export function launchClaude(outDir) {
95
+ const prompt = [
96
+ 'Do a you-wrapped on me.',
97
+ `The local extraction has already run — signals are in ${outDir}/signals.json`,
98
+ `and my own words are in ${outDir}/evidence.local.md (read it, never copy it anywhere).`,
99
+ 'Follow the you-wrapped skill: sweep any connected services I have (Granola, Slack,',
100
+ 'Gmail, Calendar, Drive) to cross-reference, then work the six-step method and',
101
+ `render the portrait to ${outDir}.`,
102
+ ].join(' ');
103
+ execFileSync('claude', [prompt], { stdio: 'inherit' });
104
+ }
105
+
80
106
  export function nodeOk() {
81
107
  const [maj, min] = process.versions.node.split('.').map(Number);
82
108
  return maj > 22 || (maj === 22 && min >= 5);