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 +3 -0
- package/SKILL.md +40 -12
- package/bin/cli.mjs +32 -5
- package/package.json +1 -1
- package/src/charts.mjs +124 -0
- package/src/discover.mjs +86 -0
- package/src/prompt.mjs +9 -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,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
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
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/discover.mjs
ADDED
|
@@ -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.
|
|
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
|
|
@@ -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
|
-
//
|
|
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);
|