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 +3 -0
- package/SKILL.md +5 -12
- package/bin/cli.mjs +27 -3
- package/package.json +1 -1
- package/src/charts.mjs +124 -0
- package/src/prompt.mjs +7 -6
- package/src/render.mjs +448 -111
- package/src/scrub.mjs +16 -6
- package/src/setup.mjs +26 -0
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,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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.
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
//
|
|
2
|
-
//
|
|
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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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
|
|
8
|
-
const
|
|
232
|
+
const cards = buildCards(p, stats, shareMode, p.__signals || {});
|
|
233
|
+
const n = cards.length;
|
|
9
234
|
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
|
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,
|
|
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=
|
|
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
|
-
:
|
|
26
|
-
--
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
.
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
.
|
|
46
|
-
.
|
|
47
|
-
|
|
48
|
-
.
|
|
49
|
-
|
|
50
|
-
.
|
|
51
|
-
.
|
|
52
|
-
|
|
53
|
-
.
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
.
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
:
|
|
61
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
|
122
|
-
document.querySelectorAll('.
|
|
123
|
-
|
|
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
|
-
|
|
54
|
-
['New York', 40.
|
|
55
|
-
['
|
|
56
|
-
['
|
|
57
|
-
['
|
|
58
|
-
['
|
|
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);
|