usehour 0.3.1 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +2 -1
  2. package/hour.mjs +519 -129
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  Your year of building with AI, as Wrapped-style cards.
4
4
 
5
5
  ```
6
- npx usehour@0.3.1
6
+ npx usehour@0.4.1
7
7
  ```
8
8
 
9
9
  - Asks before it reads anything, and lists the folders it will read.
@@ -11,6 +11,7 @@ npx usehour@0.3.1
11
11
  - Counts your habits, and finds a few of your best prompts (anything that looks like a key, password, email or home folder already hidden). You can pick some for your profile and black out words, but not add or change any.
12
12
  - Opens your cards in your browser. Save any card as an image to post it.
13
13
  - Optional: claim usehour.com/@you from the cards page. You see exactly what is sent first. The script itself never connects to the internet.
14
+ - Counts only sessions you started yourself: sub-agents and runs started by scripts or SDKs are left out. `npx usehour@0.4.1 --explain` shows what was counted and what was left out, in your terminal only.
14
15
  - No dependencies, nothing installed.
15
16
 
16
17
  It's one file: read [hour.mjs](https://usehour.com/hour.mjs) before you run it.
package/hour.mjs CHANGED
@@ -8,10 +8,11 @@
8
8
  1. Tells you which folders it will read, and asks before reading anything.
9
9
  2. Reads your Claude Code (~/.claude/projects) and Codex (~/.codex/sessions)
10
10
  session logs on this machine. Read only. Nothing is changed or deleted.
11
- 3. Counts: sessions, hours, models, how you brief and steer the agent.
12
- It also picks a few of your strongest prompts (with anything that looks
13
- like a key, token, password, email or home path already hidden) so you
14
- can choose to put them on your profile.
11
+ 3. Counts: sessions, hours, models, how you brief and steer the agent,
12
+ the file types your agents edited, commits, tools used. Counts and
13
+ labels only: no prompt you wrote, no code, no file contents. The one
14
+ exception is your go-to phrase, if it is three words or fewer (like
15
+ "please proceed"), which you see before anything is sent.
15
16
  4. Writes ./hour-wrapped/index.html (your cards) and wrapped.json (the
16
17
  counts only), then opens the cards in your browser.
17
18
  5. This script never uses the network. The cards page can make one request,
@@ -30,7 +31,7 @@ import { spawn } from "node:child_process";
30
31
  import { Worker, isMainThread, parentPort, workerData } from "node:worker_threads";
31
32
  import { fileURLToPath } from "node:url";
32
33
 
33
- const VERSION = "0.3.1";
34
+ const VERSION = "0.4.1";
34
35
  const API_DEFAULT = "https://usehour.com/api";
35
36
 
36
37
  /* ------------------------------------------------------------------ patterns */
@@ -105,9 +106,21 @@ function promptFacts(ts, text) {
105
106
  spec: SPECIFIC.test(t),
106
107
  explains: kind === "correct" && words >= 12 && EXPLAIN.test(t),
107
108
  pasted: PASTED.test(t),
109
+ work: words >= 5 ? workKind(t) : null,
108
110
  };
109
111
  return f;
110
112
  }
113
+ /* What a prompt is mostly about, as one label. First match wins, so "fix the
114
+ failing test" is fixing, not testing. Only the label leaves this function. */
115
+ const WORK = [
116
+ ["fix", /\b(fix|fixes|bug|bugs|error|errors|broken|breaks|fails?|failing|crash\w*|exception|stack ?trace|not working|doesn'?t work|regression)\b/i],
117
+ ["tidy", /\b(refactor\w*|clean ?up|rename|simplify|restructure|reorgani[sz]e|tidy|dedupe|split (it|this|the)|extract)\b/i],
118
+ ["test", /\b(write (a |the )?tests?|unit tests?|e2e|coverage|test suite|add tests?)\b/i],
119
+ ["understand", /\b(explain|what does|how does|why does|walk me through|understand|investigate|look into|find out|figure out)\b/i],
120
+ ["write", /\b(readme|docs?|documentation|write[- ]?up|blog|article|post|email|copy)\b/i],
121
+ ["build", /\b(add|build|create|implement|make|new|feature|set ?up|scaffold|generate|design)\b/i],
122
+ ];
123
+ function workKind(t) { for (const [k, re] of WORK) if (re.test(t)) return k; return null; }
111
124
  /* How good an example a prompt is: a clear brief with context, limits, a
112
125
  finish line, steps, specifics and a request for proof. Long pastes of code
113
126
  or logs are not examples of prompting, so they are skipped. */
@@ -130,12 +143,21 @@ function cleanPrompt(t) {
130
143
  if (!t || t.startsWith("<") || t.startsWith("# AGENTS.md") || t.startsWith("Caveat:") || t.startsWith("[Request interrupted") || t.startsWith("# Files mentioned")) return null;
131
144
  return t;
132
145
  }
146
+ /* A short fingerprint of a prompt, used only to spot the same message logged
147
+ in two files (a resumed or forked session). The words themselves never
148
+ leave the file parser. */
149
+ function hashText(t) { let h = 0x811c9dc5; for (let i = 0; i < t.length; i++) { h ^= t.charCodeAt(i); h = Math.imul(h, 0x01000193); } return (h >>> 0).toString(36); }
133
150
  let KEEP_EXAMPLES = true;
134
151
  const KEEP_CODEX = /"(session_meta|turn_context|user_message|UserMessage|function_call|custom_tool_call|local_shell_call|token_count|turn_aborted)"|"role":\s*"user"/;
135
152
  const KEEP_CLAUDE = /"type":\s*"(user|assistant)"/;
136
153
  async function parseFile(file, src) {
137
- const s = { src, sub: file.includes(`${path.sep}subagents${path.sep}`), start: null, end: null, cwd: null, models: {}, prompts: [], ids: [], examples: [], patches: 0, cmds: 0, tests: 0, aborts: 0, tokens: 0 };
138
- const seenMsg = new Set(); const seenText = new Set();
154
+ const sub = file.includes(`${path.sep}subagents${path.sep}`);
155
+ const s = { src, sub, how: "typed", via: null, id: path.basename(file, ".jsonl"), parent: sub ? path.basename(path.dirname(path.dirname(file))) : null, start: null, end: null, cwd: null, models: {}, prompts: [], ids: [], examples: [], patches: 0, cmds: 0, tests: 0, aborts: 0, tokens: 0, exts: {}, commits: 0, prs: 0, mcp: {}, skills: {} };
156
+ const seenMsg = new Set(); const seenText = new Map(); let waiting = false, curModel = null, agentTs = null;
157
+ /* Each prompt is "being worked on" from when it was sent until the agent's
158
+ last step before the next prompt. Only the two times are kept. */
159
+ const addPrompt = (f) => { closeLast(); s.prompts.push(f); agentTs = null; };
160
+ const closeLast = () => { const l = s.prompts[s.prompts.length - 1]; if (l && agentTs && agentTs > (l.ts || "")) l.end = agentTs; };
139
161
  const rl = readline.createInterface({ input: fs.createReadStream(file, { encoding: "utf8" }), crlfDelay: Infinity });
140
162
  for await (const line of rl) {
141
163
  if (line.length > 4_000_000) continue;
@@ -147,55 +169,93 @@ async function parseFile(file, src) {
147
169
  if (src === "claude") {
148
170
  if (o.isSidechain) s.sub = true;
149
171
  s.cwd = s.cwd || o.cwd || null;
172
+ /* Started by a script or the Agent SDK (claude -p, an app built on it),
173
+ not typed by the person: kept apart from their own sessions. */
174
+ if (typeof o.entrypoint === "string") { s.via = s.via || o.entrypoint; if (/^sdk/.test(o.entrypoint)) s.how = "script"; }
150
175
  const m = o.message && typeof o.message === "object" ? o.message : {};
151
176
  if (o.type === "user") {
152
177
  const c = m.content;
153
178
  let text = typeof c === "string" ? c : Array.isArray(c) && !c.some((x) => x && x.type === "tool_result") ? c.filter((x) => x && x.type === "text").map((x) => x.text).join("\n") : null;
154
179
  if (text && text.trim().startsWith("[Request interrupted")) s.aborts++;
155
180
  if (o.isMeta) continue;
181
+ /* Messages another agent or a background task put in the user's turn. */
182
+ if (o.origin && typeof o.origin === "object" && o.origin.kind && o.origin.kind !== "human") continue;
156
183
  const u = cleanPrompt(text);
157
- if (u) { const f = promptFacts(ts, u); keepExample(s, f, u); s.prompts.push(f); s.ids.push(o.uuid || null); }
184
+ if (u) { const f = promptFacts(ts, u); keepExample(s, f, u); addPrompt(f); s.ids.push(o.uuid || null); waiting = true; }
158
185
  } else if (o.type === "assistant") {
186
+ if (ts) agentTs = ts;
159
187
  if (m.id && !seenMsg.has(m.id)) {
160
188
  seenMsg.add(m.id);
161
- if (m.model && m.model !== "<synthetic>") s.models[m.model] = (s.models[m.model] || 0) + 1;
189
+ /* One count per prompt the person sent: the model that answered it. */
190
+ if (waiting && m.model && m.model !== "<synthetic>") { s.models[m.model] = (s.models[m.model] || 0) + 1; waiting = false; }
162
191
  const u = m.usage || {};
163
192
  s.tokens += (u.input_tokens || 0) + (u.output_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0);
164
193
  }
165
194
  for (const x of m.content || []) {
166
195
  if (!x || x.type !== "tool_use") continue;
167
- if (["Edit", "Write", "MultiEdit", "NotebookEdit"].includes(x.name)) s.patches++;
168
- else if (x.name === "Bash") { s.cmds++; if (TEST.test((x.input && x.input.command) || "")) s.tests++; }
196
+ const inp = x.input || {};
197
+ if (["Edit", "Write", "MultiEdit", "NotebookEdit"].includes(x.name)) { s.patches++; addExt(s, inp.file_path || inp.notebook_path); }
198
+ else if (x.name === "Bash") { s.cmds++; shellFacts(s, inp.command || ""); }
199
+ else if (x.name === "Skill" && typeof inp.skill === "string") tally(s.skills, inp.skill.replace(/^.*:/, ""));
200
+ else if (/^mcp__/.test(x.name)) tally(s.mcp, x.name.split("__")[1]);
169
201
  }
170
202
  }
171
203
  } else {
172
204
  const p = o.payload || {};
173
205
  if (o.type === "session_meta") {
174
206
  s.cwd = p.cwd || null;
207
+ s.via = typeof p.source === "string" ? p.source : p.source && p.source.subagent ? "subagent" : null;
175
208
  if ((p.source && typeof p.source === "object" && p.source.subagent) || p.parent_thread_id) s.sub = true;
209
+ /* codex exec, the Codex SDK and MCP runs are started by a program. */
210
+ if (p.source === "exec" || p.source === "mcp" || /sdk|exec/i.test(p.originator || "")) s.how = "script";
211
+ if (p.id) s.id = p.id;
212
+ const pm = JSON.stringify(p.source || {}).match(/"parent_thread_id":"([^"]+)"/);
213
+ s.parent = p.parent_thread_id || (pm && pm[1]) || null;
176
214
  } else if (o.type === "turn_context") {
177
- if (p.model) s.models[p.model] = (s.models[p.model] || 0) + 1;
215
+ if (p.model) curModel = p.model;
178
216
  } else if (o.type === "event_msg" && p.type === "turn_aborted") s.aborts++;
179
217
  else if (o.type === "event_msg" && p.type === "token_count") {
180
218
  const t = p.info && p.info.total_token_usage && p.info.total_token_usage.total_tokens;
181
219
  if (t) s.tokens = Math.max(s.tokens, t);
220
+ if (ts) agentTs = ts;
182
221
  }
183
222
  const ut = codexUserText(p);
184
223
  if (ut !== null && (o.type === "event_msg" || o.type === "response_item")) {
185
224
  const u = cleanPrompt(ut);
186
- if (u && !seenText.has(u)) { seenText.add(u); const f = promptFacts(ts, u); keepExample(s, f, u); s.prompts.push(f); s.ids.push(null); }
225
+ /* Codex logs each message twice (as the item and as the event), a
226
+ moment apart. The same words said again later still count. */
227
+ const t0 = Date.parse(ts || "") || 0, prev = u ? seenText.get(u) : undefined;
228
+ if (u && (prev === undefined || Math.abs(t0 - prev) > 5000)) { seenText.set(u, t0); const f = promptFacts(ts, u); keepExample(s, f, u); addPrompt(f); s.ids.push(hashText(u) + "|" + (ts || "").slice(0, 19)); if (curModel) s.models[curModel] = (s.models[curModel] || 0) + 1; }
187
229
  }
188
230
  if (o.type === "response_item" && ["function_call", "custom_tool_call", "local_shell_call"].includes(p.type)) {
189
231
  const name = p.name || "";
190
232
  let arg = p.arguments || p.input || (p.action ? JSON.stringify(p.action) : "");
191
233
  if (typeof arg !== "string") arg = JSON.stringify(arg);
192
- if (name === "apply_patch" || arg.includes("*** Begin Patch")) s.patches++;
193
- else { s.cmds++; if (TEST.test(arg)) s.tests++; }
234
+ if (ts) agentTs = ts;
235
+ if (name === "apply_patch" || arg.includes("*** Begin Patch")) { s.patches++; for (const mm of arg.matchAll(/\*\*\* (?:Update|Add) File: ([^\n\\"]+)/g)) addExt(s, mm[1]); }
236
+ else if (/^mcp__/.test(name)) tally(s.mcp, name.split("__")[1]);
237
+ else { s.cmds++; shellFacts(s, arg); }
194
238
  }
195
239
  }
196
240
  }
241
+ closeLast();
197
242
  return s;
198
243
  }
244
+ /* What a session touched, as labels only: a file's extension (or a few
245
+ telling file names), whether a command ran tests, committed or opened a pull
246
+ request, and which tools (MCP servers, skills) were used. */
247
+ function tally(o, k) { if (k) o[k] = (o[k] || 0) + 1; }
248
+ function addExt(s, f) {
249
+ if (typeof f !== "string" || !f) return;
250
+ const b = f.split(/[\/\\]/).pop().toLowerCase();
251
+ const k = /^dockerfile|^docker-compose/.test(b) ? "docker" : b === "package.json" ? "package.json" : /\.github\/workflows\//.test(f) ? "ci" : (b.match(/\.([a-z0-9]{1,8})$/) || [])[1];
252
+ tally(s.exts, k);
253
+ }
254
+ function shellFacts(s, c) {
255
+ if (TEST.test(c)) s.tests++;
256
+ if (/\bgit\s+(?:-C\s+\S+\s+)?commit\b/.test(c)) s.commits++;
257
+ if (/\bgh\s+pr\s+create\b/.test(c)) s.prs++;
258
+ }
199
259
  /* Keeps the best three example prompts of a file, secrets hidden. */
200
260
  function keepExample(s, f, text) {
201
261
  if (!KEEP_EXAMPLES) return;
@@ -235,15 +295,20 @@ function modelName(id) {
235
295
  const pretty = m.replace(/^claude-/, "Claude ").replace(/-(\d+)-(\d+)(-\d{8})?$/, " $1.$2").replace(/-(\d+)(-\d{8})?$/, " $1").replace(/^gpt/, "GPT");
236
296
  return pretty.replace(/\b(opus|sonnet|haiku|fable)\b/g, (w) => w[0].toUpperCase() + w.slice(1));
237
297
  }
238
- function compute(summaries) {
239
- const seen = new Set();
240
- for (const s of summaries) {
241
- if (s.src !== "claude") continue;
298
+ /* Only sessions the person started count: sub-agents and runs started by a
299
+ script or an SDK are left out of every number (the counts of them are kept
300
+ so --explain can show what was left out). { all: true } counts them too,
301
+ the way versions before 0.4.0 did; --explain uses it to compare. */
302
+ const MAX_AT_ONCE = 24;
303
+ function compute(summaries, opts = {}) {
304
+ const seen = new Set(), rank = (s) => (s.sub ? 2 : s.how === "script" ? 1 : 0);
305
+ /* The same prompt in two files keeps its place in the person's own session. */
306
+ for (const s of [...summaries].sort((a, b) => rank(a) - rank(b))) {
242
307
  const keep = [];
243
308
  s.prompts.forEach((p, i) => { const k = s.ids[i] || `${p.ts}|${p.words}`; if (!seen.has(k)) { seen.add(k); keep.push(p); } });
244
309
  s.prompts = keep;
245
310
  }
246
- const main = summaries.filter((s) => !s.sub && s.prompts.length);
311
+ const main = summaries.filter((s) => !s.sub && (opts.all || s.how !== "script") && s.prompts.length);
247
312
  const P = [];
248
313
  main.forEach((s, si) => { [...s.prompts].sort((a, b) => (a.ts || "").localeCompare(b.ts || "")).forEach((p, i) => { if (p.ts) P.push({ ...p, d: new Date(p.ts), si, i }); }); });
249
314
  if (!P.length) return null;
@@ -262,13 +327,30 @@ function compute(summaries) {
262
327
  const nf = follows.length || 1;
263
328
  const verifySessions = main.filter((s) => s.prompts.some((p) => p.verify)).length;
264
329
  const edit = main.filter((s) => s.patches > 0), editChecked = edit.filter((s) => s.tests > 0).length;
265
- const sorted = P.map((p) => [p.d.getTime(), p.si]).sort((a, b) => a[0] - b[0]);
266
- let best = 0, j = 0; const win = new Map();
267
- for (let i = 0; i < sorted.length; i++) {
268
- win.set(sorted[i][1], (win.get(sorted[i][1]) || 0) + 1);
269
- while (sorted[i][0] - sorted[j][0] > 20 * 60e3) { const k = sorted[j][1]; const v = win.get(k) - 1; v ? win.set(k, v) : win.delete(k); j++; }
270
- best = Math.max(best, win.size);
271
- }
330
+ const peak = peakAtOnce(main);
331
+ /* More than a person could run by hand means something else started them:
332
+ say nothing rather than a wrong number. */
333
+ const atOnce = opts.all || peak.max <= MAX_AT_ONCE ? peak.max : 0;
334
+ /* Everything the agents did for you: your sessions and the sub-agents they
335
+ started (not the sub-agents of scripted runs). */
336
+ const scriptIds = new Set(summaries.filter((s) => !s.sub && s.how === "script").map((s) => s.id));
337
+ const work = opts.all ? summaries : summaries.filter((s) => (s.sub ? !scriptIds.has(s.parent) : s.how !== "script"));
338
+ const sum = (arr, f) => arr.reduce((a, s) => a + f(s), 0);
339
+ const merged = (k) => { const o = {}; work.forEach((s) => { for (const [x, c] of Object.entries(s[k] || {})) o[x] = (o[x] || 0) + c; }); return o; };
340
+ const exts = merged("exts"), edits = Object.values(exts).reduce((a, b) => a + b, 0);
341
+ const shares = (o, n, skip = []) => { const t = Object.entries(o).filter(([k]) => !skip.includes(k)); const tot = t.reduce((a, [, c]) => a + c, 0) || 1; return t.sort((a, b) => b[1] - a[1]).slice(0, n).map(([name, c]) => ({ name, share: c / tot })); };
342
+ const byLabel = (map) => { const o = {}; for (const [e, c] of Object.entries(exts)) { const l = map[e]; if (l) o[l] = (o[l] || 0) + c; } return o; };
343
+ const stack = edits >= 20 ? shares(byLabel(LANG), 5, ["Config", "Markdown"]) : [];
344
+ const areas = edits >= 20 ? shares(byLabel(AREA), 3) : [];
345
+ const wk = {}; P.forEach((p) => { if (p.work) wk[p.work] = (wk[p.work] || 0) + 1; });
346
+ const workMix = Object.values(wk).reduce((a, b) => a + b, 0) >= 20 ? shares(wk, 6).map((x) => ({ key: x.name, share: x.share })) : [];
347
+ const clean = (o, n) => shares(Object.fromEntries(Object.entries(o).filter(([k]) => /^[A-Za-z][\w .-]{1,39}$/.test(k) && !/^ccd_/.test(k))), n).map((x) => x.name);
348
+ const children = new Set(summaries.filter((s) => s.sub && s.parent).map((s) => s.parent));
349
+ const dayList = [...new Set(P.map((p) => p.d.toISOString().slice(0, 10)))].sort();
350
+ let streak = dayList.length ? 1 : 0, run = 1;
351
+ for (let i = 1; i < dayList.length; i++) { run = Date.parse(dayList[i]) - Date.parse(dayList[i - 1]) === 864e5 ? run + 1 : 1; streak = Math.max(streak, run); }
352
+ /* Longest focused stretch: one session worked on with no gap over 20 minutes. */
353
+ const longest = Math.max(0, ...main.map((s) => { let best = 0, a = 0, b = 0; s.prompts.filter((p) => p.ts).map((p) => [Date.parse(p.ts), Math.min(Date.parse(p.end || p.ts), Date.parse(p.ts) + 3 * 3600e3)]).sort((x, y) => x[0] - y[0]).forEach(([x, y]) => { if (x - b > 20 * 60e3) { a = x; b = y; } else b = Math.max(b, y); best = Math.max(best, b - a); }); return best / 60e3; }));
272
354
  const shorts = {}; P.forEach((p) => { if (p.short) shorts[p.short] = (shorts[p.short] || 0) + 1; });
273
355
  const goto = Object.entries(shorts).filter(([, c]) => c >= 5).sort((a, b) => b[1] - a[1])[0] || null;
274
356
  const days = new Set(P.map((p) => p.d.toDateString())).size;
@@ -292,26 +374,56 @@ function compute(summaries) {
292
374
  version: VERSION,
293
375
  generated: new Date().toISOString().slice(0, 10),
294
376
  window: { first: first.toISOString().slice(0, 10), last: last.toISOString().slice(0, 10), activeDays: days },
295
- sessions: { total: main.length, claudeCode: main.filter((s) => s.src === "claude").length, codex: main.filter((s) => s.src === "codex").length, subagentThreads: summaries.filter((s) => s.sub).length },
377
+ sessions: { total: main.length, claudeCode: main.filter((s) => s.src === "claude").length, codex: main.filter((s) => s.src === "codex").length, subagentThreads: summaries.filter((s) => s.sub).length, scripted: summaries.filter((s) => !s.sub && s.how === "script" && s.prompts.length).length },
296
378
  prompts: { total: P.length, medianWords: median(P.map((p) => p.words)), firstPromptMedianWords: median(firsts), followupMedianWords: median(follows.map((p) => p.words)) },
297
379
  followups: { total: follows.length, question: mix.question / nf, correction: mix.correct / nf, goAhead: mix.go / nf, instruction: mix.direct / nf },
298
380
  proof: { sessionsAskedToVerify: verifySessions, codingSessions: edit.length, codingSessionsWithTests: editChecked },
299
- parallelMax: best,
381
+ parallelMax: atOnce,
382
+ atOnceAt: atOnce ? peak.at : null,
300
383
  hours, busiestHour: busiest, weekendShare: P.filter((p) => [0, 6].includes(p.d.getDay())).length / P.length,
301
384
  months: months.map((m) => ({ month: m.key, label: m.label, prompts: m.n })), busiestMonth: busiestMonth.key,
302
385
  models: models.slice(0, 5), anthropicShare: anthropic,
303
386
  projects, stops: main.reduce((a, s) => a + s.aborts, 0),
304
387
  goToPrompt: goto ? { text: goto[0], count: goto[1] } : null,
305
388
  dontKnow: P.filter((p) => p.dontknow).length,
306
- tokens: summaries.reduce((a, s) => a + s.tokens, 0),
389
+ tokens: summaries.filter((s) => opts.all || s.sub || s.how !== "script").reduce((a, s) => a + s.tokens, 0),
307
390
  promptProfile,
391
+ stack, areas, workMix,
392
+ shipped: { commits: sum(work, (s) => s.commits || 0), prs: sum(work, (s) => s.prs || 0) },
393
+ automations: { runs: scriptIds.size && !opts.all ? summaries.filter((s) => !s.sub && s.how === "script" && s.prompts.length).length : 0, projects: new Set(summaries.filter((s) => !s.sub && s.how === "script" && s.cwd).map((s) => s.cwd)).size },
394
+ subagents: { threads: work.filter((s) => s.sub).length, sessionsUsing: main.filter((s) => children.has(s.id)).length },
395
+ actionsPerPrompt: P.length ? Math.round(sum(work, (s) => s.cmds + s.patches) / P.length * 10) / 10 : 0,
396
+ tools: { mcp: clean(merged("mcp"), 6), skills: clean(merged("skills"), 5) },
397
+ style: { longestStretchMin: Math.round(longest), streakDays: streak, nightShare: P.filter((p) => p.d.getHours() >= 22 || p.d.getHours() < 5).length / P.length, earlyShare: P.filter((p) => p.d.getHours() >= 5 && p.d.getHours() < 9).length / P.length },
308
398
  };
309
399
  }
400
+ /* The most agents at work at the same moment. A prompt keeps its session busy
401
+ from when it was sent until the agent's last step before the next prompt
402
+ (at most three hours; a minute if the agent never answered). */
403
+ function peakAtOnce(sessions) {
404
+ const ev = [], spans = new Map();
405
+ for (const s of sessions) {
406
+ const iv = s.prompts.filter((p) => p.ts).map((p) => { const a = Date.parse(p.ts); let b = p.end ? Date.parse(p.end) : a + 60e3; if (!(b > a)) b = a + 60e3; return [a, Math.min(b, a + 3 * 3600e3)]; }).sort((x, y) => x[0] - y[0]);
407
+ const mg = []; for (const [a, b] of iv) { const l = mg[mg.length - 1]; if (l && a <= l[1]) l[1] = Math.max(l[1], b); else mg.push([a, b]); }
408
+ spans.set(s, mg); for (const [a, b] of mg) ev.push([a, 1], [b, -1]);
409
+ }
410
+ ev.sort((x, y) => x[0] - y[0] || x[1] - y[1]);
411
+ let cur = 0, max = 0, at = 0; for (const [t, d] of ev) { cur += d; if (cur > max) { max = cur; at = t; } }
412
+ const active = sessions.filter((s) => spans.get(s).some(([a, b]) => a <= at && at < b));
413
+ const d = new Date(at), two = (n) => String(n).padStart(2, "0");
414
+ return { max, at: max ? `${d.getFullYear()}-${two(d.getMonth() + 1)}-${two(d.getDate())}T${two(d.getHours())}:${two(d.getMinutes())}` : null, active };
415
+ }
416
+ /* File types, as languages and as the kind of thing being built. */
417
+ const LANG = {}, AREA = {};
418
+ [["TypeScript", "ts tsx mts cts"], ["JavaScript", "js jsx mjs cjs"], ["Python", "py"], ["Jupyter", "ipynb"], ["Go", "go"], ["Rust", "rs"], ["Java", "java"], ["Kotlin", "kt kts"], ["Swift", "swift"], ["Ruby", "rb"], ["PHP", "php"], ["C#", "cs"], ["C++", "cpp cc cxx hpp hh"], ["C", "c h"], ["SQL", "sql"], ["CSS", "css scss sass less"], ["HTML", "html htm"], ["Astro", "astro"], ["Vue", "vue"], ["Svelte", "svelte"], ["Dart", "dart"], ["Shell", "sh bash zsh"], ["Markdown", "md mdx"], ["Elixir", "ex exs"], ["Scala", "scala"], ["R", "r"], ["Lua", "lua"], ["Terraform", "tf"], ["Config", "json yaml yml toml ini env docker ci package.json"]]
419
+ .forEach(([l, e]) => e.split(" ").forEach((x) => { LANG[x] = l; }));
420
+ [["Front end", "tsx jsx vue svelte astro css scss sass less html htm"], ["Back end", "ts mts cts js mjs cjs py go rs java rb php cs ex exs scala sql cpp cc c h"], ["Data & ML", "ipynb r"], ["Infra & DevOps", "tf docker ci sh bash zsh yaml yml toml"], ["Mobile", "swift kt kts dart"], ["Docs & writing", "md mdx txt"]]
421
+ .forEach(([l, e]) => e.split(" ").forEach((x) => { AREA[x] = l; }));
310
422
  /* The strongest example prompts across all sessions: no repeats, at most two
311
423
  from any one project, best first. They stay on this computer unless you
312
424
  choose some for your profile. */
313
425
  function pickExamples(summaries, n = 12) {
314
- const all = summaries.filter((s) => !s.sub).flatMap((s) => (s.examples || []).map((e) => ({ ...e, cwd: s.cwd || "" })));
426
+ const all = summaries.filter((s) => !s.sub && s.how !== "script").flatMap((s) => (s.examples || []).map((e) => ({ ...e, cwd: s.cwd || "" })));
315
427
  all.sort((a, b) => b.q - a.q);
316
428
  const seen = new Set(), per = {}, out = [];
317
429
  for (const e of all) {
@@ -324,11 +436,14 @@ function pickExamples(summaries, n = 12) {
324
436
  return out;
325
437
  }
326
438
 
439
+ /* @shared:start ---------------------------------------------------------
440
+ From here to @shared:end is also the site's netlify/lib/cards-page.mjs
441
+ (the profile shows the same cards): node site/scripts/sync-cards.mjs */
327
442
  /* Builder types. Each signal is scored against the level where it becomes
328
443
  notable; the highest score wins, and below 1 everywhere you are a Pair
329
444
  Programmer. The rules are public so anyone can check their own. */
330
445
  const TYPES = {
331
- conductor: { name: "The Conductor", line: "Several agents at once. Long briefs, then you let them run." },
446
+ conductor: { name: "The Conductor", line: "Many sessions going in parallel. Long briefs, then you let them run." },
332
447
  verifier: { name: "The Verifier", line: "Nothing ships until the agent has shown it works." },
333
448
  architect: { name: "The Architect", line: "You brief first and in detail. The agent gets the plan before the work." },
334
449
  interrogator: { name: "The Interrogator", line: "You ask more than you tell. Questions steer the work." },
@@ -339,7 +454,7 @@ function builderType(m) {
339
454
  const verify = m.sessions.total ? m.proof.sessionsAskedToVerify / m.sessions.total : 0;
340
455
  const tested = m.proof.codingSessions ? m.proof.codingSessionsWithTests / m.proof.codingSessions : 0;
341
456
  const scores = {
342
- conductor: m.parallelMax / 6,
457
+ conductor: (m.parallelMax || 0) / (m.atOnceAt !== undefined ? 4 : 6),
343
458
  verifier: Math.max(verify / 0.45, tested / 0.75),
344
459
  architect: m.prompts.firstPromptMedianWords / 80,
345
460
  interrogator: m.followups.question / 0.3,
@@ -354,8 +469,39 @@ const compact = (n) => n >= 1e9 ? (n / 1e9).toFixed(1).replace(/\.0$/, "") + "B"
354
469
  /* Dots for a habit: reaching the notable level fills 7 of 10, the last 3 are
355
470
  for going beyond it. */
356
471
  const habitFill = (h) => Math.min(1, 0.7 * Math.min(1, h.value / h.notable) + 0.3 * Math.max(0, (h.value - h.notable) / (1 - h.notable)));
472
+ /* The Hour score, 0 to 100 (beta). Each part is scored the same way as the
473
+ habit bars: reaching the "good" level gives 70, the rest is for going past
474
+ it. The score is the plain average of the parts. Needs at least 4 parts. */
475
+ function hourScore(m) {
476
+ const parts = (m.promptProfile || []).map((h) => ({ key: h.key, label: h.label, points: Math.round(100 * habitFill(h)) }));
477
+ if (m.proof && m.proof.codingSessions >= 5) parts.push({ key: "tests", label: "Running tests", points: Math.round(100 * habitFill({ value: m.proof.codingSessionsWithTests / m.proof.codingSessions, notable: 0.75 })) });
478
+ if (m.atOnceAt && m.parallelMax >= 1) parts.push({ key: "parallel", label: "Agents at once", points: Math.round(100 * Math.min(1, 0.7 * Math.min(1, m.parallelMax / 4) + 0.3 * Math.max(0, (m.parallelMax - 4) / 8))) });
479
+ if (parts.length < 4) return null;
480
+ return { total: Math.round(parts.reduce((a, x) => a + x.points, 0) / parts.length), parts };
481
+ }
482
+ const WORK_LABEL = { build: ["Building new things", "building new things"], fix: ["Fixing bugs", "fixing bugs"], understand: ["Understanding code", "understanding code"], write: ["Writing and docs", "writing and docs"], tidy: ["Cleaning up code", "cleaning up code"], test: ["Writing tests", "writing tests"] };
483
+ const atOnceWhen = (at) => { if (!at) return ""; const d = new Date(at + ":00Z"); return `${at.slice(11, 16)} on ${d.getUTCDate()} ${d.toLocaleString("en", { month: "short", timeZone: "UTC" })}`; };
484
+ /* The headlines: the four strongest things about how someone works, in plain
485
+ sentences, built only from the numbers. who: null for "you", else a name. */
486
+ function headlines(m, who) {
487
+ const Y = who || "You", v = (you, they) => (who ? they : you), yr = who ? who + "'s" : "Your", H = [];
488
+ const add = (w, t) => H.push([w, t]);
489
+ const hp = (k) => (m.promptProfile || []).find((h) => h.key === k);
490
+ const proof = m.sessions.total ? m.proof.sessionsAskedToVerify / m.sessions.total : 0;
491
+ if (m.prompts.firstPromptMedianWords >= 80) add(m.prompts.firstPromptMedianWords / 80, `${Y} ${v("write", "writes")} detailed briefs: a typical first prompt is ${Math.round(m.prompts.firstPromptMedianWords)} words.`);
492
+ if (proof >= 0.45) add(proof / 0.45, `${Y} ${v("make", "makes")} the agent prove its work in ${pct(proof)}% of sessions.`);
493
+ if (m.atOnceAt && m.parallelMax >= 3) add(m.parallelMax / 3, `${Y} ${v("run", "runs")} up to ${m.parallelMax} agents at the same time.`);
494
+ if (m.stack && m.stack.length) add(1, `${Y} ${v("work", "works")} mostly in ${m.stack[0].name}${m.stack[1] ? " and " + m.stack[1].name : ""}.`);
495
+ if (m.shipped && m.shipped.commits >= 20) add(Math.min(1.6, m.shipped.commits / 100), `${yr} agents made ${m.shipped.commits.toLocaleString("en")} commits${m.shipped.prs ? ` and opened ${m.shipped.prs} pull requests` : ""}.`);
496
+ if (m.automations && m.automations.runs >= 10) add(Math.min(1.5, m.automations.runs / 20), `${Y} ${v("build", "builds")} with agents too: ${m.automations.runs} runs started by ${v("your", "their")} own code.`);
497
+ const st = hp("steps"); if (st && st.value >= st.notable) add(st.value / st.notable, `${Y} ${v("break", "breaks")} big tasks into steps.`);
498
+ if (m.followups.question >= 0.3) add(m.followups.question / 0.3, `${Y} ${v("steer", "steers")} by asking questions.`);
499
+ const rc = hp("recover"); if (rc && rc.value >= rc.notable) add(rc.value / rc.notable, `${Y} ${v("say", "says")} exactly what's wrong when correcting the agent.`);
500
+ if (m.workMix && m.workMix[0]) add(0.9, `Most prompts are about ${WORK_LABEL[m.workMix[0].key][1]}.`);
501
+ return H.sort((a, b) => b[0] - a[0]).slice(0, 4).map((x) => x[1]);
502
+ }
357
503
  function cards(m, name) {
358
- const type = TYPES[builderType(m)];
504
+ const tk = builderType(m), type = TYPES[tk];
359
505
  const tools = [m.sessions.claudeCode && "Claude Code", m.sessions.codex && "Codex"].filter(Boolean).join(" and ");
360
506
  const verify = m.sessions.total ? m.proof.sessionsAskedToVerify / m.sessions.total : 0;
361
507
  const tested = m.proof.codingSessions ? m.proof.codingSessionsWithTests / m.proof.codingSessions : 0;
@@ -363,14 +509,22 @@ function cards(m, name) {
363
509
  const busy = m.months.find((x) => x.month === m.busiestMonth);
364
510
  const C = [];
365
511
  C.push({ id: "intro", bg: "#0B0B0A", fg: "#F4F1EA", dot: "#F4F1EA", pat: "months", ask: "Your year with AI", big: m.sessions.total.toLocaleString("en"), line: `sessions in ${tools}. ${m.prompts.total.toLocaleString("en")} prompts. Busiest month: ${busy ? busy.label : ""}.`, bs: m.sessions.total >= 1000 ? "26cqw" : "32cqw" });
366
- C.push({ id: "type", bg: "#F1ECE3", fg: "#0B0B0A", dot: "#0B0B0A", pat: "sweep", ask: "Your builder type", big: type.name, line: type.line, bs: "15cqw", grad: true });
512
+ C.push({ id: "type", bg: "#0B0B0A", fg: "#F4F1EA", av: tk, ask: "Your builder type", big: type.name, line: type.line, bs: "15cqw", grad: true });
513
+ const sc = hourScore(m);
514
+ if (sc) C.push({ id: "score", bg: "#F1ECE3", fg: "#0B0B0A", rc: "#8B5FD9", rows: sc.parts.map((x) => [x.label, x.points / 100, String(x.points)]), ask: "Your Hour score", big: String(sc.total), line: `out of 100: your average across ${sc.parts.length} habits.`, bs: "18cqw" });
367
515
  if (m.goToPrompt) C.push({ id: "prompt", bg: "#DBA004", fg: "#0B0B0A", dot: "#0B0B0A", pat: "rain", ask: "Your go-to prompt", big: `“${m.goToPrompt.text}”`, line: `You said it ${m.goToPrompt.count} times.`, bs: m.goToPrompt.text.length > 12 ? "14cqw" : "20cqw" });
368
516
  const hs = [...m.hours].map((v, h) => [h, v]).sort((a, b) => b[1] - a[1]);
369
517
  C.push({ id: "hour", bg: "#2B5FE3", fg: "#FFFFFF", dot: "#FFFFFF", pat: "hours", ask: "Your busiest hour", big: hourLabel(m.busiestHour), line: `${hourLabel(hs[1][0])} is a close second.`, bs: "26cqw" });
370
- if (m.parallelMax >= 2) C.push({ id: "parallel", bg: "#F2601F", fg: "#0B0B0A", dot: "#0B0B0A", pat: "lanes", n: Math.min(13, m.parallelMax), ask: "Most agents at once", big: String(m.parallelMax), line: "sessions going inside twenty minutes.", bs: "34cqw" });
518
+ if (m.parallelMax >= 2) C.push({ id: "parallel", bg: "#F2601F", fg: "#0B0B0A", dot: "#0B0B0A", pat: "lanes", n: Math.min(13, m.parallelMax), ask: "Agents at once", big: String(m.parallelMax), line: m.atOnceAt ? `At ${atOnceWhen(m.atOnceAt)}, ${m.parallelMax} agents were working on your tasks at the same time.` : "sessions going inside twenty minutes.", bs: "34cqw" });
371
519
  C.push({ id: "proof", bg: "#8B5FD9", fg: "#FFFFFF", dot: "#FFFFFF", pat: "ring", pct: verify, ask: "How often you made the agent prove it", big: `${pct(verify)}%`, line: "of sessions, you asked it to test, verify or screenshot.", bs: "30cqw" });
372
520
  C.push({ id: "steer", bg: "#F1ECE3", fg: "#0B0B0A", dot: "#8B5FD9", pat: "share", frac: m.followups.question, ask: "How you steer", big: `${pct(m.followups.question)}%`, line: `of your follow-ups are questions. ${pct(m.followups.correction)}% are corrections.`, bs: "30cqw" });
373
- if (top) C.push({ id: "model", bg: "#0B0B0A", fg: "#F4F1EA", dot: "#F2601F", pat: "stripes", shares: m.models.map((x) => x.share), ask: "Your model", big: top.name, line: `${pct(top.share)}% of your turns.` + (m.anthropicShare > 0 && m.anthropicShare < 1 ? ` ${pct(m.anthropicShare)}% Anthropic overall.` : ""), bs: top.name.length > 11 ? "11.5cqw" : "16cqw" });
521
+ if (m.stack && m.stack.length >= 2) C.push({ id: "stack", bg: "#F7F3EC", fg: "#0B0B0A", rc: "#2B5FE3", rows: m.stack.map((x) => [x.name, x.share, pct(x.share) + "%"]), ask: "Your stack", big: m.stack[0].name, line: m.areas && m.areas.length ? `By files edited. Mostly ${m.areas[0].name.toLowerCase()}${m.areas[1] ? ", then " + m.areas[1].name.toLowerCase() : ""}.` : "By files your agents edited.", bs: m.stack[0].name.length > 8 ? "12cqw" : "16cqw" });
522
+ if (m.workMix && m.workMix.length >= 3) C.push({ id: "work", bg: "#F7F3EC", fg: "#0B0B0A", rc: "#F2601F", rows: m.workMix.slice(0, 5).map((x) => [WORK_LABEL[x.key][0], x.share, pct(x.share) + "%"]), ask: "What you spend it on", big: WORK_LABEL[m.workMix[0].key][0], line: "Judged from the words in your prompts.", bs: "10cqw" });
523
+ if (m.shipped && m.shipped.commits >= 5) C.push({ id: "ship", bg: "#DBA004", fg: "#0B0B0A", dot: "#0B0B0A", pat: "field", ask: "What you shipped", big: m.shipped.commits.toLocaleString("en"), line: `commits${m.shipped.prs ? ` and ${m.shipped.prs} pull requests` : ""}, made by your agents.`, bs: m.shipped.commits >= 1000 ? "26cqw" : "32cqw" });
524
+ if (m.actionsPerPrompt >= 2) C.push({ id: "actions", bg: "#FB65C2", fg: "#0B0B0A", dot: "#0B0B0A", pat: "rain", ask: "Actions per prompt", big: String(m.actionsPerPrompt), line: `agent actions per prompt: commands and file edits.${m.stops ? ` You stopped the agent ${m.stops} times.` : ""}`, bs: "30cqw" });
525
+ if (m.automations && (m.automations.runs >= 5 || (m.subagents && m.subagents.sessionsUsing >= 5))) { const su = m.subagents && m.sessions.total ? m.subagents.sessionsUsing / m.sessions.total : 0; C.push(m.automations.runs >= 5 ? { id: "auto", bg: "#0B0B0A", fg: "#F4F1EA", dot: "#F2601F", pat: "lanes", n: 9, ask: "Built with agents", big: String(m.automations.runs), line: `runs started by your own code, not typed.${su >= 0.05 ? ` Your agents handed work to helpers in ${pct(su)}% of sessions.` : ""}`, bs: "30cqw" } : { id: "auto", bg: "#0B0B0A", fg: "#F4F1EA", dot: "#F2601F", pat: "lanes", n: 9, ask: "Your agents delegate", big: `${pct(su)}%`, line: "of your sessions handed work to helper agents.", bs: "30cqw" }); }
526
+ if (m.style && m.projects) C.push({ id: "range", bg: "#8B5FD9", fg: "#FFFFFF", dot: "#FFFFFF", pat: "months", ask: "Your range", big: String(m.projects), line: `projects, ${m.window.activeDays} active days, a ${m.style.streakDays}-day streak. Longest focused stretch: ${Math.floor(m.style.longestStretchMin / 60)}h ${m.style.longestStretchMin % 60}m.`, bs: "30cqw" });
527
+ if (top) C.push({ id: "model", bg: "#0B0B0A", fg: "#F4F1EA", dot: "#F2601F", pat: "stripes", shares: m.models.map((x) => x.share), ask: "Your model", big: top.name, line: `${pct(top.share)}% of your prompts.` + (m.anthropicShare > 0 && m.anthropicShare < 1 ? ` ${pct(m.anthropicShare)}% Anthropic overall.` : ""), bs: top.name.length > 11 ? "11.5cqw" : "16cqw" });
374
528
  if (m.dontKnow >= 10) C.push({ id: "honest", bg: "#FB65C2", fg: "#0B0B0A", dot: "#0B0B0A", pat: "rain", ask: "Your most honest phrase", big: "“I don't know”", line: `${m.dontKnow} times. You think out loud, with the agent.`, bs: "14cqw" });
375
529
  if (m.tokens > 0) C.push({ id: "tokens", bg: "#2B5FE3", fg: "#FFFFFF", dot: "#FFFFFF", pat: "field", ask: "How much you built", big: compact(m.tokens), line: "tokens processed.", bs: "30cqw" });
376
530
  if (m.proof.codingSessions >= 5) C.push({ id: "edge", bg: "#F7F3EC", fg: "#0B0B0A", dot: "#F2601F", pat: "ring", pct: tested, ask: tested < 0.75 ? "Your growth edge" : "Your strength", big: `${pct(tested)}%`, line: tested < 0.75 ? "of coding sessions ran a test. Push that higher." : "of coding sessions ran a test. Keep it that way.", bs: "30cqw" });
@@ -378,8 +532,8 @@ function cards(m, name) {
378
532
  const hp = m.promptProfile, best = [...hp].sort((a, b) => b.value / b.notable - a.value / a.notable)[0];
379
533
  C.push({ id: "prompting", bg: "#F7F3EC", fg: "#0B0B0A", bs: "8.6cqw", rows: hp.map((h) => [h.label, habitFill(h), `${pct(h.value)}%`]), ask: "How you prompt", big: best.label, line: `Your strongest habit.` });
380
534
  }
381
- C.push({ id: "summary", bg: "#0B0B0A", fg: "#F4F1EA", summary: true, who: name || "", period: `${monthYear(m.window.first)} – ${monthYear(m.window.last)}`, type: type.name,
382
- stats: [["Sessions", m.sessions.total.toLocaleString("en")], ["Busiest hour", hourLabel(m.busiestHour)], ["Go-to prompt", m.goToPrompt ? `“${m.goToPrompt.text}”` : "–"], ["Agents at once", String(m.parallelMax)], ["Made it prove it", `${pct(verify)}%`], ["Model", top ? top.name.replace(/^Claude /, "") : "–"]] });
535
+ C.push({ id: "summary", bg: "#0B0B0A", fg: "#F4F1EA", av: tk, summary: true, who: name || "", period: `${monthYear(m.window.first)} – ${monthYear(m.window.last)}`, type: type.name,
536
+ stats: [["Sessions", m.sessions.total.toLocaleString("en")], ["Hour score", sc ? String(sc.total) : "–"], ["Agents at once", m.parallelMax >= 2 ? String(m.parallelMax) : "–"], ["Made it prove it", `${pct(verify)}%`], [m.stack && m.stack.length ? "Stack" : "Busiest hour", m.stack && m.stack.length ? m.stack[0].name : hourLabel(m.busiestHour)], ["Go-to prompt", m.goToPrompt ? `“${m.goToPrompt.text}”` : "–"]] });
383
537
  return C;
384
538
  }
385
539
 
@@ -406,7 +560,53 @@ const CLAIM_CSS = String.raw`
406
560
  .cl-hcard:before{content:"";position:absolute;right:-10px;top:-10px;width:170px;height:120px;background-image:radial-gradient(circle,rgba(244,241,234,.9) 0 2.6px,transparent 3px);background-size:12px 12px;-webkit-mask-image:radial-gradient(circle at 80% 20%,#000 10%,transparent 70%);mask-image:radial-gradient(circle at 80% 20%,#000 10%,transparent 70%)}
407
561
  .cl-hcard label{position:relative;font-size:13px;opacity:.7}
408
562
  .cl-hrow{position:relative;display:flex;align-items:baseline;margin-top:4px}.cl-hrow span{font-size:40px;font-weight:650;letter-spacing:-.05em}
409
- .cl-hrow input{flex:1;min-width:0;border:0;outline:none;background:transparent;color:#F4F1EA;font:650 40px var(--sans);letter-spacing:-.05em;padding:0}
563
+ .cl-hrow{align-items:center!important;gap:6px;margin-top:10px!important;padding:6px 8px 6px 14px;border-radius:16px;background:rgba(244,241,234,.08);box-shadow:inset 0 0 0 1.5px rgba(244,241,234,.3);transition:box-shadow .15s,background .15s}
564
+ .cl-hrow:focus-within{background:rgba(244,241,234,.12);box-shadow:inset 0 0 0 2px #B0ACFE}
565
+ .cl-hrow input{flex:1;min-width:0;border:0;outline:none;background:transparent;color:#F4F1EA;font:650 40px var(--sans);letter-spacing:-.05em;padding:0;caret-color:#FB65C2}
566
+ .cl-edit-h{flex:none;display:inline-flex;align-items:center;gap:6px;height:36px;padding:0 13px;border-radius:999px;border:0;background:#F4F1EA;color:var(--ink);font:600 14px var(--sans);cursor:pointer}.cl-edit-h svg{width:15px;height:15px}
567
+ .cl-hst{position:relative;margin-top:10px;font-size:14px;opacity:.8}
568
+ .claimbox .pill em{font-style:normal;margin-left:10px;padding:2px 8px;border-radius:99px;background:rgba(255,255,255,.18);font:500 12px var(--sans)}
569
+ .pk-row{flex:none;display:grid;gap:12px;margin-top:22px}
570
+ .pk{display:flex;flex-direction:column;border-radius:20px;padding:18px 18px 14px;background:#EDE8DE;color:var(--ink);cursor:pointer;transition:background .35s,color .35s}.pk:hover{background:#E7E1D5}
571
+ .pk-pub{--on:#0B0B0A;--fg:#F4F1EA}.pk-roles{--on:#2B5FE3;--fg:#FFFFFF}.pk-feature{--on:#F2601F;--fg:#0B0B0A}
572
+ .pk.on,.pk.on:hover{background:var(--on);color:var(--fg)}
573
+ .pk-l{font-size:13px;font-weight:500;opacity:.75}
574
+ .pk .art{display:block;width:100%;height:auto;margin:14px 0 0;fill:currentColor;opacity:.28;transition:opacity .35s}.pk.on .art{opacity:1}
575
+ .pk .art circle.l{transform-box:fill-box;transform-origin:center}.pk.on .art circle.l{animation:pk-pop .55s cubic-bezier(.2,.8,.3,1.3) var(--d) both}
576
+ @keyframes pk-pop{from{transform:scale(0)}}
577
+ .pk-b{margin-top:14px;font-size:28px;line-height:1.02;letter-spacing:-.045em;font-weight:650}
578
+ .pk-pub.on .hd{background:linear-gradient(100deg,#4F88FF 0%,#B0ACFE 38%,#FB65C2 72%,#FD2980 100%);-webkit-background-clip:text;background-clip:text;color:transparent}
579
+ .pk-t{flex:1;margin-top:8px;font-size:14px;line-height:1.42;opacity:.85}
580
+ .pk-f{display:flex;justify-content:space-between;align-items:center;gap:10px;margin-top:14px;padding-top:12px;box-shadow:inset 0 1px 0 color-mix(in srgb,currentColor 18%,transparent);font-size:13px;font-weight:600}
581
+ .pk .o1,.pk.on .o0{display:none}.pk.on .o1{display:inline}
582
+ .pk .sw i{background:rgba(11,11,10,.2)}.pk.on .sw i{background:var(--fg)}.pk.on .sw i:after{background:var(--on);box-shadow:none}.pk .sw input:focus-visible+i{outline-color:currentColor}
583
+ .gv{flex:none;display:grid;gap:18px;margin-top:12px;border-radius:20px;padding:16px 18px;box-shadow:inset 0 0 0 1px rgba(11,11,10,.12)}
584
+ .gv-h{display:flex;flex-direction:column;align-items:flex-start}.gv-b{display:block;margin-top:8px;font-size:22px;line-height:1.05;letter-spacing:-.035em;font-weight:650}.gv .cl-link{padding:12px 0 0}
585
+ .gv ul{list-style:none;margin:10px 0 0;padding:0;display:flex;flex-direction:column;gap:7px;font-size:14px;line-height:1.35}
586
+ .gv li{display:flex;gap:9px}.gv li:before{content:"";flex:none;width:8px;height:8px;margin-top:5px;border-radius:50%;background:var(--ink)}.gv .st li:before{background:none;box-shadow:inset 0 0 0 1.5px var(--ink)}
587
+ .gv .gv-pre{grid-column:1/-1;margin:0}
588
+ @media(min-width:900px){.pk-row{grid-template-columns:repeat(3,minmax(0,1fr))}.gv{grid-template-columns:1.15fr 1fr 1fr;padding:18px 24px}.pk-row{margin-top:18px}}
589
+ .pk.fl,.pk.fl:hover,.pk.fl.on{background:none;padding:0;perspective:1400px}
590
+ .pk-flip{position:relative;flex:1;display:grid;transform-style:preserve-3d;transition:transform .8s cubic-bezier(.3,.9,.25,1)}.pk.fl.on .pk-flip{transform:rotateY(180deg)}
591
+ .pk-face{grid-area:1/1;display:flex;flex-direction:column;border-radius:20px;padding:18px 18px 14px;-webkit-backface-visibility:hidden;backface-visibility:hidden;background:#EDE8DE;color:var(--ink);transition:background .2s}
592
+ .pk.fl:hover .pk-face.fr{background:#E7E1D5}.pk-face.fr .pk-t{flex:1}.pk-face.bk{transform:rotateY(180deg);background:var(--on);color:var(--fg)}
593
+ .pk.fl.on .pk-face.fr .sw i{background:rgba(11,11,10,.2)}.pk.fl.on .pk-face.fr .sw i:after{background:#fff}
594
+ .sw.on-v i{background:var(--fg)}.sw.on-v i:after{transform:translateX(20px);background:var(--on);box-shadow:none}
595
+ .pk-l2{display:flex;justify-content:space-between;align-items:center;gap:8px;font-size:13px;font-weight:500}.pk-l2 span{opacity:.8}.pk-l2 em{font-style:normal;font-size:10.5px;font-weight:650;letter-spacing:.06em;text-transform:uppercase;padding:3px 8px;border-radius:99px;box-shadow:inset 0 0 0 1px color-mix(in srgb,currentColor 50%,transparent)}
596
+ .ib{flex:1;display:flex;flex-direction:column;margin-top:12px;border-radius:16px;padding:14px;background:color-mix(in srgb,var(--fg) 13%,transparent)}
597
+ .ib-h{display:flex;align-items:center;gap:7px;font-size:12px;font-weight:600;opacity:.85}.ib-h:before{content:"";width:7px;height:7px;border-radius:50%;background:currentColor;animation:ib-p 1.6s ease-in-out infinite}
598
+ @keyframes ib-p{50%{opacity:.25}}
599
+ .ib-t{margin-top:8px;font-size:19px;line-height:1.1;letter-spacing:-.03em;font-weight:650}
600
+ .ib-s{margin-top:12px;font-size:12px;opacity:.75}.ib-c{display:flex;flex-wrap:wrap;gap:5px;margin-top:6px}.ib-c i{font-style:normal;font-size:12px;font-weight:600;padding:4px 9px;border-radius:99px;background:color-mix(in srgb,var(--fg) 18%,transparent)}
601
+ .ib-a{display:flex;gap:8px;margin-top:auto;padding-top:12px}.ib-a b{flex:1;text-align:center;padding:8px 0;border-radius:99px;background:var(--fg);color:var(--on);font-size:13px}.ib-a b.o{background:none;color:var(--fg);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--fg) 50%,transparent)}
602
+ .fp{flex:1;display:flex;flex-direction:column;margin-top:12px;border-radius:16px;padding:12px;background:#F4F1EA;color:#0B0B0A}
603
+ .fp-h{display:flex;align-items:center;gap:6px;font-size:13px;font-weight:650}.fp-h svg{width:16px;height:16px}.fp-h span{font-weight:400;opacity:.6}
604
+ .fp-b{flex:1;display:flex;gap:12px;align-items:flex-end;margin-top:10px}.pk-thumb{flex:none;width:104px}.pk-thumb .card{border-radius:7px}
605
+ .fp-t{display:flex;flex-direction:column;gap:4px;font-size:12.5px;line-height:1.35;padding-bottom:2px}.fp-t b{font-size:16px;letter-spacing:-.02em}
606
+ .fp-c{display:flex;flex-wrap:wrap;gap:5px;margin-top:10px}.fp-c i{font-style:normal;font-size:12px;font-weight:600;padding:4px 9px;border-radius:99px;background:rgba(11,11,10,.14)}
607
+ @media(prefers-reduced-motion:reduce){.pk-flip{transition:none}.ib-h:before{animation:none}}
608
+ .sw{position:relative;flex:none}.sw input{position:absolute;opacity:0;width:1px;height:1px}.sw i{display:block;width:48px;height:28px;border-radius:999px;background:rgba(11,11,10,.16);position:relative;transition:background .15s}.sw i:after{content:"";position:absolute;left:3px;top:3px;width:22px;height:22px;border-radius:50%;background:#fff;box-shadow:0 1px 3px rgba(0,0,0,.25);transition:transform .15s}
609
+ .cl-opt.dk .sw i{background:rgba(244,241,234,.25)}.sw input:checked+i{background:var(--acc)}.sw input:checked+i:after{transform:translateX(20px)}.sw input:focus-visible+i{outline:2px solid var(--acc);outline-offset:2px}
410
610
  .cl-hst{position:relative;margin-top:8px;font-size:14px;min-height:20px;opacity:.85}
411
611
  .cl-f{flex:none;display:flex;flex-direction:column;gap:6px;margin-top:14px;font-size:14px;font-weight:500}
412
612
  .cl-f input{height:48px;padding:0 14px;border-radius:12px;border:1px solid rgba(11,11,10,.14);background:#fff;font:16px var(--sans);color:var(--ink)}
@@ -441,8 +641,12 @@ const CLAIM_CSS = String.raw`
441
641
  .cl-done h2{position:relative;margin:90px 0 0;font-size:44px;line-height:.95;letter-spacing:-.05em;font-weight:650}
442
642
  .cl-done a.u{position:relative;display:block;margin-top:16px;font:15px var(--mono);color:#F4F1EA;word-break:break-all}
443
643
  .cl-done p{position:relative;margin:14px 0 0;font-size:14px;line-height:1.45;opacity:.8}
644
+ .cl-done .u.off{position:relative;margin-top:16px;display:flex;align-items:center;gap:10px;flex-wrap:wrap;font:15px var(--mono);color:rgba(244,241,234,.6)}
645
+ .cl-done .u.off .tag{font:600 12px var(--sans);letter-spacing:.02em;color:#F4F1EA;background:rgba(244,241,234,.14);border-radius:999px;padding:4px 10px}
646
+ .cl-done a.u.s{margin-top:8px;font-size:13px;opacity:.85}
444
647
  .cl-row{flex:none;display:flex;gap:10px;margin-top:16px}.cl-row a,.cl-row button{flex:1;display:flex;align-items:center;justify-content:center;height:52px;border-radius:999px;font:600 16px var(--sans);text-decoration:none;cursor:pointer;border:0}
445
- .cl-row a{background:var(--ink);color:var(--paper)}.cl-row button{background:#fff;color:var(--ink);box-shadow:inset 0 0 0 1px rgba(11,11,10,.14)}
648
+ .cl-row a{background:var(--ink);color:var(--paper)}
649
+ .cl-row.sec a{background:#fff;color:var(--ink);box-shadow:inset 0 0 0 1px rgba(11,11,10,.14)}.cl-row button{background:#fff;color:var(--ink);box-shadow:inset 0 0 0 1px rgba(11,11,10,.14)}
446
650
  .cl-g2{margin-top:22px}.cl-g2>*+*{margin-top:14px}
447
651
  .cl-foot{flex:none;display:flex;flex-direction:column-reverse;gap:8px;margin-top:14px}.cl-foot .cl-fine{margin:0;text-align:center}
448
652
  .cl-tip{margin-top:10px;font-size:12.5px;font-weight:600;opacity:.8}
@@ -466,8 +670,24 @@ d.querySelector(".cta").onclick=function(e){e.stopPropagation();openClaim()};ret
466
670
  function openClaim(){document.getElementById("cl").hidden=false;document.body.style.overflow="hidden";renderClaim()}
467
671
  function closeClaim(){document.getElementById("cl").hidden=true;document.body.style.overflow=""}
468
672
  function payload(){return{v:2,handle:CS.handle,name:CS.name.trim(),email:CS.email.trim(),linkedin:"",public:CS.pub,roles:CS.roles,feature:CS.feature,prompts:CS.picked.map(function(i){return{text:promptOut(i),date:D.examples[i].date,hidden:CS.hide[i].size}}).filter(function(p){return p.text&&p.text!=="[hidden]"}),wrapped:D.m}}
469
- function prog(n){return'<div class="cl-top"><button type="button" class="cl-back" data-a="back">'+(n===1?"Close":"Back")+'</button><span>'+n+' of 3</span></div><div class="cl-prog">'+[1,2,3].map(function(i){return'<i'+(i<=n?' class="on"':'')+'></i>'}).join("")+'</div>'}
470
- function handleNote(){var h=CS.handle;if(!h)return"Letters, numbers and dashes.";if(!HANDLE_RE.test(h))return"3 to 24 letters, numbers or single dashes.";return"Press Continue to check it's free."}
673
+ function prog(n){var T=D.examples.length?3:2,k=T===2&&n===3?2:n,all=T===3?[1,2,3]:[1,2];return'<div class="cl-top"><button type="button" class="cl-back" data-a="back">'+(n===1?"Close":"Back")+'</button><span>'+k+' of '+T+'</span></div><div class="cl-prog">'+all.map(function(i){return'<i'+(i<=k?' class="on"':'')+'></i>'}).join("")+'</div>'}
674
+ function perkType(){var t=(D.cards||[]).find(function(c){return c.id==="type"});return t?String(t.big).replace(/^The /,""):""}
675
+ function dotArt(k){var C=18,R=5,o="";for(var y=0;y<R;y++)for(var x=0;x<C;x++){var r;
676
+ if(k==="pub")r=Math.pow(x/(C-1),1.5)*(0.6+0.4*Math.sin((y+1)/(R+1)*Math.PI));
677
+ else if(k==="roles"){var hs={1:2,4:4,7:3,10:5,13:3,16:5};r=x%3===1&&y>=R-hs[x]?0.85:0}
678
+ else{var dx=(x-C*0.64)/1.7,dy=y-R*0.4;r=1.05-Math.sqrt(dx*dx+dy*dy)/4.4}
679
+ var lit=r>0.28;o+='<circle cx="'+(x*10+5)+'" cy="'+(y*10+5)+'" r="'+((lit?Math.min(1,r):0.17)*4.4).toFixed(2)+'"'+(lit?' class="l" style="--d:'+(x*24+y*10)+'ms"':'')+'/>'}
680
+ return'<svg class="art" viewBox="0 0 '+C*10+' '+R*10+'" aria-hidden="true">'+o+'</svg>'}
681
+ function pkTile(k,label,big,body,offT,onT,back){var on=!!CS[k],sw='<span class="sw"><input type="checkbox" data-o="'+k+'"'+(on?' checked':'')+'><i></i></span>',fr='<span class="pk-l">'+label+'</span>'+dotArt(k)+'<span class="pk-b">'+big+'</span><span class="pk-t">'+body+'</span>';
682
+ if(!back)return'<label class="pk pk-'+k+(on?' on':'')+'">'+fr+'<span class="pk-f"><span><span class="o0">'+offT+'</span><span class="o1">'+onT+'</span></span>'+sw+'</span></label>';
683
+ return'<label class="pk fl pk-'+k+(on?' on':'')+'"><span class="pk-flip"><span class="pk-face fr">'+fr+'<span class="pk-f"><span>'+offT+'</span>'+sw+'</span></span><span class="pk-face bk" aria-hidden="true">'+back+'<span class="pk-f"><span>'+onT+'</span><span class="sw on-v"><i></i></span></span></span></span></label>'}
684
+ function cardBy(id){return (D.cards||[]).find(function(c){return c.id===id})}
685
+ function introBack(hh){var m=D.m||{},t=cardBy("type"),sc=cardBy("score"),ch=[];if(t)ch.push(t.big);if(m.parallelMax>1&&m.parallelMax<=24)ch.push(m.parallelMax+" agents at once");if(m.shipped&&m.shipped.commits)ch.push(Number(m.shipped.commits).toLocaleString("en")+" commits");if(ch.length<3&&sc)ch.push("Hour score "+sc.big);
686
+ return'<span class="pk-l2"><span>What an intro looks like</span><em>Example</em></span><span class="ib"><span class="ib-h">Intro request</span><span class="ib-t">A team building AI agents wants to meet @'+hh+'.</span><span class="ib-s">What they saw</span><span class="ib-c">'+ch.slice(0,3).map(function(x){return'<i>'+esc(x)+'</i>'}).join("")+'</span><span class="ib-a"><b>Say yes</b><b class="o">Not now</b></span></span>'}
687
+ function featBack(hh){var t=cardBy("type"),ln=t&&t.line?String(t.line).split(/(?<=\.)\s/)[0]:"";
688
+ return'<span class="pk-l2"><span>Builders to watch</span><em>Example</em></span><span class="fp"><span class="fp-h"><svg viewBox="0 0 64 64" aria-hidden="true"><use href="#mark"/></svg>Hour<span>· this week</span></span><span class="fp-b"><span class="pk-thumb"></span><span class="fp-t"><b>@'+hh+'</b><span>'+esc((t?t.big+". ":"")+ln)+'</span></span></span></span><span class="fp-c"><i>X</i><i>LinkedIn</i><i>Hour newsletter</i></span>'}
689
+ function perkLine(){var t=(D.cards||[]).find(function(c){return c.id==="type"}),sc=(D.cards||[]).find(function(c){return c.id==="score"});return (t?t.big:"")+(sc?" · Hour score "+sc.big:"")}
690
+ function handleNote(){var h=CS.handle;if(!h)return"Letters, numbers and dashes.";if(D.suggest&&h===D.suggest)return"We suggested this from your computer’s name. Type any handle you like.";if(!HANDLE_RE.test(h))return"3 to 24 letters, numbers or single dashes.";return"Press Continue to check it's free."}
471
691
  function toks(i){return CS.texts[i].split(/(\s+)/)}
472
692
  function isAuto(w){return w.indexOf("[hidden]")>=0}
473
693
  function promptOut(i){var h=CS.hide[i];return toks(i).map(function(w,k){return h.has(k)?"[hidden]":w}).join("").replace(/\[hidden\](\s+\[hidden\])+/g,"[hidden]").trim()}
@@ -476,7 +696,7 @@ function viewHTML(i){var h=CS.hide[i];return toks(i).map(function(w,k){if(/^\s+$
476
696
  function editHTML(i){var h=CS.hide[i];return toks(i).map(function(w,k){if(/^\s+$/.test(w))return w;if(isAuto(w))return hidHTML(w);return'<button type="button" class="tok'+(h.has(k)?" on":"")+'" data-a="tok" data-i="'+i+'" data-k="'+k+'">'+esc(w)+'</button>'}).join("")}
477
697
  function renderClaim(){var s=document.getElementById("cls"),H="",n=CS.step;
478
698
  if(n===1){H=prog(1)+'<h2 class="cl-h">Pick your handle.</h2><p class="cl-p">It’s the address of your profile. Once it’s yours, nobody else can have it.</p>'
479
- +'<div class="cl-g2"><div class="cl-hcard"><label for="clh">usehour.com/</label><div class="cl-hrow"><span class="grad">@</span><input id="clh" autocomplete="off" autocapitalize="off" spellcheck="false" maxlength="24" value="'+esc(CS.handle)+'"></div><div class="cl-hst" id="clhst">'+esc(handleNote())+'</div></div>'
699
+ +'<div class="cl-g2"><div class="cl-hcard"><label for="clh">Your handle · usehour.com/</label><div class="cl-hrow"><span class="grad">@</span><input id="clh" autocomplete="off" autocapitalize="off" spellcheck="false" maxlength="24" value="'+esc(CS.handle)+'" aria-describedby="clhst"><button type="button" class="cl-edit-h" data-a="edith" aria-label="Edit your handle"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 20h4L19 9l-4-4L4 16v4z"/><path d="M13.5 6.5l4 4"/></svg>Edit</button></div><div class="cl-hst" id="clhst">'+esc(handleNote())+'</div></div>'
480
700
  +'<div><label class="cl-f" style="margin-top:0">Your name<input id="cln" autocomplete="name" maxlength="80" value="'+esc(CS.name)+'"></label>'
481
701
  +'<label class="cl-f">Email, to keep your handle<input id="cle" type="email" autocomplete="email" maxlength="200" value="'+esc(CS.email)+'"></label>'
482
702
  +'<p class="cl-fine">We email you a link to your profile. That’s how you get back in. No password.</p></div></div>'
@@ -489,25 +709,32 @@ return'<div class="cl-pc'+(on?" on":"")+(ed?" ed":"")+'" data-a="pick" data-i="'
489
709
  +'<button type="button" class="cl-edit" data-a="edit" data-i="'+i+'">'+(ed?"Done":"Black out words")+'</button></div>'}).join("")+'</div><div class="cl-more" id="clm" hidden><span>'+(D.examples.length>4?"More prompts below":"More below")+'</span></div></div>'
490
710
  +'<div class="cl-err">'+esc(CS.err)+'</div><div class="cl-foot" style="margin-top:14px">'+(k?'<button type="button" class="cl-skip" data-a="skip2">Skip, don’t share any prompts</button>':'<span></span>')+'<button type="button" class="cl-go" data-a="next2">'+(k?"Continue with "+k+" prompt"+(k>1?"s":""):"Continue without prompts")+'</button></div>'}
491
711
  else if(n===3){var p=payload(),hh=esc(CS.handle);H=prog(3)+'<h2 class="cl-h">Make it work for you.</h2><p class="cl-p">All optional. Change any of it later from your profile.</p>'
492
- +'<div class="cl-g2"><div>'
493
- +'<label class="cl-opt dk" style="margin-top:0"><span class="cl-oh"><span>Make <span class="grad">@'+hh+'</span> public</span><input type="checkbox" data-o="pub"'+(CS.pub?" checked":"")+'></span><span class="cl-ot">A link for your LinkedIn, GitHub and email signature. It shows how you work, which a CV can’t.</span><span class="cl-ot s">Off: your handle stays reserved, and only people with your private link can see the profile.</span></label>'
494
- +'<label class="cl-opt"><span class="cl-oh"><span>Let companies come to you</span><input type="checkbox" data-o="roles"'+(CS.roles?" checked":"")+'></span><ul class="cl-ul" style="--dot:#2B5FE3"><li>Teams hiring AI builders see your profile and ask for an intro. No applying, no cover letters.</li><li>You say yes or no to each one. Your email stays hidden until you say yes.</li></ul></label>'
495
- +'<label class="cl-opt"><span class="cl-oh"><span>Get featured by Hour</span><input type="checkbox" data-o="feature"'+(CS.feature?" checked":"")+'></span><ul class="cl-ul" style="--dot:#F2601F"><li>We post standout cards in our “builders to watch” series, tagged to you.</li><li>Your work in front of founders and hiring teams who follow Hour.</li></ul></label>'
496
- +'</div><div><h3 class="cl-h3" style="margin-top:0">What goes to Hour</h3><div class="cl-two"><div><b><i style="background:#2B5FE3"></i>Sent</b><span>Your card numbers</span><span>'+(p.prompts.length?p.prompts.length+" prompt"+(p.prompts.length>1?"s":"")+" you picked":"No prompts")+'</span><span>Handle, name, email</span></div><div><b><i style="background:#F2601F"></i>Stays with you</b><span>Your logs and code</span><span>Every other prompt</span><span>File and project names</span></div></div>'
497
- +'<button type="button" class="cl-link" data-a="data">'+(CS.data?"Hide the exact data":"See the exact data")+'</button>'+(CS.data?'<pre>'+esc(JSON.stringify(p,null,2))+'</pre>':'')+'</div></div>'
712
+ +'<div class="pk-row">'
713
+ +pkTile("pub","Public profile",'<span class="hd">@'+hh+'</span>',"One link for LinkedIn, GitHub and your email signature. It shows how you work, which a CV can’t.","Private: only your secret link opens it","Live at usehour.com/@"+hh)
714
+ +pkTile("roles","Teams hiring","Get asked, not screened.","Teams hiring AI builders see how you work and ask to meet you. You say yes or no. Your email stays hidden.","Not open to intros","Open to intros, by email",introBack(hh))
715
+ +pkTile("feature","Hour picks","Get featured.","We share standout cards with the founders and hiring teams who follow Hour, tagged @"+hh+".","Not in the picks",esc(perkType())?"Your "+esc(perkType())+" card is in":"Your cards are in",featBack(hh))
716
+ +'</div><div class="gv"><div class="gv-h"><span class="pk-l">What goes to Hour</span><span class="gv-b">Your cards go.<br>Your prompts stay.</span><button type="button" class="cl-link" data-a="data">'+(CS.data?"Hide the exact data":"See the exact data")+'</button></div>'
717
+ +'<div class="gv-c"><span class="pk-l">Sent to Hour</span><ul><li>The numbers on your cards</li><li>Languages, tools and model names</li>'+(D.m&&D.m.goToPrompt?'<li>Your go-to phrase, “'+esc(D.m.goToPrompt.text)+'”</li>':'')+'<li>Handle, name and email</li></ul></div>'
718
+ +'<div class="gv-c st"><span class="pk-l">Stays on this computer</span><ul><li>Every prompt you wrote</li><li>Your logs and code</li><li>File and project names</li></ul></div>'
719
+ +(CS.data?'<pre class="gv-pre">'+esc(JSON.stringify(p,null,2))+'</pre>':'')+'</div>'
498
720
  +'<div class="cl-err">'+esc(CS.err)+'</div><div class="cl-sp"></div><div class="cl-foot"><p class="cl-fine">Delete it any time, with one link.</p><button type="button" class="cl-go" data-a="create"'+(CS.busy?" disabled":"")+'>'+(CS.busy?"Creating…":"Create @"+hh)+'</button></div>'}
499
- else{var d=CS.done,u=d.publicUrl||d.url;H='<div class="cl-top"><span></span><button type="button" class="cl-back" data-a="close">Close</button></div><div class="cl-narrow"><div class="cl-done"><h2>@'+esc(d.handle)+'<br>is yours.</h2><a class="u" href="'+esc(u)+'" target="_blank" rel="noopener">'+esc(u.replace("https://",""))+'</a><p>'+(d.publicUrl?"Your profile is public. Put the link anywhere you like.":"Your profile is private. Only people you send this link to can see it.")+'</p></div>'
500
- +'<div class="cl-row"><a href="'+esc(u)+'" target="_blank" rel="noopener">Open my profile</a><button type="button" data-a="copy">Copy link</button></div><p class="cl-fine c">We emailed you this link, plus a private link to change your settings or delete your profile.</p></div>'}
501
- s.innerHTML=H;s.scrollTop=0;
721
+ else{var d=CS.done,pub=!!d.publicUrl,u=d.publicUrl||d.url,hu="usehour.com/@"+d.handle;H='<div class="cl-top"><span></span><button type="button" class="cl-back" data-a="close">Close</button></div><div class="cl-narrow"><div class="cl-done"><h2>@'+esc(d.handle)+'<br>is yours.</h2>'
722
+ +(pub?'<a class="u" href="'+esc(u)+'" target="_blank" rel="noopener">'+esc(hu)+'</a><p>Your profile is public. Put the link anywhere you like.</p>'
723
+ :'<div class="u off">'+esc(hu)+'<span class="tag">Not public yet</span></div><p>'+esc(hu)+' starts working when you make your profile public. Until then, only this private link opens it:</p><a class="u s" href="'+esc(d.url)+'" target="_blank" rel="noopener">'+esc(d.url.replace("https://",""))+'</a>')+'</div>'
724
+ +(pub||!d.key?'':'<button type="button" class="cl-go" style="width:100%;margin-top:16px" data-a="gopub"'+(CS.busy?" disabled":"")+'>'+(CS.busy?"Making it public…":"Make @"+esc(d.handle)+" public")+'</button>')
725
+ +'<div class="cl-err">'+esc(CS.err)+'</div>'
726
+ +'<div class="cl-row'+(pub?'':' sec')+'"><a href="'+esc(u)+'" target="_blank" rel="noopener">Open my profile</a><button type="button" data-a="copy">Copy link</button></div><p class="cl-fine c">We emailed you this link, plus a private link to change your settings or delete your profile.</p></div>'}
727
+ s.innerHTML=H;s.scrollTop=0;if(n===3){var th=s.querySelector(".pk-thumb"),tc=cardBy("type");if(th&&tc)th.appendChild(el(tc))}if(n===1){var hi=document.getElementById("clh");if(hi&&D.suggest&&CS.handle===D.suggest){hi.focus();hi.select()}}
502
728
  if(n===2){var L=document.getElementById("cll"),M=document.getElementById("clm"),f=function(){M.hidden=L.scrollHeight-L.clientHeight-L.scrollTop<24};L.addEventListener("scroll",f);f()}}
503
729
  document.getElementById("cls").addEventListener("input",function(e){var t=e.target;
504
730
  if(t.id==="clh"){var v=t.value.toLowerCase().replace(/[^a-z0-9-]/g,"");if(v!==t.value)t.value=v;CS.handle=v;CS.err="";document.getElementById("clhst").textContent=handleNote()}
505
731
  else if(t.id==="cln")CS.name=t.value;else if(t.id==="cle")CS.email=t.value;
506
732
  else if(t.tagName==="TEXTAREA")CS.texts[+t.getAttribute("data-i")]=t.value});
507
- document.getElementById("cls").addEventListener("change",function(e){var o=e.target.getAttribute("data-o");if(o){CS[o]=e.target.checked;if(CS.data)renderClaim()}});
733
+ document.getElementById("cls").addEventListener("change",function(e){var o=e.target.getAttribute("data-o");if(o){CS[o]=e.target.checked;var pk=e.target.closest(".pk");if(pk)pk.classList.toggle("on",e.target.checked);if(CS.data)renderClaim()}});
508
734
  document.getElementById("cls").addEventListener("click",function(e){var b=e.target.closest("[data-a]");if(!b||e.target.tagName==="TEXTAREA")return;var a=b.getAttribute("data-a"),i=+b.getAttribute("data-i");
509
735
  if(a==="back"){CS.err="";if(CS.step===1)closeClaim();else{CS.step=CS.step===3&&!D.examples.length?1:CS.step-1;renderClaim()}}
510
736
  else if(a==="close"){closeClaim()}
737
+ else if(a==="edith"){var hi=document.getElementById("clh");hi.focus();hi.select()}
511
738
  else if(a==="next1"){if(CS.busy)return;if(!HANDLE_RE.test(CS.handle)){CS.err="Handles are 3 to 24 letters, numbers or single dashes.";return renderClaim()}if(!CS.name.trim()){CS.err="Add your name.";return renderClaim()}if(!EMAIL_RE.test(CS.email.trim())){CS.err="Add an email address, so the handle stays yours.";return renderClaim()}
512
739
  CS.busy=true;CS.err="";renderClaim();fetch(D.api+"/handles/"+encodeURIComponent(CS.handle)).then(function(r){return r.json()}).then(function(j){CS.busy=false;if(j.available){CS.step=D.examples.length?2:3}else CS.err="@"+CS.handle+(j.reason==="already taken"?" is taken. Try another.":": "+j.reason+".");renderClaim()}).catch(function(){CS.busy=false;CS.err="Couldn’t reach usehour.com. Check your connection and try again.";renderClaim()})}
513
740
  else if(a==="edit"){e.stopPropagation();CS.editing=CS.editing===i?-1:i;var y0=document.getElementById("cll").scrollTop;renderClaim();document.getElementById("cll").scrollTop=y0}
@@ -517,19 +744,61 @@ else if(a==="next2"){CS.err="";CS.editing=-1;CS.step=3;renderClaim()}
517
744
  else if(a==="skip2"){CS.picked=[];CS.err="";CS.editing=-1;CS.step=3;renderClaim()}
518
745
  else if(a==="data"){CS.data=!CS.data;renderClaim()}
519
746
  else if(a==="copy"){var u=CS.done.publicUrl||CS.done.url;(navigator.clipboard?navigator.clipboard.writeText(u):Promise.reject()).then(function(){b.textContent="Copied"},function(){window.prompt("Copy this link",u)})}
747
+ else if(a==="gopub"){if(CS.busy)return;CS.busy=true;CS.err="";renderClaim();
748
+ fetch(D.api+"/profiles/public",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:CS.done.id,key:CS.done.key})}).then(function(r){return r.json().catch(function(){return{}}).then(function(j){return{s:r.status,j:j}})}).then(function(x){CS.busy=false;
749
+ if(x.s===200&&x.j.publicUrl){CS.done.publicUrl=x.j.publicUrl;delete CS.done.key}else CS.err="That didn’t work ("+(x.j.error||"error "+x.s)+"). You can switch it on from the link in your email.";renderClaim()}).catch(function(){CS.busy=false;CS.err="Couldn’t reach usehour.com. Try again, or switch it on from the link in your email.";renderClaim()})}
520
750
  else if(a==="create"){if(CS.busy)return;CS.busy=true;CS.err="";renderClaim();
521
751
  fetch(D.api+"/profiles",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(payload())}).then(function(r){return r.json().catch(function(){return{}}).then(function(j){return{s:r.status,j:j}})}).then(function(x){CS.busy=false;
522
752
  if(x.s===201&&x.j.url){CS.done=x.j;CS.step=4;document.getElementById("topnote").textContent="Your profile is on usehour.com. Your logs and other prompts stayed on this computer.";document.getElementById("claimbar").hidden=true}
523
753
  else if(x.s===409){CS.step=1;CS.err="Someone just took @"+CS.handle+". Pick another."}
524
754
  else CS.err="That didn’t go through ("+(x.j.error||"error "+x.s)+"). Nothing was saved. Try again in a minute.";renderClaim()}).catch(function(){CS.busy=false;CS.err="Couldn’t reach usehour.com. Nothing was saved. Try again.";renderClaim()})}});
525
755
  document.getElementById("claimb").onclick=openClaim;
526
- (function(){var h=document.getElementById("clhandle"),b=document.getElementById("claimb");if(CS.handle){h.textContent=CS.handle;b.textContent="Claim @"+CS.handle}})();
756
+ (function(){var h=document.getElementById("clhandle");if(CS.handle){h.textContent=CS.handle;var sg=document.createElement("em");sg.textContent="suggested";h.parentNode.appendChild(sg)}})();
527
757
  `;
528
758
 
529
759
  /* --------------------------------------------------------------- the page */
760
+ /* The avatar engine (direction D, the living dot matrix). Source of truth is
761
+ cli/avatar-engine.js; this is the same code as a string, because the
762
+ published package is this one file. Same copy in site/netlify/lib/avatar.mjs. */
763
+ const AV_JS = "/* Hour avatars, direction D: the living dot matrix. One per builder type.\n No dependencies. window.HourAvatar.draw(ctx, size, t, type, seed, still)\n paints one frame; HourAvatar.add(canvas, type, seed) animates a canvas;\n HourAvatar.scan(root) picks up every canvas[data-av=\"type:seed\"]. Draw on\n a dark surface: the eyes are near-white. */\nwindow.HourAvatar = (function () {\n var STOPS = [[0, [79, 136, 255]], [0.38, [176, 172, 254]], [0.72, [251, 101, 194]], [1, [253, 41, 128]]];\n function grad(u) { u = Math.max(0, Math.min(1, u)); for (var i = 1; i < STOPS.length; i++) { if (u <= STOPS[i][0]) { var a = STOPS[i - 1], b = STOPS[i], k = (u - a[0]) / (b[0] - a[0]); return [a[1][0] + (b[1][0] - a[1][0]) * k, a[1][1] + (b[1][1] - a[1][1]) * k, a[1][2] + (b[1][2] - a[1][2]) * k]; } } return STOPS[3][1]; }\n function rgba(c, a) { return 'rgba(' + (c[0] | 0) + ',' + (c[1] | 0) + ',' + (c[2] | 0) + ',' + a + ')'; }\n function hash(n) { var x = Math.sin(n * 127.1 + 311.7) * 43758.5453; return x - Math.floor(x); }\n function ease(k) { k = Math.max(0, Math.min(1, k)); return k < .5 ? 4 * k * k * k : 1 - Math.pow(-2 * k + 2, 3) / 2; }\n function gaze(t, seed, bias) {\n var P = 2.1 + hash(seed) * 1.2, s = Math.floor((t + seed * 3) / P), f = ((t + seed * 3) % P) / P;\n function tgt(i) { var h = hash(i + seed * 17), h2 = hash(i * 3.3 + seed); return [(h < .35 ? 0 : (h2 * 2 - 1)) + (bias || 0), (hash(i * 7.7 + seed) * 2 - 1) * .5]; }\n var a = tgt(s - 1), b = tgt(s), k = ease(f / .16);\n return [a[0] + (b[0] - a[0]) * k, a[1] + (b[1] - a[1]) * k];\n }\n function blink(t, seed) {\n var P = 3.7 + hash(seed + 9) * 1.6, f = (t + seed * 5) % P, d = .16;\n if (f > d) { var f2 = f - .28; if (hash(Math.floor((t + seed * 5) / P) + seed) > .7 && f2 > 0 && f2 < d) return Math.abs(f2 / d * 2 - 1); return 1; }\n return Math.abs(f / d * 2 - 1);\n }\n function capsule(ctx, x, y, w, h) { var r = Math.min(w, h) / 2; ctx.moveTo(x - w / 2 + r, y - h / 2); ctx.lineTo(x + w / 2 - r, y - h / 2); ctx.arc(x + w / 2 - r, y - h / 2 + r, r, -Math.PI / 2, 0); ctx.lineTo(x + w / 2, y + h / 2 - r); ctx.arc(x + w / 2 - r, y + h / 2 - r, r, 0, Math.PI / 2); ctx.lineTo(x - w / 2 + r, y + h / 2); ctx.arc(x - w / 2 + r, y + h / 2 - r, r, Math.PI / 2, Math.PI); ctx.lineTo(x - w / 2, y - h / 2 + r); ctx.arc(x - w / 2 + r, y - h / 2 + r, r, Math.PI, Math.PI * 1.5); ctx.closePath(); }\n function draw(ctx, S, t, type, seed, still) {\n seed = seed || 1;\n var cx = S / 2, cy = S / 2, R = S * .44, small = S < 90, n = S < 50 ? 7 : S < 90 ? 9 : S < 200 ? 19 : 27, st = 2 * R / n;\n var fields = type === 'pair' ? [[cx - R * .36, cy + R * .04, R * .6, seed], [cx + R * .36, cy - R * .04, R * .62, seed + 4]] : [[cx, cy, R, seed]];\n var tilt = type === 'interrogator' ? Math.sin(t * .9) * .12 * (Math.sin(t * .45) > 0 ? 1 : .2) : 0;\n var eyes = fields.map(function (f, i) {\n var bias = type === 'sprinter' ? .7 : type === 'pair' ? (i ? -1 : 1) * (Math.sin(t * .5 + seed) > .3 ? .9 : 0) : 0;\n var g = still ? [type === 'sprinter' ? .7 : type === 'pair' ? (i ? -.6 : .6) : 0, 0] : gaze(t, f[3], bias), op = still ? 1 : blink(t, f[3]);\n var sq = 1; if (type === 'verifier') { var by = Math.sin(t * 1.3) * R * .75; sq = 1 - .55 * Math.exp(-Math.pow(by / (R * .18), 2)); }\n return { x: f[0], y: f[1], r: f[2], g: g, op: op * sq, by: type === 'verifier' ? f[1] + Math.sin(t * 1.3) * R * .75 : null };\n });\n var sats = type === 'conductor' ? [0, 1, 2].map(function (k) { var a = t * .6 + k * 2.094; return [cx + Math.cos(a) * R * .72, cy + Math.sin(a) * R * .72]; }) : [];\n for (var iy = 0; iy <= n; iy++) for (var ix = 0; ix <= n; ix++) {\n var px = cx - R + ix * st, py = cy - R + iy * st;\n if (type === 'sprinter') px -= ((t * 40) % st);\n var best = 0, inside = 0, bright = 0;\n eyes.forEach(function (e) {\n var dx = px - e.x, dy = py - e.y;\n if (tilt) { var c = Math.cos(tilt), s = Math.sin(tilt); var rx = dx * c - dy * s, ry = dx * s + dy * c; dx = rx; dy = ry; }\n var dd = type === 'architect' ? Math.max(Math.abs(dx), Math.abs(dy)) / (e.r * .86) : Math.hypot(dx, dy) / e.r;\n var edge = 1 - dd; if (edge > inside) inside = edge;\n for (var s2 = -1; s2 <= 1; s2 += 2) {\n var hx = e.x + s2 * e.r * .27 + e.g[0] * e.r * .13, hy = e.y - e.r * .02 + e.g[1] * e.r * .08;\n var raise = type === 'interrogator' && s2 === 1 ? 1.3 : 1;\n var qx = (px - hx) / Math.max(e.r * .115, small ? st * .55 : 0), qy = (py - hy) / Math.max(e.r * .23 * raise * Math.max(.12, e.op), small ? st * .6 * Math.max(.3, e.op) : 0);\n var v = Math.exp(-(qx * qx + qy * qy) * 1.3); if (v > bright) bright = v;\n }\n if (e.by !== null) { var band = Math.exp(-Math.pow((py - e.by) / (st * 1.2), 2)); best = Math.max(best, band); }\n });\n sats.forEach(function (sp) { var v = Math.exp(-Math.pow(Math.hypot(px - sp[0], py - sp[1]) / (st * 1.3), 2)); if (v > bright * .8) bright = Math.max(bright, v * .95); });\n if (inside <= 0 && !sats.length) continue;\n if (inside <= 0 && bright < .2) continue;\n var fade = Math.min(1, inside * 3.2);\n var wave = .5 + .5 * Math.sin((px + py) / S * 9 - t * 2.2 + seed);\n if (type === 'architect') { var row = (t * 3.2) % (n + 8); wave = Math.max(wave * .6, Math.exp(-Math.pow(iy - row, 2) / 3)); }\n var base = st * ((small ? .26 : .14) + .12 * wave * fade + .16 * best);\n var r = base + st * .34 * bright;\n var col = grad((px - (cx - R)) / (2 * R) * .75 + (py - (cy - R)) / (2 * R) * .25);\n if (bright > .35) col = [244 - 30 * (1 - bright), 241 - 30 * (1 - bright), 234];\n ctx.fillStyle = rgba(col, Math.min(1, ((small ? .22 : .35) + (small ? .25 : .6) * wave * fade + .5 * best) * fade + bright));\n ctx.beginPath();\n if (type === 'sprinter' && bright < .35) { var w = r * (1.6 + 1.4 * (1 - ix / n)); capsule(ctx, px, py, w * 2, r * 2); } else ctx.arc(px, py, Math.min(r, st * .48), 0, 6.283);\n ctx.fill();\n }\n }\n var list = [], raf = 0, t0 = 0;\n var still = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;\n function frame(now) {\n raf = 0; if (!t0) t0 = now; var t = (now - t0) / 1000;\n list = list.filter(function (a) { return a.c.isConnected; });\n list.forEach(function (a) {\n var S = a.c.clientWidth; if (!S) return;\n var dpr = Math.min(2, window.devicePixelRatio || 1), px = Math.round(S * dpr);\n if (a.c.width !== px) { a.c.width = px; a.c.height = px; }\n var ctx = a.c.getContext('2d'); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, S, S);\n draw(ctx, S, still ? 1.3 : t, a.type, a.seed, still);\n });\n if (list.length && !still) raf = requestAnimationFrame(frame);\n }\n function add(c, type, seed) { list.push({ c: c, type: type, seed: seed || 1 }); if (!raf) raf = requestAnimationFrame(frame); }\n function scan(root) { [].slice.call((root || document).querySelectorAll('canvas[data-av]')).forEach(function (c) { if (c.__av) return; c.__av = 1; var p = c.getAttribute('data-av').split(':'); add(c, p[0], +(p[1] || 1)); }); }\n return { draw: draw, add: add, scan: scan };\n})();\n";
764
+
765
+ /* Draws a card onto a canvas (the dot patterns and, for saving, the whole
766
+ card). Shared with the profile page, which paints its card carousel with it. */
767
+ const PAINT_JS = String.raw`function drawOn(x,W,H,c){if(!c.pat)return;
768
+ const cols=28,step=W/cols,rows=Math.ceil(H/step),top=H*.13,bot=H*.47;x.fillStyle=c.dot||"#fff";
769
+ const dot=(i,j,s)=>{if(s<=.04)return;x.globalAlpha=Math.min(1,.18+.82*s);x.beginPath();x.arc((i+.5)*step,(j+.5)*step,step*.42*Math.min(1,s),0,7);x.fill()};
770
+ const rand=(i,j)=>{const t=Math.sin(i*127.1+j*311.7+c.id.length*17.3)*43758.5453;return t-Math.floor(t)};
771
+ const band=[];for(let j=0;j<rows;j++){const y=(j+.5)*step;if(y>top&&y<bot)band.push(j)}const nb=band.length,j0=band[0]||0;
772
+ const colChart=v=>{const m=Math.max(1,...v),per=cols/v.length;for(let i=0;i<cols;i++){const h=Math.round(v[Math.min(v.length-1,Math.floor(i/per))]/m*nb);for(let k=0;k<nb;k++)dot(i,j0+nb-1-k,k<h?1:.12)}};
773
+ const ring=(p,a,b)=>{const cx=W/2,cy=(top+bot)/2,R=(bot-top)/2*.98;for(const j of band)for(let i=0;i<cols;i++){const px=(i+.5)*step-cx,py=(j+.5)*step-cy,d=Math.hypot(px,py);let an=Math.atan2(px,-py);if(an<0)an+=2*Math.PI;const t=an/(2*Math.PI),w=R*(a+(b-a)*t);if(d<=R&&d>=R-w)dot(i,j,t<=p?1:.16)}};
774
+ switch(c.pat){case"months":colChart(D.months);break;case"hours":colChart(D.hours);break;case"sweep":ring(1,.06,.5);break;case"ring":ring(c.pct||0,.1,.5);break;
775
+ case"lanes":for(let k=0;k<(c.n||5);k++){const i=Math.round((k+.5)*cols/(c.n||5)),s0=Math.floor(rand(k,3)*nb*.45),e0=nb-Math.floor(rand(k,7)*nb*.3);for(let q=0;q<nb;q++)dot(i,j0+q,q>=s0&&q<e0?1:.14)}break;
776
+ case"share":{const f=c.frac||0;let n=0;for(const j of band)for(let i=0;i<cols;i++){dot(i,j,rand(i,j)<f?1:.16);n++}break}
777
+ case"stripes":{let acc=0;const cut=(c.shares||[1]).map(v=>acc+=v);for(const j of band)for(let i=0;i<cols;i++){const f=(i+.5)/cols,k=cut.findIndex(v=>f<=v+1e-9);dot(i,j,[1,.62,.42,.3,.18][k<0?4:k])}break}
778
+ case"rain":for(const j of band)for(let i=0;i<cols;i++){const v=rand(i,j);dot(i,j,v>.58?.95:v>.3?.35:.1)}break;
779
+ case"field":for(const j of band)for(let i=0;i<cols;i++){const f=(i/cols+(j-j0)/nb)/2;dot(i,j,.15+.85*Math.pow(rand(i,j),1.6)*f*1.4)}break}x.globalAlpha=1}
780
+ /* Save a card as a 1080x1350 PNG. Drawn with canvas on this machine; nothing is uploaded. */
781
+ function wrapText(g,t,maxW){const words=String(t).split(" "),out=[];let l="";for(const w of words){const n=l?l+" "+w:w;if(g.measureText(n).width>maxW&&l){out.push(l);l=w}else l=n}if(l)out.push(l);return out}
782
+ function paint(g,W,H,c){g.fillStyle=c.bg;g.fillRect(0,0,W,H);drawOn(g,W,H,c);if(c.av){const s=c.summary?W*.22:W*.46;g.save();g.translate(c.summary?W*.73:W*.27,c.summary?W*.05:W*.18);HourAvatar.draw(g,s,1.3,c.av,1,true);g.restore()}const P=W*.08,F="Geist, ui-sans-serif, system-ui, -apple-system, Helvetica, Arial, sans-serif";g.fillStyle=c.fg;g.textBaseline="alphabetic";
783
+ const grad=()=>{const gr=g.createLinearGradient(P,0,W-P,0);[["#4F88FF",0],["#B0ACFE",.38],["#FB65C2",.72],["#FD2980",1]].forEach(([k,o])=>gr.addColorStop(o,k));return gr};
784
+ /* footer */const fs=W*.034,fy=H-W*.07;g.font="600 "+fs+"px "+F;const r=W*.019;if(D.mark){const s=r*2.3;g.save();g.translate(P-r*.15,fy-fs*.35-s/2);g.scale(s/64,s/64);const mg=g.createLinearGradient(0,0,64,64);[["#4F88FF",0],["#B0ACFE",.38],["#FB65C2",.72],["#FD2980",1]].forEach(([k,o])=>mg.addColorStop(o,k));g.fillStyle=mg;g.fill(new Path2D(D.mark));g.restore();g.fillStyle=c.fg}else{g.lineWidth=W*.008;g.strokeStyle=grad();g.beginPath();g.arc(P+r,fy-fs*.35,r,0,7);g.stroke()}g.globalAlpha=.8;g.fillText("Hour",P+r*2+W*.016,fy);g.font="400 "+fs+"px ui-monospace, SFMono-Regular, Menlo, monospace";g.textAlign="right";g.fillText("usehour.com",W-P,fy);g.textAlign="left";g.globalAlpha=1;
785
+ let y=fy-W*.085;
786
+ if(c.summary){const cw=(W-2*P-W*.06)/2,rowH=W*.22;const rows=Math.ceil(c.stats.length/2);let top=y-rows*rowH+W*.02;
787
+ c.stats.forEach((s,k)=>{const x=P+(k%2)*(cw+W*.06),yy=top+Math.floor(k/2)*rowH;g.fillStyle=c.fg;g.globalAlpha=1;g.fillRect(x,yy,cw,W*.0035);g.globalAlpha=.75;g.font="400 "+W*.034+"px "+F;g.fillText(s[0],x,yy+W*.05);g.globalAlpha=1;g.font="650 "+W*.062+"px "+F;wrapText(g,s[1],cw).slice(0,2).forEach((l,i)=>g.fillText(l,x,yy+W*.115+i*W*.062))});
788
+ g.font="650 "+W*.115+"px "+F;g.fillStyle=grad();g.fillText(c.type,P,c.av?W*.37:W*.33);g.fillStyle=c.fg;g.globalAlpha=.85;g.font="500 "+W*.04+"px "+F;g.fillText((c.who?c.who.split(" ")[0]+" · ":"")+c.period,P,W*.16,c.av?W*.6:W-2*P);g.globalAlpha=1;return}
789
+ g.font="450 "+W*.056+"px "+F;const L=wrapText(g,c.line,(W-2*P)*.92);L.reverse().forEach((l,i)=>g.fillText(l,P,y-i*W*.07));y-=L.length*W*.07+W*.034;
790
+ let bs=parseFloat(c.bs||"19")/100*W;g.font="650 "+bs+"px "+F;let Bl=wrapText(g,c.big,W-2*P);while(Bl.length>1&&Bl[Bl.length-1].length<=2&&bs>W*.08){bs*=.92;g.font="650 "+bs+"px "+F;Bl=wrapText(g,c.big,W-2*P)}g.fillStyle=c.grad?grad():c.fg;Bl.reverse().forEach((l,i)=>g.fillText(l,P,y-i*bs*.9));
791
+ g.fillStyle=c.fg;g.globalAlpha=.85;g.font="500 "+W*.044+"px "+F;wrapText(g,c.ask,(W-2*P)*.62).forEach((l,i)=>g.fillText(l,P,W*.16+i*W*.055));g.globalAlpha=1;
792
+ if(c.rows){const d=W*.023,gap=W*.009,pw=W*.135,dx=W-P-pw-10*d-9*gap;const rs=Math.min(W*.083,W*.42/c.rows.length);c.rows.forEach((w,k)=>{const yy=W*.29+k*rs;g.fillStyle=c.fg;g.font="500 "+W*.04+"px "+F;g.fillText(w[0],P,yy);g.font="600 "+W*.04+"px "+F;g.textAlign="right";g.fillText(w[2],W-P,yy);g.textAlign="left";for(let q=0;q<10;q++){const on=q<Math.round(w[1]*10);g.globalAlpha=on?1:.14;g.fillStyle=on?(c.rc||"#8B5FD9"):c.fg;g.beginPath();g.arc(dx+q*(d+gap)+d/2,yy-W*.015,d/2,0,7);g.fill()}g.globalAlpha=1})}}
793
+ `;
530
794
  function pageHTML(m, C, o = {}) {
531
- const api = o.api || null, examples = api ? o.examples || [] : [];
532
- const data = JSON.stringify({ hours: m.hours, months: m.months.map((x) => x.prompts), cards: C, m: api ? m : null, examples, api, suggest: api ? o.suggest || "" : "" }).replace(/</g, "\\u003c").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
795
+ const avk = (C.find((c) => c.av) || {}).av || "pair";
796
+ /* mode: "local" (the cards page on your computer), or on a profile:
797
+ "owner" (you, with Save) or "visitor" (read only, with "Make yours"). */
798
+ const mode = o.mode || "local", onSite = mode !== "local", first = String(o.who || "").split(" ")[0];
799
+ const api = onSite ? null : o.api || null, examples = api ? o.examples || [] : [];
800
+ const yours = mode === "visitor" ? `${first}'s` : "Your";
801
+ const data = JSON.stringify({ mark: MARK_PATH, hours: m.hours, months: m.months.map((x) => x.prompts), cards: C, m: api ? m : null, examples, api, canSave: mode !== "visitor", heads: headlines(m, mode === "visitor" ? first : null), suggest: api ? o.suggest || "" : "" }).replace(/</g, "\\u003c").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
533
802
  /* The page may talk to exactly one place: Hour's profile endpoint, and only
534
803
  when you press the button. With --no-share it may talk to nothing. */
535
804
  const connect = api ? new URL(api).origin : "'none'";
@@ -537,6 +806,7 @@ function pageHTML(m, C, o = {}) {
537
806
  const meta = { period: `${monthYear(m.window.first)} – ${monthYear(m.window.last)}`, sessions: m.sessions.total.toLocaleString("en"), prompts: m.prompts.total.toLocaleString("en") };
538
807
  const LOCK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="5" y="11" width="14" height="9" rx="2"/><path d="M8 11V8a4 4 0 0 1 8 0v3"/></svg>';
539
808
  const DL = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 4v11M7 10l5 5 5-5M5 20h14"/></svg>';
809
+ const NOTE = onSite ? `Counted on ${escH(first)}'s computer. No prompts shared.` : "Made on this computer. Nothing has been sent.";
540
810
  const csp = `default-src 'none'; img-src data: blob:; font-src data:; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src ${connect}; form-action 'none'; base-uri 'none'`;
541
811
  return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
542
812
  <meta http-equiv="Content-Security-Policy" content="${csp}">
@@ -551,14 +821,20 @@ function pageHTML(m, C, o = {}) {
551
821
  .note{display:flex;align-items:center;gap:8px;font-size:14px;color:var(--ink-3);line-height:1.5}.note svg{flex:none;width:15px;height:15px}
552
822
  .btn{display:inline-flex;align-items:center;justify-content:center;gap:9px;height:52px;padding:0 22px;border-radius:999px;border:0;background:var(--ink);color:var(--paper);font:600 16px var(--sans);cursor:pointer;text-decoration:none}
553
823
  .btn.ghost{background:#fff;color:var(--ink);font-weight:500;box-shadow:inset 0 0 0 1px rgba(11,11,10,.14)}.btn svg{width:17px;height:17px;flex:none}.btn[disabled]{opacity:.6;cursor:wait}
554
- .hero{margin-top:40px;display:grid;grid-template-columns:minmax(0,420px) minmax(0,1fr);gap:clamp(28px,5vw,56px);align-items:stretch}
824
+ .hero{margin-top:48px;display:grid;grid-template-columns:minmax(0,420px) minmax(0,1fr);gap:clamp(32px,6vw,80px);align-items:center}
555
825
  .hero-r{display:flex;flex-direction:column}
556
826
  .hero h1{margin:4px 0 0;font-size:clamp(48px,6.4vw,76px);line-height:.9;letter-spacing:-.06em;font-weight:650}
557
827
  .hero p.sub{margin:16px 0 0;font-size:19px;line-height:1.4;color:#3A3935}
558
- .acts{margin-top:22px;display:flex;gap:10px;flex-wrap:wrap}
559
- .claimbox{margin-top:auto;border-radius:26px;background:#8B5FD9;color:#fff;padding:28px 28px 26px;position:relative;overflow:hidden}
828
+ .acts{margin-top:32px;display:flex;gap:12px;flex-wrap:wrap}
829
+ .glance{margin-top:56px;display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:16px;align-items:stretch}.glance.solo{grid-template-columns:1fr}
830
+ .heads-card{border-radius:26px;background:#fff;box-shadow:inset 0 0 0 1px rgba(11,11,10,.08);padding:30px 32px}.heads-card .k{font-size:15px;font-weight:500;color:#6C6A64}
831
+ .claimbox{border-radius:26px;background:#8B5FD9;color:#fff;padding:28px 28px 26px;position:relative;overflow:hidden}
560
832
  .claimbox:before{content:"";position:absolute;right:-20px;top:-20px;width:300px;height:190px;background-image:radial-gradient(circle,rgba(255,255,255,.9) 0 3px,transparent 3.4px);background-size:14px 14px;-webkit-mask-image:radial-gradient(circle at 80% 20%,#000 10%,transparent 68%);mask-image:radial-gradient(circle at 80% 20%,#000 10%,transparent 68%)}
561
- .claimbox>*{position:relative}.claimbox .k{font-size:15px;font-weight:500;opacity:.9}.claimbox .h{margin-top:8px;font-size:clamp(32px,3.6vw,44px);line-height:.95;letter-spacing:-.05em;font-weight:650}
833
+ .claimbox>*{position:relative}
834
+ .claimbox a.cta{display:inline-flex;align-items:center;height:48px;margin-top:14px;padding:0 22px;border-radius:999px;background:#fff;color:var(--ink);font:600 15px var(--sans);text-decoration:none}
835
+ .heads{list-style:none;margin:18px 0 0;padding:0;display:grid;gap:16px}.heads li{position:relative;padding-left:24px;font-size:clamp(17px,1.6vw,20px);line-height:1.35;letter-spacing:-.015em;font-weight:500}.heads li:before{content:"";position:absolute;left:0;top:.45em;width:10px;height:10px;border-radius:50%;background:linear-gradient(135deg,#4F88FF,#B0ACFE 38%,#FB65C2 72%,#FD2980)}
836
+ .nosave .save,.nosave #sts{display:none}
837
+ a.btn{text-decoration:none;display:inline-flex;align-items:center}.claimbox .k{font-size:15px;font-weight:500;opacity:.9}.claimbox .h{margin-top:8px;font-size:clamp(32px,3.6vw,44px);line-height:.95;letter-spacing:-.05em;font-weight:650}
562
838
  .claimbox .pill{display:inline-flex;margin-top:14px;padding:9px 15px;border-radius:999px;background:rgba(11,11,10,.22);font:16px var(--mono)}.claimbox .pill span{opacity:.7}
563
839
  .claimbox p{margin:12px 0 0;max-width:460px;font-size:15.5px;line-height:1.42}
564
840
  .claimbox button{margin-top:18px;height:52px;padding:0 24px;border-radius:999px;border:0;background:#fff;color:var(--ink);font:600 16px var(--sans);cursor:pointer}
@@ -567,9 +843,9 @@ h2.sec{margin:64px 0 18px;font-size:30px;letter-spacing:-.04em;font-weight:650}
567
843
  .grid .card{cursor:pointer;transition:transform .15s}.grid .card:hover{transform:translateY(-3px)}
568
844
  .save{display:flex;align-items:center;justify-content:center;gap:7px;width:100%;height:40px;margin-top:6px;border:0;background:none;border-radius:999px;font:500 13px var(--sans);color:#3A3935;cursor:pointer}.save:hover{background:rgba(11,11,10,.05)}.save svg{width:14px;height:14px}
569
845
  .tips{margin-top:36px;display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px}.tips div{border-radius:18px;background:#fff;box-shadow:inset 0 0 0 1px rgba(11,11,10,.08);padding:20px 22px}.tips b{display:block;font-size:16px}.tips span{display:block;margin-top:4px;font-size:14px;line-height:1.45;color:#3A3935}
570
- @media(max-width:760px){.hero{grid-template-columns:1fr}.hero-r{order:-1}.claimbox{margin-top:24px}.tips{grid-template-columns:1fr}h2.sec{margin-top:48px;font-size:24px}}
846
+ @media(max-width:760px){.hero{grid-template-columns:1fr;margin-top:28px;gap:28px}.hero-r{order:-1}.glance{grid-template-columns:1fr;margin-top:28px}.heads-card{padding:24px 22px}.claimbox{padding:24px 22px}.tips{grid-template-columns:1fr}h2.sec{margin-top:48px;font-size:24px}}
571
847
  .intro{position:fixed;inset:0;z-index:40;background:var(--ink);color:#F4F1EA;display:flex;flex-direction:column;padding:26px clamp(20px,4vw,48px);overflow:auto}.intro[hidden],.story[hidden]{display:none}
572
- .intro:before{content:"";position:absolute;inset:0;background-image:radial-gradient(circle,rgba(244,241,234,.85) 0 3px,transparent 3.4px);background-size:18px 18px;-webkit-mask-image:radial-gradient(ellipse at 78% 40%,#000 0%,transparent 55%);mask-image:radial-gradient(ellipse at 78% 40%,#000 0%,transparent 55%);pointer-events:none}
848
+ .intro:before{content:"";position:absolute;inset:0;background-image:radial-gradient(circle,rgba(244,241,234,.85) 0 3px,transparent 3.4px);background-size:18px 18px;-webkit-mask-image:radial-gradient(circle at 78% 50%,transparent 0 17vw,rgba(0,0,0,.55) 21vw,transparent 31vw);mask-image:radial-gradient(circle at 78% 50%,transparent 0 17vw,rgba(0,0,0,.55) 21vw,transparent 31vw);pointer-events:none}
573
849
  .intro>*{position:relative}.intro .top .note{color:#F4F1EA;opacity:.75}
574
850
  .intro .mid{margin:auto 0;max-width:720px;padding:40px 0}.intro .meta{font-size:clamp(15px,1.6vw,18px);font-weight:500;opacity:.8}
575
851
  .intro h1{margin:18px 0 0;font-size:clamp(56px,9vw,112px);line-height:.88;letter-spacing:-.065em;font-weight:650}
@@ -590,79 +866,58 @@ h2.sec{margin:64px 0 18px;font-size:30px;letter-spacing:-.04em;font-weight:650}
590
866
  @media(max-width:640px){.nb{display:none}}
591
867
  .st-acts{margin-top:14px;display:flex;gap:10px}.st-acts button{display:inline-flex;align-items:center;gap:8px;height:44px;padding:0 18px;border-radius:999px;border:0;background:rgba(244,241,234,.1);color:#F4F1EA;font:500 14px var(--sans);cursor:pointer}.st-acts svg{width:16px;height:16px}
592
868
  .card{container-type:inline-size;aspect-ratio:4/5;width:100%;border-radius:5.5%/4.4%;overflow:hidden;position:relative;background:var(--bg);color:var(--fg);isolation:isolate}
593
- .card canvas{position:absolute;inset:0;width:100%;height:100%}
869
+ .card canvas{position:absolute;inset:0;width:100%;height:100%}.card canvas.av{inset:auto;left:27cqw;top:18cqw;width:46cqw;height:46cqw}.card canvas.av.s{left:auto;right:5cqw;top:5cqw;width:22cqw;height:22cqw}.card canvas.av.s~.in .ask{max-width:none;padding-right:23cqw;min-height:16cqw;font-size:3.7cqw;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.intro .iav{position:absolute;right:clamp(24px,6vw,96px);top:50%;transform:translateY(-50%);width:min(34vw,440px);height:min(34vw,440px);pointer-events:none}@media (max-width:860px){.intro .iav{position:static;transform:none;width:120px;height:120px;margin:8px 0 -8px}}
594
870
  .card .in{position:absolute;inset:0;padding:9cqw 8cqw 7cqw;display:flex;flex-direction:column;z-index:1}
595
871
  .ask{font-size:4.4cqw;font-weight:500;opacity:.85;margin-top:3cqw;max-width:62%}
596
872
  .big{margin-top:auto;font-size:var(--bs,19cqw);line-height:.9;letter-spacing:-.055em;font-weight:650;text-wrap:balance}
597
873
  .line{font-size:5.6cqw;line-height:1.25;letter-spacing:-.015em;margin-top:3.4cqw;max-width:92%;text-wrap:balance}
598
874
  .ft{display:flex;justify-content:space-between;align-items:center;margin-top:6cqw;font-size:3.4cqw;opacity:.8}.ft .b{display:flex;align-items:center;gap:1.6cqw;font-weight:600}.ft svg{width:4.6cqw;height:4.6cqw}.ft .u{font-family:var(--mono)}
599
- .sum{margin-top:auto;display:grid;grid-template-columns:1fr 1fr;gap:4.5cqw 5cqw}.sum div{border-top:.35cqw solid currentColor;padding-top:2cqw}.sum small{display:block;font-size:3.4cqw;opacity:.75;margin-bottom:.6cqw}.sum b{font-size:6.2cqw;letter-spacing:-.035em;font-weight:650;line-height:1}
875
+ .sum{margin-top:auto;display:grid;grid-template-columns:1fr 1fr;gap:5.5cqw 6cqw}.sum div{border-top:.35cqw solid currentColor;padding-top:2.6cqw}.sum small{display:block;font-size:3.4cqw;opacity:.75;margin-bottom:1.4cqw}.sum b{font-size:6.2cqw;letter-spacing:-.035em;font-weight:650;line-height:1}
600
876
  .arch{font-size:11.5cqw;line-height:.92;letter-spacing:-.05em;font-weight:650;margin:2cqw 0 6cqw}
601
- .rows{margin-top:6cqw;display:grid;gap:3.2cqw}.rows div{display:grid;grid-template-columns:minmax(0,1fr) auto 9cqw;align-items:center;gap:2.6cqw;font-size:4cqw;font-weight:500;white-space:nowrap}.rows div>span{overflow:hidden;text-overflow:ellipsis}.rows em{display:flex;gap:.9cqw}.rows u{width:2.3cqw;height:2.3cqw;border-radius:50%;background:currentColor;opacity:.14}.rows u.on{opacity:1;background:#8B5FD9}.rows b{text-align:right;font-weight:600}
877
+ .rows{margin-top:6cqw;display:grid;gap:3.2cqw}.rows div{display:grid;grid-template-columns:minmax(0,1fr) auto 9cqw;align-items:center;gap:2.6cqw;font-size:4cqw;font-weight:500;white-space:nowrap}.rows div>span{overflow:hidden;text-overflow:ellipsis}.rows em{display:flex;gap:.9cqw}.rows u{width:2.3cqw;height:2.3cqw;border-radius:50%;background:currentColor;opacity:.14}.rows u.on{opacity:1;background:var(--rc,#8B5FD9)}.rows.many{gap:1.5cqw;margin-top:4cqw}.rows b{text-align:right;font-weight:600}
602
878
  ${api ? CLAIM_CSS : ""}
603
879
  .grad{background:linear-gradient(100deg,#4F88FF 0%,#B0ACFE 38%,#FB65C2 72%,#FD2980 100%);-webkit-background-clip:text;background-clip:text;color:transparent}
604
880
  </style></head><body>
605
881
  <svg width="0" height="0" style="position:absolute" aria-hidden="true"><defs><linearGradient id="ob" x1="0" y1="0" x2="1" y2="1"><stop offset="0%" stop-color="#4F88FF"/><stop offset="38%" stop-color="#B0ACFE"/><stop offset="72%" stop-color="#FB65C2"/><stop offset="100%" stop-color="#FD2980"/></linearGradient><symbol id="mark" viewBox="0 0 64 64"><path fill="url(#ob)" d="${MARK_PATH}"/></symbol></defs></svg>
606
882
  <div class="intro" id="intro">
607
- <div class="top"><div class="brand"><svg viewBox="0 0 64 64"><use href="#mark"/></svg>Hour</div><div class="note">${LOCK}Made on this computer. Nothing has been sent.</div></div>
608
- <div class="mid"><div class="meta">${escH(meta.period)} · ${meta.sessions} sessions · ${meta.prompts} prompts</div>
609
- <h1>Your year with <span class="grad">AI</span> is ready.</h1>
610
- <p>${C.length} cards about how you build: your type, your hours, how you brief the agent. About a minute.</p>
611
- <div class="go"><button type="button" class="play" id="play"><svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M7 4.5v15l13-7.5z"/></svg>Play my year</button><button type="button" class="skip" id="skip">Skip to all cards</button></div></div>
883
+ <div class="top"><div class="brand"><svg viewBox="0 0 64 64"><use href="#mark"/></svg>Hour</div><div class="note">${LOCK}${NOTE}</div></div>
884
+ <canvas class="iav" data-av="${avk}:1" aria-hidden="true"></canvas><div class="mid"><div class="meta">${escH(meta.period)} · ${meta.sessions} sessions · ${meta.prompts} prompts</div>
885
+ <h1>${yours} year with <span class="grad">AI</span>${onSite ? "." : " is ready."}</h1>
886
+ <p>${C.length} cards about how ${mode === "visitor" ? escH(first) + " builds" : "you build"}: the type, the hours, how the agent gets briefed. About a minute.</p>
887
+ <div class="go"><button type="button" class="play" id="play"><svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M7 4.5v15l13-7.5z"/></svg>${mode === "visitor" ? `Play ${escH(first)}'s year` : "Play my year"}</button><button type="button" class="skip" id="skip">Skip to all cards</button></div></div>
612
888
  <div class="tip">Tip: use the arrow keys, or tap the left and right of a card. Space pauses.</div>
613
889
  </div>
614
890
  <div class="story" id="story" hidden role="dialog" aria-modal="true" aria-label="Your year with AI">
615
- <div class="top"><div class="brand"><svg viewBox="0 0 64 64"><use href="#mark"/></svg>Your year with AI</div><div class="cnt"><span id="stn"></span><button type="button" class="rb" id="stx" aria-label="Close"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg></button></div></div>
891
+ <div class="top"><div class="brand"><svg viewBox="0 0 64 64"><use href="#mark"/></svg>${yours} year with AI</div><div class="cnt"><span id="stn"></span><button type="button" class="rb" id="stx" aria-label="Close"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg></button></div></div>
616
892
  <div class="st-row"><button type="button" class="rb nb" id="stp" aria-label="Previous card"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 5l-7 7 7 7"/></svg></button>
617
893
  <div class="st-col"><div class="st-bars" id="bars"></div><div class="st-slot"><div id="slot"></div><button type="button" class="st-hit l" id="hl" aria-label="Previous"></button><button type="button" class="st-hit r" id="hr" aria-label="Next"></button></div></div>
618
894
  <button type="button" class="rb nb fw" id="stf" aria-label="Next card"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 5l7 7-7 7"/></svg></button></div>
619
895
  <div class="st-acts" id="sta"><button type="button" id="sts">${DL}Save this card</button><button type="button" id="stz"><svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><rect x="6" y="5" width="4" height="14" rx="1"/><rect x="14" y="5" width="4" height="14" rx="1"/></svg><span>Pause</span></button></div>
620
896
  </div>
621
897
  <div class="wrap">
622
- <div class="top"><div class="brand"><svg viewBox="0 0 64 64"><use href="#mark"/></svg>Hour</div><div class="note" id="topnote">${LOCK}Made on this computer. Nothing has been sent.</div></div>
898
+ <div class="top"><div class="brand"><svg viewBox="0 0 64 64"><use href="#mark"/></svg>Hour</div><div class="note" id="topnote">${LOCK}${NOTE}</div></div>
623
899
  <section class="hero"><div id="sumcard"></div><div class="hero-r">
624
- <h1>Your year with AI</h1><p class="sub">${C.length} cards from ${meta.sessions} sessions. Save the ones you like and post them.</p>
625
- <div class="acts"><button type="button" class="btn" id="saveall">${DL}Save all cards</button><button type="button" class="btn ghost" id="replay"><svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M7 4.5v15l13-7.5z"/></svg>Play again</button></div>
626
- ${api ? `<div class="claimbox" id="claimbar"><div class="k">Next: make it a profile</div><div class="h">Claim your handle.</div><div class="pill"><span>usehour.com/</span>@<b id="clhandle">you</b></div><p>A profile companies hiring AI builders actually want to read. First come, first served. Private until you say otherwise.</p><button type="button" id="claimb">Claim your handle</button></div>` : ""}
900
+ <h1>${yours} year with AI</h1><p class="sub">${C.length} cards from ${meta.sessions} sessions.${mode === "visitor" ? "" : " Save the ones you like and post them."}</p>
901
+ <div class="acts">${mode === "visitor" ? "" : `<button type="button" class="btn" id="saveall">${DL}Save all cards</button>`}<button type="button" class="btn${mode === "visitor" ? "" : " ghost"}" id="replay"><svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M7 4.5v15l13-7.5z"/></svg>${mode === "visitor" ? `Play ${escH(first)}'s year` : "Play again"}</button>${onSite && o.profileUrl ? `<a class="btn ghost" href="${escH(o.profileUrl)}">${mode === "visitor" ? `${escH(first)}'s profile` : "Back to my profile"}</a>` : ""}</div>
627
902
  </div></section>
628
- <h2 class="sec">All your cards</h2><div class="grid" id="grid"></div>
629
- <div class="tips"><div><b>What to post</b><span>Your summary card or your builder type, with one line on what surprised you.</span></div><div><b>Where they go</b><span>Saved cards land in your Downloads, sized for LinkedIn and X.</span></div><div><b>Come back any time</b><span>This page lives in the hour-wrapped folder where you ran the command. The numbers are in wrapped.json.</span></div></div>
903
+ <section class="glance${mode === "owner" ? " solo" : ""}"><div class="heads-card"><div class="k">${mode === "visitor" ? `${escH(first)} in four lines` : "You in four lines"}</div><ul class="heads" id="heads"></ul></div>
904
+ ${mode === "visitor" ? `<div class="claimbox"><div class="k">Made with Hour</div><div class="h">What does your year with AI look like?</div><p>Cards like these, from your own Claude Code and Codex logs. Counted on your computer.</p><a class="cta" href="${escH(o.makeUrl || "https://usehour.com/wrapped")}">Make yours</a></div>` : ""}
905
+ ${api ? `<div class="claimbox" id="claimbar"><div class="k">Next: make it a profile</div><div class="h">Claim your handle.</div><div class="pill"><span>usehour.com/</span>@<b id="clhandle">you</b></div><p>A profile companies hiring AI builders actually want to read. First come, first served. Private until you say otherwise.</p><button type="button" id="claimb">Claim your handle</button></div>` : ""}
906
+ </section>
907
+ <h2 class="sec">${mode === "visitor" ? `All of ${escH(first)}\u2019s cards` : "All your cards"}</h2><div class="grid" id="grid"></div>
908
+ ${mode === "visitor" ? "" : `<div class="tips"><div><b>What to post</b><span>Your summary card or your builder type, with one line on what surprised you.</span></div><div><b>Where they go</b><span>Saved cards land in your Downloads, sized for LinkedIn and X.</span></div><div><b>Come back any time</b><span>${onSite ? "Your cards live on your profile now: open it and press Your cards." : "This page lives in the hour-wrapped folder where you ran the command, and on your profile once you claim one."}</span></div></div>`}
630
909
  ${api ? CLAIM_HTML : ""}
631
910
  </div>
632
911
  <script>
912
+ ${AV_JS}
633
913
  const D=${data};
634
914
  const esc=s=>String(s).replace(/[&<>"]/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c]));
635
- function el(c){const d=document.createElement("article");d.className="card";d.style.setProperty("--bg",c.bg);d.style.setProperty("--fg",c.fg);if(c.bs)d.style.setProperty("--bs",c.bs);
915
+ function el(c){const d=document.createElement("article");d.className="card";d.style.setProperty("--bg",c.bg);d.style.setProperty("--fg",c.fg);if(c.bs)d.style.setProperty("--bs",c.bs);if(c.rc)d.style.setProperty("--rc",c.rc);
636
916
  const ft='<div class="ft"><span class="b"><svg viewBox="0 0 64 64"><use href="#mark"/></svg>Hour</span><span class="u">usehour.com</span></div>';
637
- d.innerHTML='<canvas aria-hidden="true"></canvas><div class="in">'+(c.summary?('<div class="ask">'+esc(c.who?c.who+" \\u00B7 ":"")+esc(c.period)+'</div><div class="arch grad">'+esc(c.type)+'</div><div class="sum">'+c.stats.map(s=>'<div><small>'+esc(s[0])+'</small><b>'+esc(s[1])+'</b></div>').join("")+'</div>'):('<div class="ask">'+esc(c.ask)+'</div>'+(c.rows?'<div class="rows">'+c.rows.map(w=>'<div><span>'+esc(w[0])+'</span><em>'+Array.from({length:10},(_,k)=>'<u'+(k<Math.round(w[1]*10)?' class="on"':'')+'></u>').join("")+'</em><b>'+esc(w[2])+'</b></div>').join("")+'</div>':'')+'<div class="big'+(c.grad?" grad":"")+'">'+esc(c.big)+'</div><div class="line">'+esc(c.line)+'</div>'))+ft+'</div>';
638
- requestAnimationFrame(()=>draw(d,c));return d}
917
+ d.innerHTML='<canvas aria-hidden="true"></canvas>'+(c.av?'<canvas class="av'+(c.summary?' s':'')+'" aria-hidden="true"></canvas>':'')+'<div class="in">'+(c.summary?('<div class="ask">'+esc(c.who?c.who.split(" ")[0]+" \\u00B7 ":"")+esc(c.period)+'</div><div class="arch grad">'+esc(c.type)+'</div><div class="sum">'+c.stats.map(s=>'<div><small>'+esc(s[0])+'</small><b>'+esc(s[1])+'</b></div>').join("")+'</div>'):('<div class="ask">'+esc(c.ask)+'</div>'+(c.rows?'<div class="rows'+(c.rows.length>5?' many':'')+'">'+c.rows.map(w=>'<div><span>'+esc(w[0])+'</span><em>'+Array.from({length:10},(_,k)=>'<u'+(k<Math.round(w[1]*10)?' class="on"':'')+'></u>').join("")+'</em><b>'+esc(w[2])+'</b></div>').join("")+'</div>':'')+'<div class="big'+(c.grad?" grad":"")+'">'+esc(c.big)+'</div><div class="line">'+esc(c.line)+'</div>'))+ft+'</div>';
918
+ requestAnimationFrame(()=>draw(d,c));if(c.av)HourAvatar.add(d.querySelector("canvas.av"),c.av,1);return d}
639
919
  function draw(card,c){const cv=card.querySelector("canvas"),r=card.getBoundingClientRect(),dpr=Math.min(devicePixelRatio||1,2),W=Math.max(1,r.width),H=Math.max(1,r.height);cv.width=W*dpr;cv.height=H*dpr;const g=cv.getContext("2d");g.scale(dpr,dpr);drawOn(g,W,H,c)}
640
- function drawOn(x,W,H,c){if(!c.pat)return;
641
- const cols=28,step=W/cols,rows=Math.ceil(H/step),top=H*.13,bot=H*.47;x.fillStyle=c.dot||"#fff";
642
- const dot=(i,j,s)=>{if(s<=.04)return;x.globalAlpha=Math.min(1,.18+.82*s);x.beginPath();x.arc((i+.5)*step,(j+.5)*step,step*.42*Math.min(1,s),0,7);x.fill()};
643
- const rand=(i,j)=>{const t=Math.sin(i*127.1+j*311.7+c.id.length*17.3)*43758.5453;return t-Math.floor(t)};
644
- const band=[];for(let j=0;j<rows;j++){const y=(j+.5)*step;if(y>top&&y<bot)band.push(j)}const nb=band.length,j0=band[0]||0;
645
- const colChart=v=>{const m=Math.max(1,...v),per=cols/v.length;for(let i=0;i<cols;i++){const h=Math.round(v[Math.min(v.length-1,Math.floor(i/per))]/m*nb);for(let k=0;k<nb;k++)dot(i,j0+nb-1-k,k<h?1:.12)}};
646
- const ring=(p,a,b)=>{const cx=W/2,cy=(top+bot)/2,R=(bot-top)/2*.98;for(const j of band)for(let i=0;i<cols;i++){const px=(i+.5)*step-cx,py=(j+.5)*step-cy,d=Math.hypot(px,py);let an=Math.atan2(px,-py);if(an<0)an+=2*Math.PI;const t=an/(2*Math.PI),w=R*(a+(b-a)*t);if(d<=R&&d>=R-w)dot(i,j,t<=p?1:.16)}};
647
- switch(c.pat){case"months":colChart(D.months);break;case"hours":colChart(D.hours);break;case"sweep":ring(1,.06,.5);break;case"ring":ring(c.pct||0,.1,.5);break;
648
- case"lanes":for(let k=0;k<(c.n||5);k++){const i=Math.round((k+.5)*cols/(c.n||5)),s0=Math.floor(rand(k,3)*nb*.45),e0=nb-Math.floor(rand(k,7)*nb*.3);for(let q=0;q<nb;q++)dot(i,j0+q,q>=s0&&q<e0?1:.14)}break;
649
- case"share":{const f=c.frac||0;let n=0;for(const j of band)for(let i=0;i<cols;i++){dot(i,j,rand(i,j)<f?1:.16);n++}break}
650
- case"stripes":{let acc=0;const cut=(c.shares||[1]).map(v=>acc+=v);for(const j of band)for(let i=0;i<cols;i++){const f=(i+.5)/cols,k=cut.findIndex(v=>f<=v+1e-9);dot(i,j,[1,.62,.42,.3,.18][k<0?4:k])}break}
651
- case"rain":for(const j of band)for(let i=0;i<cols;i++){const v=rand(i,j);dot(i,j,v>.58?.95:v>.3?.35:.1)}break;
652
- case"field":for(const j of band)for(let i=0;i<cols;i++){const f=(i/cols+(j-j0)/nb)/2;dot(i,j,.15+.85*Math.pow(rand(i,j),1.6)*f*1.4)}break}x.globalAlpha=1}
653
- /* Save a card as a 1080x1350 PNG. Drawn with canvas on this machine; nothing is uploaded. */
654
- function wrapText(g,t,maxW){const words=String(t).split(" "),out=[];let l="";for(const w of words){const n=l?l+" "+w:w;if(g.measureText(n).width>maxW&&l){out.push(l);l=w}else l=n}if(l)out.push(l);return out}
655
- function paint(g,W,H,c){g.fillStyle=c.bg;g.fillRect(0,0,W,H);drawOn(g,W,H,c);const P=W*.08,F="Geist, ui-sans-serif, system-ui, -apple-system, Helvetica, Arial, sans-serif";g.fillStyle=c.fg;g.textBaseline="alphabetic";
656
- const grad=()=>{const gr=g.createLinearGradient(P,0,W-P,0);[["#4F88FF",0],["#B0ACFE",.38],["#FB65C2",.72],["#FD2980",1]].forEach(([k,o])=>gr.addColorStop(o,k));return gr};
657
- /* footer */const fs=W*.034,fy=H-W*.07;g.globalAlpha=.8;g.font="600 "+fs+"px "+F;const r=W*.019;g.lineWidth=W*.008;g.strokeStyle=grad();g.beginPath();g.arc(P+r,fy-fs*.35,r,0,7);g.stroke();g.fillText("Hour",P+r*2+W*.016,fy);g.font="400 "+fs+"px ui-monospace, SFMono-Regular, Menlo, monospace";g.textAlign="right";g.fillText("usehour.com",W-P,fy);g.textAlign="left";g.globalAlpha=1;
658
- let y=fy-W*.085;
659
- if(c.summary){const cw=(W-2*P-W*.05)/2,rowH=W*.2;const rows=Math.ceil(c.stats.length/2);let top=y-rows*rowH+W*.02;
660
- c.stats.forEach((s,k)=>{const x=P+(k%2)*(cw+W*.05),yy=top+Math.floor(k/2)*rowH;g.fillStyle=c.fg;g.globalAlpha=1;g.fillRect(x,yy,cw,W*.0035);g.globalAlpha=.75;g.font="400 "+W*.034+"px "+F;g.fillText(s[0],x,yy+W*.05);g.globalAlpha=1;g.font="650 "+W*.062+"px "+F;wrapText(g,s[1],cw).slice(0,2).forEach((l,i)=>g.fillText(l,x,yy+W*.115+i*W*.062))});
661
- g.font="650 "+W*.115+"px "+F;g.fillStyle=grad();g.fillText(c.type,P,W*.33);g.fillStyle=c.fg;g.globalAlpha=.85;g.font="500 "+W*.044+"px "+F;g.fillText((c.who?c.who+" · ":"")+c.period,P,W*.16);g.globalAlpha=1;return}
662
- g.font="450 "+W*.056+"px "+F;const L=wrapText(g,c.line,(W-2*P)*.92);L.reverse().forEach((l,i)=>g.fillText(l,P,y-i*W*.07));y-=L.length*W*.07+W*.034;
663
- let bs=parseFloat(c.bs||"19")/100*W;g.font="650 "+bs+"px "+F;let Bl=wrapText(g,c.big,W-2*P);while(Bl.length>1&&Bl[Bl.length-1].length<=2&&bs>W*.08){bs*=.92;g.font="650 "+bs+"px "+F;Bl=wrapText(g,c.big,W-2*P)}g.fillStyle=c.grad?grad():c.fg;Bl.reverse().forEach((l,i)=>g.fillText(l,P,y-i*bs*.9));
664
- g.fillStyle=c.fg;g.globalAlpha=.85;g.font="500 "+W*.044+"px "+F;wrapText(g,c.ask,(W-2*P)*.62).forEach((l,i)=>g.fillText(l,P,W*.16+i*W*.055));g.globalAlpha=1;
665
- if(c.rows){const d=W*.023,gap=W*.009,pw=W*.135,dx=W-P-pw-10*d-9*gap;c.rows.forEach((w,k)=>{const yy=W*.29+k*W*.083;g.fillStyle=c.fg;g.font="500 "+W*.04+"px "+F;g.fillText(w[0],P,yy);g.font="600 "+W*.04+"px "+F;g.textAlign="right";g.fillText(w[2],W-P,yy);g.textAlign="left";for(let q=0;q<10;q++){const on=q<Math.round(w[1]*10);g.globalAlpha=on?1:.14;g.fillStyle=on?"#8B5FD9":c.fg;g.beginPath();g.arc(dx+q*(d+gap)+d/2,yy-W*.015,d/2,0,7);g.fill()}g.globalAlpha=1})}}
920
+ ${PAINT_JS}
666
921
  async function save(c){try{await document.fonts.ready}catch(e){}const cv=document.createElement("canvas");cv.width=1080;cv.height=1350;paint(cv.getContext("2d"),1080,1350,c);const a=document.createElement("a");a.download="hour-"+c.id+".png";a.href=cv.toDataURL("image/png");document.body.appendChild(a);a.click();a.remove()}
667
922
  let cur=0,timer=null,t0=0,paused=false;const DUR=6000;
668
923
  const $=id=>document.getElementById(id),slot=$("slot"),bars=$("bars"),grid=$("grid");
@@ -682,16 +937,116 @@ $("play").onclick=()=>openStory(0);$("skip").onclick=closeStory;$("replay").oncl
682
937
  $("stp").onclick=$("hl").onclick=()=>show(cur-1);$("stf").onclick=$("hr").onclick=()=>go(cur+1);$("stz").onclick=togglePause;$("sts").onclick=()=>save(D.cards[cur]);
683
938
  addEventListener("keydown",e=>{const o=$("cl");if(o&&!o.hidden)return;if($("story").hidden){if(!$("intro").hidden&&(e.key==="Enter"||e.key===" ")){e.preventDefault();openStory(0)}return}
684
939
  if(e.key==="ArrowRight")go(cur+1);else if(e.key==="ArrowLeft")show(cur-1);else if(e.key===" "){e.preventDefault();togglePause()}else if(e.key==="Escape")closeStory()});
940
+ (D.heads||[]).forEach(t=>{const li=document.createElement("li");li.textContent=t;$("heads").appendChild(li)});if(!D.canSave)document.body.classList.add("nosave");
685
941
  const SUM=D.cards.find(c=>c.summary);if(SUM){const e=el(SUM);e.style.cursor="pointer";e.onclick=()=>openStory(D.cards.indexOf(SUM));$("sumcard").appendChild(e)}
686
942
  const DLI='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 4v11M7 10l5 5 5-5M5 20h14"/></svg>';
687
943
  D.cards.forEach((c,k)=>{const w=document.createElement("div");const e=el(c);e.onclick=()=>openStory(k);const s=document.createElement("button");s.type="button";s.className="save";s.innerHTML=DLI+"Save";s.onclick=()=>save(c);w.append(e,s);grid.appendChild(w)});
688
- $("saveall").onclick=async()=>{const b=$("saveall"),t=b.innerHTML;b.disabled=true;for(let i=0;i<D.cards.length;i++){b.textContent="Saving "+(i+1)+" of "+D.cards.length+"…";await save(D.cards[i]);await new Promise(r=>setTimeout(r,350))}b.innerHTML=t;b.disabled=false};
944
+ if($("saveall"))$("saveall").onclick=async()=>{const b=$("saveall"),t=b.innerHTML;b.disabled=true;for(let i=0;i<D.cards.length;i++){b.textContent="Saving "+(i+1)+" of "+D.cards.length+"…";await save(D.cards[i]);await new Promise(r=>setTimeout(r,350))}b.innerHTML=t;b.disabled=false};
689
945
  ${api ? CLAIM_JS : ""}
946
+ /* From the profile carousel: usehour.com/@you/cards#card-3 opens that card. */
947
+ const HM=location.hash.match(/^#card-([0-9]+)$/);if(HM&&+HM[1]<D.cards.length)openStory(+HM[1]);
948
+ HourAvatar.scan(document);
690
949
  </script></body></html>`;
691
950
  }
951
+ /* @shared:end ----------------------------------------------------------- */
952
+
953
+ /* ------------------------------------------------------------ --explain
954
+ Prints, in this terminal only, what was counted and what was left out, and
955
+ the busiest twenty minutes the way versions before 0.4.0 counted them. No
956
+ prompt text is printed; project folders are shown by their last name only.
957
+ Nothing is saved or sent. */
958
+ function explain(summaries) {
959
+ const pad = (x, n) => { const t = String(x); return (t.length > n - 2 ? t.slice(0, n - 3) + "…" : t).padEnd(n); };
960
+ const tool = (s) => (s.src === "claude" ? "Claude Code" : "Codex");
961
+ const started = (s) => (s.sub ? "sub-agent" : s.how === "script" ? `program (${s.via || "?"})` : `you (${s.via || "?"})`);
962
+ const topModel = (s) => { const e = Object.entries(s.models).sort((a, b) => b[1] - a[1])[0]; return e ? e[0] : "–"; };
963
+ const folder = (s) => (s.cwd ? path.basename(s.cwd) : "–");
964
+ const withP = summaries.filter((s) => s.prompts.length);
965
+ const own = withP.filter((s) => !s.sub && s.how !== "script"), prog = withP.filter((s) => !s.sub && s.how === "script"), subs = withP.filter((s) => s.sub);
966
+ const by = (arr, f) => Object.entries(arr.reduce((a, s) => { const k = f(s); a[k] = (a[k] || 0) + 1; return a; }, {})).sort((a, b) => b[1] - a[1]).map(([k, v]) => `${k} ${v}`).join(", ");
967
+ const before = compute(summaries.map((s) => ({ ...s, prompts: [...s.prompts], ids: [...s.ids] })), { all: true });
968
+ const now = compute(summaries.map((s) => ({ ...s, prompts: [...s.prompts], ids: [...s.ids] })));
969
+ const L = (t = "") => console.log(" " + t);
970
+ L(); L(B("What Hour counted, and what it left out")); L(DIM("Printed here only. Nothing is saved or sent. No prompt text is shown.")); L();
971
+ L(B("Sessions with at least one prompt"));
972
+ L(` Started by you ${pad(own.length, 6)} ${DIM(by(own, (s) => tool(s) + " " + (s.via || "?")))}`);
973
+ L(` Started by a program ${pad(prog.length, 6)} ${DIM(by(prog, (s) => tool(s) + " " + (s.via || "?")))} ${prog.length ? "left out from 0.4.0" : ""}`);
974
+ L(` Sub-agents ${pad(subs.length, 6)} ${DIM("always left out")}`);
975
+ L();
976
+ const models = (arr) => { const mc = {}; arr.forEach((s) => { for (const [k, v] of Object.entries(s.models)) mc[k] = (mc[k] || 0) + v; }); const t = Object.values(mc).reduce((a, b) => a + b, 0) || 1; return Object.entries(mc).sort((a, b) => b[1] - a[1]).slice(0, 4).map(([k, v]) => `${k} ${pct(v / t)}% (${v})`).join(", ") || "–"; };
977
+ L(B("Models, one count per prompt"));
978
+ L(` Your sessions ${models(own)}`);
979
+ if (prog.length) L(` Program-started ${models(prog)}`);
980
+ L();
981
+ /* The most agents at work at one moment: counting everything (as before
982
+ 0.4.0), and counting only your own sessions. */
983
+ const peakAll = peakAtOnce(withP.filter((s) => !s.sub)), peakOwn = peakAtOnce(own);
984
+ const rows = new Map(peakAll.active.map((s) => [s, s.prompts.length]));
985
+ const when = (p) => (p.at ? DIM(" at " + p.at.replace("T", " ")) : "");
986
+ /* What the 0.3.x card showed: sessions with a prompt inside any 20 minutes. */
987
+ const ev = []; withP.filter((s) => !s.sub).forEach((s) => s.prompts.forEach((p) => { const t = Date.parse(p.ts || ""); if (t) ev.push([t, s]); }));
988
+ ev.sort((a, b) => a[0] - b[0]); let old = 0; const win = new Map();
989
+ for (let i = 0, j = 0; i < ev.length; i++) { win.set(ev[i][1], (win.get(ev[i][1]) || 0) + 1); while (ev[i][0] - ev[j][0] > 20 * 60e3) { const k = ev[j][1], v = win.get(k) - 1; v ? win.set(k, v) : win.delete(k); j++; } old = Math.max(old, win.size); }
990
+ L(B("Agents at once: most agents working at the same moment"));
991
+ L(` The 0.3.x card said ${old}${DIM(" (sessions with a prompt inside 20 minutes, everything counted)")}`);
992
+ L(` Counting everything ${peakAll.max}${when(peakAll)}`);
993
+ L(` Only yours (0.4.0) ${peakOwn.max > MAX_AT_ONCE ? `hidden (above ${MAX_AT_ONCE})` : peakOwn.max}${when(peakOwn)}`);
994
+ L(DIM(" A session counts as working from your prompt until the agent's last step before your next one."));
995
+ if (rows.size) {
996
+ /* Sessions that look the same are grouped: 30 runs of one script are one row. */
997
+ const g = new Map();
998
+ rows.forEach((n, s) => { const k = [tool(s), started(s), folder(s), topModel(s)].join("\t"); const x = g.get(k) || { k, sessions: 0, prompts: 0 }; x.sessions++; x.prompts += n; g.set(k, x); });
999
+ const G = [...g.values()].sort((a, b) => b.sessions - a.sessions || b.prompts - a.prompts);
1000
+ L(); L(DIM(" Working at that moment, counting everything:"));
1001
+ L(DIM(" " + pad("Sessions", 10) + pad("Tool", 13) + pad("Started by", 28) + pad("Project folder", 24) + pad("Model", 24) + "Prompts"));
1002
+ G.slice(0, 25).forEach((x) => { const [t, st, f, mo] = x.k.split("\t"); L(" " + pad(x.sessions, 10) + pad(t, 13) + pad(st, 28) + pad(f, 24) + pad(mo, 24) + x.prompts); });
1003
+ if (G.length > 25) L(DIM(` …and ${G.length - 25} more rows`));
1004
+ }
1005
+ L();
1006
+ if (before && now) {
1007
+ const v = (m) => (m.sessions.total ? pct(m.proof.sessionsAskedToVerify / m.sessions.total) + "%" : "–");
1008
+ const h = (m, k) => { const x = m.promptProfile.find((y) => y.key === k); return x ? pct(x.value) + "%" : "–"; };
1009
+ const top = (m) => (m.models[0] ? m.models[0].name : "–");
1010
+ L(B("Your numbers, before 0.4.0 → now"));
1011
+ [["Sessions", before.sessions.total, now.sessions.total], ["Prompts", before.prompts.total, now.prompts.total], ["Agents at once", before.parallelMax, now.parallelMax || "hidden"], ["Hour score", (hourScore(before) || {}).total ?? "–", (hourScore(now) || {}).total ?? "–"], ["Made it prove it", v(before), v(now)], ["Briefing", h(before, "brief"), h(now, "brief")], ["Model", top(before), top(now)], ["Builder type", TYPES[builderType(before)].name, TYPES[builderType(now)].name]]
1012
+ .forEach(([k, a, b]) => L(` ${pad(k, 18)}${pad(a, 26)}→ ${b}`));
1013
+ L();
1014
+ }
1015
+ L(DIM("Copy what you're happy to share. Project folder names are yours to blank out."));
1016
+ L();
1017
+ }
692
1018
 
693
1019
  /* ------------------------------------------------------------------ the run */
694
- const B = (s) => `\x1b[1m${s}\x1b[0m`, DIM = (s) => `\x1b[2m${s}\x1b[0m`;
1020
+ /* Terminal look. Colour only on a real terminal, never with NO_COLOR or
1021
+ TERM=dumb. 24-bit colour where the terminal says it has it, otherwise the
1022
+ nearest of the 256 standard colours. No packages: plain escape codes. */
1023
+ const COLOR = !!process.stdout.isTTY && !("NO_COLOR" in process.env) && process.env.TERM !== "dumb";
1024
+ const TRUECOLOR = /truecolor|24bit/i.test(process.env.COLORTERM || "") || /iTerm|WezTerm|vscode|ghostty|Warp/i.test(process.env.TERM_PROGRAM || "");
1025
+ const E = (c) => (COLOR ? `\x1b[${c}m` : "");
1026
+ const B = (s) => E(1) + s + E(0), DIM = (s) => E(2) + s + E(0);
1027
+ const c256 = ([r, g, b]) => { const q = (v) => (v < 48 ? 0 : v < 115 ? 1 : Math.min(5, Math.round((v - 35) / 40))); return 16 + 36 * q(r) + 6 * q(g) + q(b); };
1028
+ const FG = (rgb) => E(TRUECOLOR ? `38;2;${rgb.join(";")}` : `38;5;${c256(rgb)}`), BG = (rgb) => E(TRUECOLOR ? `48;2;${rgb.join(";")}` : `48;5;${c256(rgb)}`);
1029
+ const HUE = { lav: [176, 172, 254], green: [95, 211, 141], blue: [79, 136, 255], purple: [139, 95, 217], orange: [242, 96, 31], mustard: [219, 160, 4], pink: [251, 101, 194] };
1030
+ const tint = (h, s) => FG(HUE[h]) + s + E(0);
1031
+ const GSTOP = [[0, [79, 136, 255]], [0.38, [176, 172, 254]], [0.72, [251, 101, 194]], [1, [253, 41, 128]]];
1032
+ const gcol = (t) => { t = Math.max(0, Math.min(1, t)); let i = 0; while (i < GSTOP.length - 2 && t > GSTOP[i + 1][0]) i++; const [a, x] = GSTOP[i], [b, y] = GSTOP[i + 1], f = (t - a) / (b - a); return x.map((v, k) => Math.round(v + (y[k] - v) * f)); };
1033
+ const grad = (s, bold) => (COLOR ? [...s].map((ch, i, a) => (ch === " " ? " " : FG(gcol(i / Math.max(1, a.length - 1))) + (bold ? E(1) : "") + ch)).join("") + E(0) : s);
1034
+ const KEY = (s) => (COLOR ? E(7) + " " + s + " " + E(27) : "[" + s + "]");
1035
+ /* The Hour ring as a dot matrix, like the cards: bright dots in the logo's
1036
+ gradient on a faint grid. Plain characters, so every terminal shows it. */
1037
+ function markLines(n = 7, R = 2.8) {
1038
+ const c0 = (n - 1) / 2, out = [];
1039
+ for (let r = 0; r < n; r++) {
1040
+ let l = "";
1041
+ for (let c = 0; c < n; c++) {
1042
+ const d = Math.hypot(c - c0, r - c0);
1043
+ l += (Math.abs(d - R) < 0.5 ? FG(gcol((c + r) / (2 * (n - 1)))) + "●" + E(0) : d < R + 1.1 ? DIM("·") : " ") + (c < n - 1 ? " " : "");
1044
+ }
1045
+ out.push(l);
1046
+ }
1047
+ return out;
1048
+ }
1049
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
695
1050
  /* One reader for the whole run, so answers typed ahead are never lost. */
696
1051
  let RL = null, LINES = [], WAIT = null;
697
1052
  function ask(q) {
@@ -705,7 +1060,7 @@ function ask(q) {
705
1060
  return new Promise((res) => { WAIT = res; });
706
1061
  }
707
1062
  function done() { if (RL) RL.close(); }
708
- const yes = (a) => /^y(es)?$/i.test(a);
1063
+ const yes = (a) => !/^n(o)?$/i.test(a); // Enter means yes: running the command was the choice
709
1064
  function openFile(f) {
710
1065
  const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
711
1066
  const args = process.platform === "win32" ? ["/c", "start", "", f] : [f];
@@ -714,7 +1069,7 @@ function openFile(f) {
714
1069
  async function main() {
715
1070
  const argv = process.argv.slice(2), flag = (n) => argv.includes(n), opt = (n) => { const i = argv.indexOf(n); return i >= 0 ? argv[i + 1] : null; };
716
1071
  if (flag("--help") || flag("-h")) {
717
- console.log(`Hour Wrapped ${VERSION}\n\n npx usehour [--yes] [--no-open] [--no-share] [--out DIR] [--claude-dir DIR] [--codex-dir DIR]\n\n --yes skip the first question (reading still happens only on this machine)\n --no-open don't open the browser\n --no-share cards only: no example prompts, and the cards page cannot connect anywhere\n --out DIR where to write hour-wrapped/ (default: current folder)\n`);
1072
+ console.log(`Hour Wrapped ${VERSION}\n\n npx usehour [--yes] [--no-open] [--no-share] [--out DIR] [--claude-dir DIR] [--codex-dir DIR] [--explain]\n\n --yes skip the first question (reading still happens only on this machine)\n --no-open don't open the browser\n --no-share cards only: no example prompts, and the cards page cannot connect anywhere\n --out DIR where to write hour-wrapped/ (default: current folder)\n --explain show, in this terminal only, what was counted and what was left out\n`);
718
1073
  return;
719
1074
  }
720
1075
  const home = os.homedir();
@@ -724,24 +1079,45 @@ async function main() {
724
1079
  ].map((s) => ({ ...s, files: walk(s.dir) }));
725
1080
  const found = sources.filter((s) => s.files.length);
726
1081
  const share = !flag("--no-share");
727
- console.log(`\n ${B("Hour Wrapped")} ${DIM("v" + VERSION)} Your year of building with AI.\n`);
1082
+ if (COLOR) {
1083
+ const mk = markLines(), mid = Math.floor(mk.length / 2);
1084
+ console.log("");
1085
+ mk.forEach((l, i) => console.log(" " + l + " " + (i === mid - 1 ? `${B("Hour Wrapped")} ${DIM("v" + VERSION)}` : i === mid ? "Your year of building with AI." : "")));
1086
+ console.log("");
1087
+ } else console.log(`\n Hour Wrapped v${VERSION} Your year of building with AI.\n`);
728
1088
  if (!found.length) {
729
1089
  console.log(` No Claude Code or Codex sessions found.\n Looked in:\n${sources.map((s) => " " + s.dir).join("\n")}\n\n If yours live somewhere else, run with --claude-dir or --codex-dir.\n`);
730
1090
  process.exitCode = 1; return;
731
1091
  }
732
1092
  const bytes = found.reduce((a, s) => a + s.files.reduce((b, f) => { try { return b + fs.statSync(f).size; } catch { return b; } }, 0), 0);
733
- console.log(" This reads your session logs, on this machine only:");
734
- for (const s of found) console.log(` ${s.label.padEnd(12)} ${s.files.length} files ${DIM(s.dir)}`);
735
- console.log(`\n It keeps counts${share ? ", plus a few of your best prompts for you to choose from" : ""}.`);
736
- console.log(` This script never connects to the internet.\n`);
1093
+ const here = process.platform === "darwin" ? "this Mac" : "this computer";
1094
+ const tick = (t) => " " + tint("green", "✓") + " " + t;
1095
+ console.log(` ${tint("lav", "●")} ${B("Offline.")} ${tint("lav", "Nothing leaves this computer.")}`);
1096
+ console.log(tick(`Reads your logs here, on ${here}`));
1097
+ console.log(tick("Keeps counts and labels only. No prompts, code or files are sent"));
1098
+ console.log(tick(`No internet needed. ${DIM("Try it with Wi-Fi off.")}`));
1099
+ console.log(`\n ${DIM("Found")}`);
1100
+ const tilde = (p) => (p.startsWith(home) ? "~" + p.slice(home.length) : p);
1101
+ const wFiles = Math.max(...found.map((s) => String(s.files.length).length));
1102
+ for (const s of found) console.log(` ${s.label.padEnd(13)} ${B(String(s.files.length).padStart(wFiles))} ${s.files.length === 1 ? "file " : "files"} ${DIM(tilde(s.dir))}`);
1103
+ console.log("");
737
1104
  if (!flag("--yes")) {
738
1105
  if (!process.stdin.isTTY) { console.log(" Run this in a terminal (or add --yes)."); process.exitCode = 1; return; }
739
- const a = await ask(` Read ${(bytes / 1e9).toFixed(1)} GB of logs and make your cards? [y/N] `);
1106
+ const size = bytes >= 1e9 ? (bytes / 1e9).toFixed(1) + " GB" : Math.max(1, Math.round(bytes / 1e6)) + " MB";
1107
+ const a = await ask(` Read ${B(size)} of logs and make your cards? ${COLOR ? `${KEY("Enter")} yes ${DIM("·")} ${KEY("n")} no` : "[Y/n]"} `);
740
1108
  if (!yes(a)) { console.log("\n Stopped. Nothing was read.\n"); return; }
741
1109
  }
742
1110
  const jobs = found.flatMap((s) => s.files.map((file) => ({ file, src: s.src, size: fs.statSync(file).size }))).sort((a, b) => b.size - a.size);
743
1111
  const summaries = []; let done = 0, doneBytes = 0; const t0 = Date.now();
744
- const tick = () => { if (process.stdout.isTTY) process.stdout.write(`\r Reading ${done}/${jobs.length} files ${Math.round(doneBytes / Math.max(1, bytes) * 100)}% `); };
1112
+ const TTY = !!process.stdout.isTTY, BAR = Math.max(10, Math.min(24, (process.stdout.columns || 80) - 50)), nf = (n) => n.toLocaleString("en");
1113
+ let found_p = 0, last = 0;
1114
+ const bar = (p) => { const k = Math.round(p * BAR); let o = ""; for (let i = 0; i < BAR; i++) o += i < k ? (COLOR ? FG(gcol(i / (BAR - 1))) + "●" : "●") : (COLOR ? E(2) + "·" : "·"); return o + E(0); };
1115
+ const progress = (force) => {
1116
+ if (!TTY) return; const now = Date.now(); if (!force && now - last < 60) return; last = now;
1117
+ const p = doneBytes / Math.max(1, bytes);
1118
+ process.stdout.write(`\r\x1b[K ${bar(p)} ${B(String(Math.round(p * 100)).padStart(3) + "%")} ${DIM(`${nf(done)}/${nf(jobs.length)} files ·`)} ${B(nf(found_p))} ${DIM("prompts")}`);
1119
+ };
1120
+ if (TTY) console.log(`\n Reading your logs ${DIM("· on this computer, offline")}\n`);
745
1121
  const nWorkers = Math.max(1, Math.min(4, (os.availableParallelism ? os.availableParallelism() : os.cpus().length) - 1));
746
1122
  await new Promise((resolve) => {
747
1123
  let next = 0, active = 0;
@@ -750,13 +1126,14 @@ async function main() {
750
1126
  const feed = (w) => {
751
1127
  if (next >= jobs.length) { w.terminate(); if (--active === 0) resolve(); return; }
752
1128
  const job = jobs[next++];
753
- w.once("message", (r) => { if (r.ok) summaries.push(r.s); done++; doneBytes += job.size; tick(); feed(w); });
754
- w.postMessage({ file: job.file, src: job.src, examples: share });
1129
+ w.once("message", (r) => { if (r.ok) { summaries.push(r.s); if (!r.s.sub && r.s.how !== "script") found_p += r.s.prompts.length; } done++; doneBytes += job.size; progress(); feed(w); });
1130
+ w.postMessage({ file: job.file, src: job.src, examples: false });
755
1131
  };
756
1132
  workers.forEach((w) => { active++; feed(w); });
757
1133
  });
758
- if (process.stdout.isTTY) process.stdout.write("\r" + " ".repeat(60) + "\r");
759
- console.log(` Read ${jobs.length} files in ${Math.round((Date.now() - t0) / 1000)}s.`);
1134
+ if (TTY) { doneBytes = bytes; progress(true); process.stdout.write("\n"); }
1135
+ console.log(` ${tint("green", "✓")} Read ${nf(jobs.length)} files in ${Math.round((Date.now() - t0) / 1000)}s. ${DIM("Nothing was sent.")}`);
1136
+ if (flag("--explain")) { explain(summaries); return; }
760
1137
  const m = compute(summaries);
761
1138
  if (!m) { console.log("\n Found the logs but no prompts in them. Nothing to make cards from.\n"); return; }
762
1139
  const C = cards(m, "");
@@ -764,16 +1141,29 @@ async function main() {
764
1141
  fs.mkdirSync(outDir, { recursive: true });
765
1142
  fs.writeFileSync(path.join(outDir, "wrapped.json"), JSON.stringify(m, null, 2));
766
1143
  const page = path.join(outDir, "index.html");
767
- const examples = share ? pickExamples(summaries, 10) : [];
1144
+ /* Since 0.4.0 no prompt text goes on a profile: only counts and labels.
1145
+ (pickExamples stays for anyone who wants to see their best prompts.) */
1146
+ const examples = [];
768
1147
  const suggest = (() => { try { const u = os.userInfo().username.toLowerCase().replace(/[^a-z0-9-]/g, "").replace(/^-+|-+$/g, "").slice(0, 24); return ["root", "admin", "user", "ubuntu", "runner", "administrator", "guest", "owner"].includes(u) ? "" : u; } catch { return ""; } })();
769
1148
  fs.writeFileSync(page, pageHTML(m, C, { api: share ? (opt("--api") || API_DEFAULT).replace(/\/+$/, "") : null, examples, suggest: suggest.length >= 3 ? suggest : "" }));
770
- const type = TYPES[builderType(m)].name;
771
- console.log(`\n ${B("You're " + type + ".")} ${m.sessions.total} sessions, ${m.prompts.total.toLocaleString("en")} prompts, busiest at ${hourLabel(m.busiestHour)}.`);
772
- console.log(` Your cards: ${page}\n`);
773
- if (!flag("--no-open")) openFile(page);
1149
+ const T = TYPES[builderType(m)], sc = hourScore(m), nf2 = (n) => Number(n).toLocaleString("en");
1150
+ const facts = [["blue", nf2(m.sessions.total), "sessions", found.length > 1 ? "in Claude Code and Codex" : "in " + found[0].label]];
1151
+ facts.push(["purple", nf2(m.prompts.total), "prompts", `busiest at ${hourLabel(m.busiestHour)}`]);
1152
+ if (m.parallelMax > 1 && m.parallelMax <= MAX_AT_ONCE) facts.push(["orange", String(m.parallelMax), "agents at once", m.atOnceAt ? "at " + atOnceWhen(m.atOnceAt) : ""]);
1153
+ if (m.shipped && m.shipped.commits) facts.push(["mustard", nf2(m.shipped.commits), "commits", m.shipped.prs ? `and ${nf2(m.shipped.prs)} pull request${m.shipped.prs > 1 ? "s" : ""}, made by your agents` : "made by your agents"]);
1154
+ const w = Math.max(...facts.map(([, n, l]) => n.length + 1 + l.length)) + 3;
1155
+ console.log("");
1156
+ for (const [h, n, l, d] of facts) { console.log(` ${tint(h, "●")} ${B(n)} ${l}${" ".repeat(w - n.length - 1 - l.length)}${DIM(d)}`); if (TTY) await sleep(140); }
1157
+ console.log(`\n You're ${grad(T.name + ".", true)}`);
1158
+ const first = String(T.line || "").split(/(?<=\.)\s/)[0];
1159
+ console.log(` ${DIM(first + (sc ? " Hour score" : ""))}${sc ? " " + B(String(sc.total)) + DIM(".") : ""}`);
1160
+ const open = !flag("--no-open");
1161
+ console.log(`\n ${B((open ? "→ Opening your " : "Your ") + C.length + " cards" + (open ? " in the browser" : " are ready"))}`);
1162
+ console.log(` ${DIM(page.startsWith(home) ? "~" + page.slice(home.length) : page)}\n`);
1163
+ if (open) openFile(page);
774
1164
  if (share) {
775
- console.log(` Want a profile? The last card lets you claim your handle at usehour.com/@you.`);
776
- console.log(` Nothing is sent unless you press the buttons there. You see exactly what goes first.`);
1165
+ console.log(` ${DIM("Want a profile? The last card lets you claim")} usehour.com/@you${DIM(".")}`);
1166
+ console.log(` ${DIM("Nothing is sent unless you press the buttons there. You see exactly what goes first.")}`);
777
1167
  if (examples.length) console.log(DIM(` (The cards page holds ${examples.length} of your prompts so you can choose. It stays on this computer.)`));
778
1168
  console.log("");
779
1169
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "usehour",
3
- "version": "0.3.1",
3
+ "version": "0.4.1",
4
4
  "description": "Your year of building with AI, as Wrapped-style cards, and an optional private profile. Reads your Claude Code and Codex logs locally.",
5
5
  "type": "module",
6
6
  "bin": { "usehour": "hour.mjs" },