you-wrapped 0.1.0 → 0.3.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,12 +82,47 @@ 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
96
89
  copy before posting it — they should verify their own scrub.
97
90
 
91
+ ## Phase 5 — offer to go deeper (every machine is different)
92
+
93
+ The standard sweep is a floor, not a ceiling. `signals.json` contains an `unexplored`
94
+ block listing sources that were **detected on this machine but deliberately not read** —
95
+ plus a folder inventory. Use it to make a *specific* offer, never a generic menu.
96
+
97
+ After delivering the portrait, tell them what else is available and what it would add.
98
+ Name the actual apps found, with a one-line reason each:
99
+
100
+ > "There's more here if you want it. I found Obsidian (24 MB of notes), WhatsApp,
101
+ > Signal, a Lightroom catalog, and 175 MB of your past Claude sessions. Your notes
102
+ > would sharpen the private-voice read; WhatsApp is often where family lives; the
103
+ > Claude sessions show what you actually ask for help with. Want any of those?"
104
+
105
+ Also offer, when relevant:
106
+
107
+ - **Other connectors** they have but you didn't sweep — Linear, GitHub, Notion, Figma,
108
+ Spotify, Strava, banking exports. Check what's actually available before offering.
109
+ - **Deeper on one source** rather than wider: a single relationship over five years, one
110
+ project end-to-end, or their sent mail alone.
111
+ - **A specific question** they bring. The best second pass is usually answering something
112
+ they've wondered about themselves, not more breadth.
113
+ - **Their creative output** — the films, repos, designs, or writing they actually made.
114
+ Data about a person is not the same as the work they produced.
115
+
116
+ Rules for going deeper:
117
+
118
+ - **Read only what they ask for.** Detection is not permission. Never open a newly
119
+ discovered source without an explicit yes.
120
+ - **Say what each source would and wouldn't tell you** before they decide.
121
+ - **Re-render when you learn something material** — a portrait that ignores a major new
122
+ source is worse than one that never had it.
123
+ - If a source is encrypted at rest (Granola, Signal, some note apps), say so plainly and
124
+ point at the connector or export path instead of attempting to break it.
125
+
98
126
  ---
99
127
 
100
128
  ## Rules
package/bin/cli.mjs CHANGED
@@ -7,7 +7,8 @@ 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 { discover } from '../src/discover.mjs';
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);
13
14
  const has = f => args.includes(f);
@@ -34,6 +35,7 @@ ${c.b}you-wrapped${c.x} — a portrait of your year, from the record your Mac al
34
35
  --out DIR where to write (default ./you-wrapped-out)
35
36
  --no-share skip the redacted share copy
36
37
  --yes skip prompts
38
+ --no-launch don't hand off to Claude Code when done
37
39
 
38
40
  ${c.d}Everything runs locally. Nothing is uploaded unless you pass --synthesize,
39
41
  and even then only aggregate signals and short quotes are sent.${c.x}
@@ -79,6 +81,11 @@ if (has('--install-skill')) {
79
81
  // ── render-only path
80
82
  if (has('--render')) {
81
83
  const p = JSON.parse(readFileSync(resolve(val('--render')), 'utf8'));
84
+ // pick up signals.json if it's sitting next to the portrait
85
+ if (!p.__signals) {
86
+ const sp = join(OUT, 'signals.json');
87
+ if (existsSync(sp)) { try { p.__signals = JSON.parse(readFileSync(sp, 'utf8')).sources; } catch {} }
88
+ }
82
89
  mkdirSync(OUT, { recursive: true });
83
90
  writeFileSync(join(OUT, 'wrapped.html'), render(p, { stats: {} }));
84
91
  log(`${c.g}✓${c.x} ${join(OUT, 'wrapped.html')}`);
@@ -144,7 +151,9 @@ photos.ok ? done(`photos — ${photos.count.toLocaleString()} (metadata only, no
144
151
  // ── 2. analyze
145
152
  step('analyzing');
146
153
  const profile = analyze({ messages, notes, browser, photos }, { days: DAYS });
147
- done('analyzed');
154
+ // what else is on this machine that we did NOT read — the agent offers these
155
+ profile.unexplored = discover();
156
+ done(`analyzed — ${profile.unexplored.apps.length} more sources detected but unread`);
148
157
 
149
158
  // ── 3. evidence pack (stays local unless you synthesize)
150
159
  const m = profile.sources.messages || {};
@@ -213,11 +222,13 @@ if (has('--synthesize')) {
213
222
  // ── 5. render
214
223
  if (portrait) {
215
224
  const stats = { line: statLine, footer: `Assembled ${new Date().toISOString().slice(0, 10)} from ${statLine}.` };
216
- writeFileSync(join(OUT, 'wrapped.html'), render(portrait, { stats }));
225
+ // the renderer draws its charts from the real signals, not from the model's prose
226
+ const withSignals = { ...portrait, __signals: profile.sources };
227
+ writeFileSync(join(OUT, 'wrapped.html'), render(withSignals, { stats }));
217
228
 
218
229
  if (!has('--no-share')) {
219
230
  const names = harvestNames(contacts.data, m.topPeople, m.topChats);
220
- const shared = scrubDeep(portrait, names);
231
+ const shared = { ...scrubDeep(portrait, names), __signals: profile.sources };
221
232
  writeFileSync(join(OUT, 'wrapped.share.html'), render(shared, { shareMode: true, stats: { line: statLine, footer: stats.footer } }));
222
233
  }
223
234
  try { execFileSync('open', [join(OUT, 'wrapped.html')]); } catch {}
@@ -233,4 +244,20 @@ ${portrait ? ` ${c.d}wrapped.html${c.x} your portrait
233
244
  ${c.d}wrapped.share.html${c.x} redacted copy, safe to post` : ''}
234
245
 
235
246
  ${c.d}${statLine}${c.x}
236
- `);
247
+ ${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` : ''}`);
248
+
249
+ // ── 6. hand off to Claude Code
250
+ // The skill was written to disk above, and a *new* claude process loads skills
251
+ // at startup — so launching now is what makes this a single command.
252
+ if (!portrait && !has('--no-launch')) {
253
+ if (insideClaude()) {
254
+ log(`${c.d} Already inside Claude Code — tell me "do a you-wrapped on me" and I'll read ${OUT}.${c.x}\n`);
255
+ } else if (hasClaudeCLI()) {
256
+ log(`${c.o} Handing off to Claude Code…${c.x}\n`);
257
+ try { launchClaude(OUT); }
258
+ 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`); }
259
+ } else {
260
+ log(` ${c.b}Next:${c.x} paste ${c.d}${join(OUT, 'prompt.md')}${c.x} into Claude.`);
261
+ log(` ${c.d}Or install Claude Code (claude.ai/code) and rerun for the one-command flow.${c.x}\n`);
262
+ }
263
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "you-wrapped",
3
- "version": "0.1.0",
3
+ "version": "0.3.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
+ `;
@@ -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
@@ -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
@@ -39,6 +40,8 @@ RULES:
39
40
  ownership — those are the only two levers that move a capacity-constrained person.
40
41
  - If the data is thin in some area, say so. An honest gap beats a confident fabrication.
41
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.
42
45
 
43
46
  OUTPUT: a JSON object matching the schema you are given. No prose outside the JSON.`;
44
47
 
@@ -55,10 +58,10 @@ Write the portrait. Return only JSON:
55
58
  {
56
59
  "headline_quote": "their own most revealing line, verbatim",
57
60
  "headline_quote_source": "where and when",
58
- "one_trait": { "name": "...", "thesis": "one sentence", "domains": [{"label":"...","evidence":"..."}] },
59
- "scissor": { "thesis": "...", "detail": "...", "numbers": [{"label":"...","from":"...","to":"..."}] },
61
+ "one_trait": { "name": "...", "thesis": "one sentence", "domains": [{"label":"...","evidence":"..."}], "evidence": [{"quote":"their exact words","source":"iMessage|Notes|search|meeting","date":"YYYY-MM-DD"}] },
62
+ "scissor": { "thesis": "...", "detail": "...", "numbers": [{"label":"...","from":"...","to":"..."}], "evidence": [{"quote":"...","source":"...","date":"..."}] },
60
63
  "collision": { "date": "...", "thesis": "...", "detail": "...", "verification": "which two sources agree" },
61
- "insight_vs_capacity": { "thesis": "...", "predicted": "...", "happened": "...", "implication": "..." },
64
+ "insight_vs_capacity": { "thesis": "...", "predicted": "...", "happened": "...", "implication": "...", "evidence": [{"quote":"...","source":"...","date":"..."}] },
62
65
  "unseen_wins": [{"label":"...","detail":"..."}],
63
66
  "distortion": { "thesis": "...", "evidence": "..." },
64
67
  "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);