usehour 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +4 -3
  2. package/hour.mjs +388 -75
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -3,14 +3,15 @@
3
3
  Your year of building with AI, as Wrapped-style cards.
4
4
 
5
5
  ```
6
- npx usehour
6
+ npx usehour@0.3.0
7
7
  ```
8
8
 
9
9
  - Asks before it reads anything, and lists the folders it will read.
10
10
  - Reads your Claude Code and Codex session logs on your machine, read only.
11
- - Keeps counts only. Your prompts and code are never saved or sent.
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
- - No dependencies, no network access, nothing installed.
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
+ - No dependencies, nothing installed.
14
15
 
15
16
  It's one file: read [hour.mjs](https://usehour.com/hour.mjs) before you run it.
16
17
 
package/hour.mjs CHANGED
@@ -2,21 +2,24 @@
2
2
  /*
3
3
  Hour Wrapped: a year of building with AI, from the sessions already on your machine.
4
4
 
5
- npx https://usehour.com/hour.tgz (or: node hour.mjs)
5
+ npx usehour (or download this file and run: node hour.mjs)
6
6
 
7
7
  What this script does, in order:
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. Keeps counts only: how many sessions, which hours, which models, how often
12
- you asked the agent to test its work. Your prompts and your code are read
13
- to count them and are never written anywhere or sent anywhere.
14
- 4. Writes two files into ./hour-wrapped/ : index.html (your cards) and
15
- wrapped.json (the counts), then opens the cards in your browser.
16
- 5. Only if you say yes: sends wrapped.json to Hour so we can make your
17
- shareable page. You see the exact data first. Nothing is sent otherwise.
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.
15
+ 4. Writes ./hour-wrapped/index.html (your cards) and wrapped.json (the
16
+ counts only), then opens the cards in your browser.
17
+ 5. This script never uses the network. The cards page can make one request,
18
+ to usehour.com, and only when you press "Create my private profile" after
19
+ seeing exactly what will be sent. The page is locked so it cannot contact
20
+ anything else.
18
21
 
19
- No dependencies. No install step. No network access unless you choose to share.
22
+ No dependencies. No install step. Run with --no-share for cards only.
20
23
  Source: https://usehour.com/hour.mjs Questions: lush@usehour.com
21
24
  */
22
25
  import fs from "node:fs";
@@ -27,8 +30,8 @@ import { spawn } from "node:child_process";
27
30
  import { Worker, isMainThread, parentPort, workerData } from "node:worker_threads";
28
31
  import { fileURLToPath } from "node:url";
29
32
 
30
- const VERSION = "0.1.0";
31
- const SHARE_URL_DEFAULT = "https://usehour.com/";
33
+ const VERSION = "0.3.0";
34
+ const API_DEFAULT = "https://usehour.com/api";
32
35
 
33
36
  /* ------------------------------------------------------------------ patterns */
34
37
  const TEST = /\b(pytest|vitest|jest|mocha|playwright|go test|cargo test|npm (run )?test|pnpm (run )?test|yarn test|bun test|node --test|npm run (check|lint|typecheck|build)|tsc\b|astro check|ruff|eslint|rspec|phpunit|dotnet test|mvn test|gradle test|--check\b)/i;
@@ -37,6 +40,43 @@ const CORRECT = /^(no\b|nope|not\b|wait\b|stop\b|don'?t\b|do not\b|actually\b|in
37
40
  const GOAHEAD = /^(ok(ay)?|all right|alright|yes|yup|yep|sure|great|cool|nice|perfect|good|please proceed|proceed|go ahead|continue|do it|let'?s go)\b/i;
38
41
  const QUESTION = /^(what|why|how|where|which|who|can you|could you|is it|are you|do you|does)\b/i;
39
42
  const DONTKNOW = /\bi don'?t know\b/i;
43
+ /* How someone briefs an agent. Each is a yes/no per prompt. */
44
+ const CTX = /\b(because|so that|the goal|goal is|goal:|context|background|currently|right now|we'?re building|i'?m building|we have an?|the problem|the issue is|users?|customers?|the idea is)\b/i;
45
+ const LIM = /\b(don'?t|do not|must|never|only|without|avoid|keep (it|the|this)|no more than|at most|should ?n[o']t|make sure|constraints?)\b/i;
46
+ const FIN = /\b(done when|when (you'?re|you are) done|should (be able|return|show|work|pass|look|be)|expected|acceptance|success|until|end result|output should|deliverable|so i can|report back|let me know|the result)\b/i;
47
+ const SEQ = /\b(first|then|after that|next|finally|once (that|this|it)'?s? (is )?done)\b/gi;
48
+ const LISTLINE = /^\s*(\d+[.)]|[-*•]|step \d+)\s+\S/gim;
49
+ const SPECIFIC = /`[^`\n]+`|\b[\w./-]+\.(tsx?|jsx?|mjs|py|go|rs|rb|java|kt|swift|md|json|css|html|astro|vue|svelte|sql|ya?ml|toml|sh|env)\b|https?:\/\/\S+|\b[a-z]+[A-Z][A-Za-z0-9]+\b|\b[a-z0-9]+_[a-z0-9_]+\b|\bline \d+|\b\d+(px|ms|s|kb|mb|%)\b/;
50
+ const EXPLAIN = /\b(because|instead|should|expected|the (error|issue|problem|bug)|it (shows|says|returns|still)|still|wrong|broke|missing)\b/i;
51
+ /* Text copied from an agent's own output rather than written by you. */
52
+ const PASTED = /\b(Ran|Read|Edited|Wrote|Searched) \d+ (commands?|files?|lines?|patterns?)\b|[⏺⎿✻]|^\s*(Thinking|Thought for \d+)/m;
53
+ const CODEY = /[{};]\s*$|^\s{4,}\S|^\s*(at |import |from |const |let |def |function |class |return |\$ |>>> |File ")|^\s*[<{[]/;
54
+
55
+ /* Anything that looks secret or personal is replaced before a prompt leaves
56
+ the file parser. This runs on the few prompts kept as examples; the rest
57
+ are never kept at all. */
58
+ const SECRETS = [
59
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(-----END [A-Z ]*PRIVATE KEY-----|$)/g,
60
+ /\b(sk|pk|rk)-(ant-|proj-|live_|test_)?[A-Za-z0-9_-]{16,}/g,
61
+ /\b(ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}|\bgithub_pat_[A-Za-z0-9_]{20,}/g,
62
+ /\bxox[abposr]-[A-Za-z0-9-]{10,}/g,
63
+ /\bAKIA[0-9A-Z]{16}\b/g,
64
+ /\bAIza[0-9A-Za-z_-]{30,}/g,
65
+ /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g,
66
+ /\b(bearer|basic)\s+[A-Za-z0-9._~+\/=-]{12,}/gi,
67
+ /\b(api[_-]?key|secret|token|password|passwd|pwd|auth|credential|private[_-]?key|access[_-]?key)(\s*[:=]\s*|\s+is\s+)["']?[^\s"',;]{4,}/gi,
68
+ /\b[a-z][a-z0-9+.-]*:\/\/[^\s:@\/]+:[^\s@\/]+@/gi,
69
+ /\b[A-Fa-f0-9]{32,}\b/g,
70
+ /\b[A-Za-z0-9+\/_-]{40,}={0,2}/g,
71
+ /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g,
72
+ /\+?\b\d[\d ()-]{8,}\d\b/g,
73
+ /\b\d{1,3}(\.\d{1,3}){3}\b/g,
74
+ ];
75
+ function redact(t) {
76
+ let out = t.replace(/(\/Users\/|\/home\/|[A-Z]:\\Users\\)[^\/\\\s]+/g, "~");
77
+ for (const re of SECRETS) out = out.replace(re, "[hidden]");
78
+ return out;
79
+ }
40
80
 
41
81
  /* ------------------------------------------------------------ file parsing */
42
82
  /* One file in, one small summary out. Prompt text stays inside this function:
@@ -51,14 +91,29 @@ function shortKey(t) {
51
91
  }
52
92
  function promptFacts(ts, text) {
53
93
  const t = text.trim();
54
- return {
55
- ts,
56
- words: t.split(/\s+/).filter(Boolean).length,
94
+ const words = t.split(/\s+/).filter(Boolean).length;
95
+ const lines = t.split("\n").filter((l) => l.trim());
96
+ const codey = lines.length >= 4 && lines.filter((l) => CODEY.test(l)).length / lines.length > 0.35;
97
+ const kind = CORRECT.test(t) ? "correct" : (GOAHEAD.test(t) && words <= 12) ? "go" : (t.endsWith("?") || QUESTION.test(t)) ? "question" : "direct";
98
+ const f = {
99
+ ts, words, kind, codey,
57
100
  verify: VERIFY.test(t),
58
101
  dontknow: DONTKNOW.test(t),
59
- kind: CORRECT.test(t) ? "correct" : (GOAHEAD.test(t) && t.split(/\s+/).length <= 12) ? "go" : (t.endsWith("?") || QUESTION.test(t)) ? "question" : "direct",
60
102
  short: shortKey(t),
103
+ ctx: CTX.test(t), lim: LIM.test(t), fin: FIN.test(t),
104
+ steps: (t.match(LISTLINE) || []).length >= 2 || (t.match(SEQ) || []).length >= 2,
105
+ spec: SPECIFIC.test(t),
106
+ explains: kind === "correct" && words >= 12 && EXPLAIN.test(t),
107
+ pasted: PASTED.test(t),
61
108
  };
109
+ return f;
110
+ }
111
+ /* How good an example a prompt is: a clear brief with context, limits, a
112
+ finish line, steps, specifics and a request for proof. Long pastes of code
113
+ or logs are not examples of prompting, so they are skipped. */
114
+ function exampleScore(f, first) {
115
+ if (f.codey || f.pasted || f.words < 30 || f.words > 700) return 0;
116
+ return f.ctx + f.lim + f.fin + f.steps + f.verify + 0.5 * f.spec + Math.min(1, f.words / 120) + (first ? 0.5 : 0);
62
117
  }
63
118
  function codexUserText(p) {
64
119
  const pick = (c) => typeof c === "string" ? c : Array.isArray(c) ? c.map((x) => x && (x.text || x.input_text) || "").filter((x) => typeof x === "string").join("\n") : "";
@@ -75,10 +130,11 @@ function cleanPrompt(t) {
75
130
  if (!t || t.startsWith("<") || t.startsWith("# AGENTS.md") || t.startsWith("Caveat:") || t.startsWith("[Request interrupted") || t.startsWith("# Files mentioned")) return null;
76
131
  return t;
77
132
  }
133
+ let KEEP_EXAMPLES = true;
78
134
  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"/;
79
135
  const KEEP_CLAUDE = /"type":\s*"(user|assistant)"/;
80
136
  async function parseFile(file, src) {
81
- const s = { src, sub: file.includes(`${path.sep}subagents${path.sep}`), start: null, end: null, cwd: null, models: {}, prompts: [], ids: [], patches: 0, cmds: 0, tests: 0, aborts: 0, tokens: 0 };
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 };
82
138
  const seenMsg = new Set(); const seenText = new Set();
83
139
  const rl = readline.createInterface({ input: fs.createReadStream(file, { encoding: "utf8" }), crlfDelay: Infinity });
84
140
  for await (const line of rl) {
@@ -98,7 +154,7 @@ async function parseFile(file, src) {
98
154
  if (text && text.trim().startsWith("[Request interrupted")) s.aborts++;
99
155
  if (o.isMeta) continue;
100
156
  const u = cleanPrompt(text);
101
- if (u) { s.prompts.push(promptFacts(ts, u)); s.ids.push(o.uuid || null); }
157
+ if (u) { const f = promptFacts(ts, u); keepExample(s, f, u); s.prompts.push(f); s.ids.push(o.uuid || null); }
102
158
  } else if (o.type === "assistant") {
103
159
  if (m.id && !seenMsg.has(m.id)) {
104
160
  seenMsg.add(m.id);
@@ -127,7 +183,7 @@ async function parseFile(file, src) {
127
183
  const ut = codexUserText(p);
128
184
  if (ut !== null && (o.type === "event_msg" || o.type === "response_item")) {
129
185
  const u = cleanPrompt(ut);
130
- if (u && !seenText.has(u)) { seenText.add(u); s.prompts.push(promptFacts(ts, u)); s.ids.push(null); }
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); }
131
187
  }
132
188
  if (o.type === "response_item" && ["function_call", "custom_tool_call", "local_shell_call"].includes(p.type)) {
133
189
  const name = p.name || "";
@@ -140,10 +196,21 @@ async function parseFile(file, src) {
140
196
  }
141
197
  return s;
142
198
  }
199
+ /* Keeps the best three example prompts of a file, secrets hidden. */
200
+ function keepExample(s, f, text) {
201
+ if (!KEEP_EXAMPLES) return;
202
+ const q = exampleScore(f, s.prompts.length === 0);
203
+ if (q < 3) return;
204
+ if (s.examples.length >= 3 && q <= s.examples[2].q) return;
205
+ const clean = redact(text).slice(0, 2400);
206
+ s.examples.push({ q: clean.includes("[hidden]") ? q - 0.5 : q, text: clean, ts: f.ts, words: f.words });
207
+ s.examples.sort((a, b) => b.q - a.q); s.examples.length = Math.min(3, s.examples.length);
208
+ }
143
209
 
144
210
  /* Worker threads parse files in parallel; each returns only its summary. */
145
211
  if (!isMainThread) {
146
- parentPort.on("message", async ({ file, src }) => {
212
+ parentPort.on("message", async ({ file, src, examples }) => {
213
+ KEEP_EXAMPLES = examples !== false;
147
214
  try { parentPort.postMessage({ ok: true, s: await parseFile(file, src) }); }
148
215
  catch (e) { parentPort.postMessage({ ok: false, error: String(e && e.message || e) }); }
149
216
  });
@@ -206,6 +273,20 @@ function compute(summaries) {
206
273
  const goto = Object.entries(shorts).filter(([, c]) => c >= 5).sort((a, b) => b[1] - a[1])[0] || null;
207
274
  const days = new Set(P.map((p) => p.d.toDateString())).size;
208
275
  const projects = new Set(main.map((s) => s.cwd).filter(Boolean)).size;
276
+ /* How you prompt: five habits, each the share of prompts where it shows.
277
+ "notable" is the level where the habit stands out; the bar fills there. */
278
+ const long = P.filter((p) => p.words >= 25 && !p.codey), mid = P.filter((p) => p.words >= 8 && !p.codey);
279
+ const briefs = P.filter((p) => p.i === 0 && !p.codey);
280
+ const corrections = follows.filter((p) => p.kind === "correct");
281
+ const rate = (a, f) => (a.length ? a.filter(f).length / a.length : 0);
282
+ const habit = (key, label, value, notable, base, what) => ({ key, label, value, notable, base, what });
283
+ const promptProfile = [
284
+ habit("brief", "Briefing", rate(briefs, (p) => p.words >= 30 && p.ctx + p.lim + p.fin >= 2), 0.4, briefs.length, "opening prompts that give context, limits or a finish line (at least two)"),
285
+ habit("steps", "Breaking it down", rate(long, (p) => p.steps), 0.25, long.length, "longer prompts laid out as steps"),
286
+ habit("proof", "Asking for proof", main.length ? verifySessions / main.length : 0, 0.45, main.length, "sessions that asked the agent to test, verify or screenshot"),
287
+ habit("specific", "Being specific", rate(mid, (p) => p.spec), 0.35, mid.length, "prompts that name files, functions, errors or numbers"),
288
+ habit("recover", "Correcting clearly", rate(corrections, (p) => p.explains), 0.5, corrections.length, "corrections that say what is wrong or what to do instead"),
289
+ ].filter((h) => h.base >= 10);
209
290
  const first = new Date(Math.min(...P.map((p) => p.d.getTime()))), last = new Date(Math.max(...P.map((p) => p.d.getTime())));
210
291
  return {
211
292
  version: VERSION,
@@ -223,8 +304,25 @@ function compute(summaries) {
223
304
  goToPrompt: goto ? { text: goto[0], count: goto[1] } : null,
224
305
  dontKnow: P.filter((p) => p.dontknow).length,
225
306
  tokens: summaries.reduce((a, s) => a + s.tokens, 0),
307
+ promptProfile,
226
308
  };
227
309
  }
310
+ /* The strongest example prompts across all sessions: no repeats, at most two
311
+ from any one project, best first. They stay on this computer unless you
312
+ choose some for your profile. */
313
+ function pickExamples(summaries, n = 12) {
314
+ const all = summaries.filter((s) => !s.sub).flatMap((s) => (s.examples || []).map((e) => ({ ...e, cwd: s.cwd || "" })));
315
+ all.sort((a, b) => b.q - a.q);
316
+ const seen = new Set(), per = {}, out = [];
317
+ for (const e of all) {
318
+ const k = e.text.toLowerCase().replace(/\s+/g, " ").slice(0, 200);
319
+ if (seen.has(k) || (per[e.cwd] || 0) >= 2) continue;
320
+ seen.add(k); per[e.cwd] = (per[e.cwd] || 0) + 1;
321
+ out.push({ text: e.text, words: e.words, date: (e.ts || "").slice(0, 10) });
322
+ if (out.length >= n) break;
323
+ }
324
+ return out;
325
+ }
228
326
 
229
327
  /* Builder types. Each signal is scored against the level where it becomes
230
328
  notable; the highest score wins, and below 1 everywhere you are a Pair
@@ -253,6 +351,9 @@ function builderType(m) {
253
351
  const monthYear = (d) => new Date(d + "T12:00:00Z").toLocaleString("en", { month: "short", year: "numeric" });
254
352
  const hourLabel = (h) => (h % 12 || 12) + (h < 12 ? " AM" : " PM");
255
353
  const compact = (n) => n >= 1e9 ? (n / 1e9).toFixed(1).replace(/\.0$/, "") + "B" : n >= 1e6 ? (n / 1e6).toFixed(1).replace(/\.0$/, "") + "M" : n >= 1e3 ? Math.round(n / 1e3) + "K" : String(n);
354
+ /* Dots for a habit: reaching the notable level fills 7 of 10, the last 3 are
355
+ for going beyond it. */
356
+ 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)));
256
357
  function cards(m, name) {
257
358
  const type = TYPES[builderType(m)];
258
359
  const tools = [m.sessions.claudeCode && "Claude Code", m.sessions.codex && "Codex"].filter(Boolean).join(" and ");
@@ -273,31 +374,221 @@ function cards(m, name) {
273
374
  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" });
274
375
  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" });
275
376
  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" });
377
+ if (m.promptProfile && m.promptProfile.length >= 3) {
378
+ const hp = m.promptProfile, best = [...hp].sort((a, b) => b.value / b.notable - a.value / a.notable)[0];
379
+ C.push({ id: "prompting", bg: "#F7F3EC", fg: "#0B0B0A", bs: "11cqw", rows: hp.map((h) => [h.label, habitFill(h), `${pct(h.value)}%`]), ask: "How you prompt", big: best.label, line: `Your strongest habit: ${pct(best.value)}% of ${best.what}.` });
380
+ }
276
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,
277
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 /, "") : "–"]] });
278
383
  return C;
279
384
  }
280
385
 
386
+ /* ------------------------------------------------------- the claim flow
387
+ Shown only when sharing is on. It runs in the cards page on your computer.
388
+ The page talks to usehour.com twice at most, each time because you pressed
389
+ a button: "Continue" checks that your handle is free (sends the handle
390
+ only), "Create" sends the profile you saw in step 3. */
391
+ const CLAIM_CSS = String.raw`
392
+
393
+ .claimc .cdots{margin-top:4cqw;height:14cqw;background-image:radial-gradient(circle,rgba(244,241,234,.95) 0 .95cqw,transparent 1.05cqw);background-size:3.6cqw 3.6cqw;-webkit-mask-image:linear-gradient(115deg,transparent 8%,#000 55%,transparent 96%);mask-image:linear-gradient(115deg,transparent 8%,#000 55%,transparent 96%)}
394
+ .claimc .pill{margin-top:4cqw;align-self:flex-start;padding:2cqw 3.6cqw;border-radius:999px;background:rgba(244,241,234,.1);box-shadow:inset 0 0 0 .3cqw rgba(244,241,234,.22);font:4.2cqw var(--mono)}.claimc .pill span{opacity:.6}.claimc .pill b{font-weight:500}
395
+ .claimc .cta{position:relative;z-index:5;margin-top:5cqw;height:13cqw;border-radius:999px;border:0;background:#F4F1EA;color:#0B0B0A;font:600 4.6cqw var(--sans);cursor:pointer}
396
+ .claimbar{margin-top:28px;display:flex;justify-content:space-between;align-items:center;gap:16px;flex-wrap:wrap;background:var(--ink);color:#F4F1EA;border-radius:22px;padding:22px 24px}.claimbar b{display:block;font-size:20px;letter-spacing:-.02em}.claimbar span{font-size:14px;opacity:.8}
397
+ .claimbar button{height:48px;padding:0 22px;border-radius:999px;border:0;background:#F4F1EA;color:var(--ink);font:600 15px var(--sans);cursor:pointer}
398
+ .cl{position:fixed;inset:0;z-index:50;background:rgba(11,11,10,.55);display:flex;align-items:center;justify-content:center;padding:16px}.cl[hidden]{display:none}
399
+ .cl-sheet{width:100%;max-width:420px;height:min(860px,calc(100svh - 32px));background:var(--paper);border-radius:28px;display:flex;flex-direction:column;padding:18px 24px 22px;overflow:auto}
400
+ @media(max-width:520px){.cl{padding:0}.cl-sheet{max-width:none;height:100svh;border-radius:0}}
401
+ .cl-top{flex:none;display:flex;justify-content:space-between;align-items:center;font-size:14px;color:var(--ink-3)}.cl-back{background:none;border:0;font:14px var(--sans);color:#3A3935;padding:10px 0;cursor:pointer;min-height:44px}
402
+ .cl-prog{flex:none;display:flex;gap:6px;margin-top:4px}.cl-prog i{flex:1;height:4px;border-radius:2px;background:rgba(11,11,10,.12)}.cl-prog i.on{background:var(--ink)}
403
+ .cl-h{flex:none;margin:24px 0 0;font-size:34px;line-height:1.02;letter-spacing:-.045em;font-weight:650}
404
+ .cl-p{flex:none;margin:10px 0 0;font-size:15px;line-height:1.45;color:#3A3935}
405
+ .cl-hcard{flex:none;margin-top:22px;background:var(--ink);color:#F4F1EA;border-radius:22px;padding:20px;position:relative;overflow:hidden}
406
+ .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
+ .cl-hcard label{position:relative;font-size:13px;opacity:.7}
408
+ .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}
410
+ .cl-hst{position:relative;margin-top:8px;font-size:14px;min-height:20px;opacity:.85}
411
+ .cl-f{flex:none;display:flex;flex-direction:column;gap:6px;margin-top:14px;font-size:14px;font-weight:500}
412
+ .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)}
413
+ .cl-fine{flex:none;margin:10px 0 0;font-size:13px;line-height:1.45;color:var(--ink-3)}.cl-fine.c{text-align:center}
414
+ .cl-err{flex:none;margin-top:12px;font-size:14px;color:#B3261E;font-weight:500}.cl-err:empty{display:none}
415
+ .cl-sp{flex:1 0 16px}
416
+ .cl-go{flex:none;height:56px;border-radius:999px;border:0;background:var(--ink);color:var(--paper);font:600 17px var(--sans);cursor:pointer;width:100%}.cl-go[disabled]{opacity:.5;cursor:wait}
417
+ .cl-skip{flex:none;display:block;width:100%;height:44px;background:none;border:0;font:14px var(--sans);color:var(--ink-3);cursor:pointer}
418
+ .cl-lw{position:relative;margin-top:16px;flex:1 1 auto;min-height:180px;display:flex;flex-direction:column}
419
+ .cl-list{flex:1;overflow:auto;display:flex;flex-direction:column;gap:12px;padding:3px 3px 48px}
420
+ .cl-pc{flex:none;border-radius:20px;padding:16px;background:#fff;color:var(--ink);box-shadow:inset 0 0 0 1px rgba(11,11,10,.12);cursor:pointer}
421
+ .cl-pc.on{box-shadow:0 0 0 3px var(--ink)}
422
+ .cl-pch{display:flex;justify-content:space-between;align-items:center;gap:8px;font-size:12px;font-weight:500}.cl-pch span:first-child{opacity:.8}
423
+ .cl-pick{flex:none;border-radius:999px;padding:5px 11px;box-shadow:inset 0 0 0 1px rgba(11,11,10,.22)}.cl-pc.on .cl-pick{background:var(--ink);color:#fff;box-shadow:none}
424
+ .cl-pc p{margin:10px 0 0;font-size:16px;line-height:1.32;letter-spacing:-.015em;font-weight:500;display:-webkit-box;-webkit-line-clamp:4;-webkit-box-orient:vertical;overflow:hidden;white-space:pre-line}
425
+ .cl-pc textarea{display:block;width:100%;margin-top:10px;min-height:180px;border:0;border-radius:12px;padding:10px 12px;font:15px/1.45 var(--sans);background:rgba(255,255,255,.94);color:var(--ink);resize:vertical}
426
+ .cl-edit{margin-top:6px;background:none;border:0;padding:8px 0;font:600 13px var(--sans);color:inherit;text-decoration:underline;cursor:pointer}
427
+ .hid{background:var(--ink);color:var(--ink);border-radius:5px;padding:0 4px}
428
+ .cl-more{position:absolute;left:0;right:0;bottom:0;height:90px;background:linear-gradient(180deg,rgba(247,243,236,0),var(--paper) 72%);display:flex;align-items:flex-end;justify-content:center;pointer-events:none;padding-bottom:4px}.cl-more[hidden]{display:none}
429
+ .cl-more span{display:inline-flex;align-items:center;height:32px;padding:0 14px;border-radius:999px;background:#fff;box-shadow:0 4px 14px rgba(11,11,10,.08),inset 0 0 0 1px rgba(11,11,10,.1);font-size:13px;font-weight:500;color:#3A3935}
430
+ .cl-opt{flex:none;display:block;border-radius:20px;padding:18px;margin-top:12px;cursor:pointer;background:#fff;box-shadow:inset 0 0 0 1px rgba(11,11,10,.1)}.cl-opt.dk{background:var(--ink);color:#F4F1EA;box-shadow:none}
431
+ .cl-oh{display:flex;justify-content:space-between;align-items:center;gap:12px;font-size:18px;font-weight:650;letter-spacing:-.02em}
432
+ .cl-oh input{width:24px;height:24px;flex:none;accent-color:var(--ink)}.cl-opt.dk .cl-oh input{accent-color:#B0ACFE}
433
+ .cl-ot{display:block;margin-top:8px;font-size:15px;line-height:1.4;color:#3A3935}.cl-opt.dk .cl-ot{color:#F4F1EA;opacity:.9}.cl-opt.dk .cl-ot.s{font-size:13px;opacity:.65}
434
+ .cl-ul{margin:10px 0 0;padding:0;list-style:none;display:flex;flex-direction:column;gap:6px;font-size:15px;line-height:1.4;color:#3A3935}.cl-ul li{display:flex;gap:8px}.cl-ul li:before{content:"";flex:none;width:6px;height:6px;border-radius:50%;background:var(--dot);margin-top:8px}
435
+ .cl-h3{flex:none;margin:26px 0 10px;font-size:18px;letter-spacing:-.02em}
436
+ .cl-two{flex:none;display:grid;grid-template-columns:1fr 1fr;gap:10px}.cl-two div{background:#fff;border-radius:16px;padding:14px;box-shadow:inset 0 0 0 1px rgba(11,11,10,.1);font-size:14px;line-height:1.35}.cl-two b{display:flex;align-items:center;gap:7px;font-size:13px;margin-bottom:4px}.cl-two b i{width:9px;height:9px;border-radius:50%}.cl-two span{display:block;margin-top:6px}
437
+ .cl-link{flex:none;align-self:flex-start;background:none;border:0;padding:10px 0;font:13px var(--sans);color:var(--ink-3);text-decoration:underline;cursor:pointer}
438
+ .cl pre{flex:none;background:var(--ink);color:#F4F1EA;border-radius:14px;padding:14px;font:12px/1.5 var(--mono);white-space:pre-wrap;word-break:break-word;max-height:260px;overflow:auto;margin:0 0 12px}
439
+ .cl-done{flex:none;margin-top:28px;background:var(--ink);color:#F4F1EA;border-radius:24px;padding:28px 24px;position:relative;overflow:hidden}
440
+ .cl-done:before{content:"";position:absolute;inset:0 0 auto auto;width:100%;height:120px;background-image:radial-gradient(circle,rgba(244,241,234,.9) 0 2.6px,transparent 3px);background-size:12px 12px;-webkit-mask-image:linear-gradient(200deg,#000,transparent 70%);mask-image:linear-gradient(200deg,#000,transparent 70%)}
441
+ .cl-done h2{position:relative;margin:90px 0 0;font-size:44px;line-height:.95;letter-spacing:-.05em;font-weight:650}
442
+ .cl-done a.u{position:relative;display:block;margin-top:16px;font:15px var(--mono);color:#F4F1EA;word-break:break-all}
443
+ .cl-done p{position:relative;margin:14px 0 0;font-size:14px;line-height:1.45;opacity:.8}
444
+ .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)}
446
+ .cl-g2{margin-top:22px}.cl-g2>*+*{margin-top:14px}
447
+ .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
+ .cl-tip{margin-top:10px;font-size:12.5px;font-weight:600;opacity:.8}
449
+ .cl-toks{margin-top:8px;font-size:15.5px;line-height:1.6;max-height:300px;overflow:auto;white-space:pre-wrap;overflow-wrap:anywhere;background:rgba(255,255,255,.95);color:var(--ink);border-radius:12px;padding:10px 12px;cursor:auto}
450
+ .tok{font:inherit;color:inherit;background:none;border:0;padding:0 1px;margin:0;border-radius:4px;cursor:pointer}.tok:hover{background:rgba(11,11,10,.1)}.tok.on{background:var(--ink);color:var(--ink)}
451
+ .cl-pc.ed{cursor:default}.cl-narrow{width:100%;max-width:560px;margin:0 auto}
452
+ @media(min-width:900px){.cl-sheet{max-width:1040px;height:min(820px,calc(100svh - 48px));padding:26px 40px 30px}.cl-h{font-size:44px}
453
+ .cl-g2{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:28px;align-items:start}.cl-g2>*+*{margin-top:0}.cl-g2>.cl-hcard{margin-top:0}
454
+ .cl-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));align-content:start;align-items:start}
455
+ .cl-foot{flex-direction:row;justify-content:space-between;align-items:center;gap:20px}.cl-foot .cl-fine{text-align:left}.cl-foot .cl-go{width:auto;min-width:300px;padding:0 34px}.cl-foot .cl-skip{width:auto}}
456
+ `;
457
+ const CLAIM_HTML = `<div class="cl" id="cl" hidden role="dialog" aria-modal="true" aria-label="Claim your handle"><div class="cl-sheet" id="cls"></div></div>`;
458
+ const CLAIM_JS = String.raw`
459
+ var CS={hide:(D.examples||[]).map(function(){return new Set()}),step:1,handle:D.suggest||"",name:"",email:"",picked:[],texts:(D.examples||[]).map(function(e){return e.text}),editing:-1,pub:false,roles:false,feature:false,busy:false,err:"",data:false,done:null};
460
+ var HANDLE_RE=/^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){2,23}$/,EMAIL_RE=/^[^\s@<>"]+@[^\s@<>"]+\.[^\s@<>"]+$/;
461
+ var PCOL=[["#DBA004","#0B0B0A"],["#8B5FD9","#FFFFFF"],["#2B5FE3","#FFFFFF"]];
462
+ function hidHTML(t){return esc(t).replace(/\[hidden\]/g,'<span class="hid">hidden</span>')}
463
+ function claimEl(){var d=document.createElement("article");d.className="card claimc";d.style.setProperty("--bg","#0B0B0A");d.style.setProperty("--fg","#F4F1EA");d.style.setProperty("--bs","15cqw");
464
+ var h=CS.handle||"you";d.innerHTML='<div class="in"><div class="ask">One more thing</div><div class="cdots"></div><div class="big">Claim your handle.</div><div class="pill"><span>usehour.com/</span><b class="grad">@'+esc(h)+'</b></div><div class="line">These cards become your profile. Handles are first come, first served.</div><button type="button" class="cta">Claim @'+esc(h)+'</button><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></div>';
465
+ d.querySelector(".cta").onclick=function(e){e.stopPropagation();openClaim()};return d}
466
+ function openClaim(){document.getElementById("cl").hidden=false;document.body.style.overflow="hidden";renderClaim()}
467
+ function closeClaim(){document.getElementById("cl").hidden=true;document.body.style.overflow=""}
468
+ 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."}
471
+ function toks(i){return CS.texts[i].split(/(\s+)/)}
472
+ function isAuto(w){return w.indexOf("[hidden]")>=0}
473
+ 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()}
474
+ function hidCount(i){return (CS.texts[i].match(/\[hidden\]/g)||[]).length+CS.hide[i].size}
475
+ function viewHTML(i){var h=CS.hide[i];return toks(i).map(function(w,k){if(/^\s+$/.test(w))return w;if(h.has(k))return'<span class="hid">'+esc(w)+'</span>';return hidHTML(w)}).join("")}
476
+ 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
+ function renderClaim(){var s=document.getElementById("cls"),H="",n=CS.step;
478
+ 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>'
480
+ +'<div><label class="cl-f" style="margin-top:0">Your name<input id="cln" autocomplete="name" maxlength="80" value="'+esc(CS.name)+'"></label>'
481
+ +'<label class="cl-f">Email, to keep your handle<input id="cle" type="email" autocomplete="email" maxlength="200" value="'+esc(CS.email)+'"></label>'
482
+ +'<p class="cl-fine">We email you a link to your profile. That’s how you get back in. No password.</p></div></div>'
483
+ +'<div class="cl-err">'+esc(CS.err)+'</div><div class="cl-sp"></div>'
484
+ +'<div class="cl-foot"><p class="cl-fine">Continue checks the name with usehour.com. Nothing else is sent.</p><button type="button" class="cl-go" data-a="next1"'+(CS.busy?" disabled":"")+'>'+(CS.busy?"Checking…":"Continue")+'</button></div>'}
485
+ else if(n===2){var k=CS.picked.length;H=prog(2)+'<h2 class="cl-h">Show how you brief an agent.</h2><p class="cl-p">Your strongest prompts, found in your logs. Pick up to 3. You can black out words, but not add or change any.</p>'
486
+ +'<div class="cl-lw"><div class="cl-list" id="cll">'+D.examples.map(function(e,i){var at=CS.picked.indexOf(i),on=at>=0,c=on?PCOL[at%3]:null,ed=CS.editing===i,w=CS.texts[i].split(/\s+/).filter(Boolean).length,hn=hidCount(i);
487
+ return'<div class="cl-pc'+(on?" on":"")+(ed?" ed":"")+'" data-a="pick" data-i="'+i+'"'+(c?' style="background:'+c[0]+';color:'+c[1]+'"':'')+'><div class="cl-pch"><span>'+esc(e.date?new Date(e.date+"T12:00:00Z").toLocaleString("en",{month:"short",day:"numeric",timeZone:"UTC"})+" · ":"")+w+' words'+(hn?" · "+hn+" hidden":"")+'</span><span class="cl-pick">'+(on?"✓ Picked":"Pick")+'</span></div>'
488
+ +(ed?'<div class="cl-tip">Tap words to black them out. Tap again to bring them back.</div><div class="cl-toks">'+editHTML(i)+'</div>':'<p>“'+viewHTML(i)+'”</p>')
489
+ +'<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
+ +'<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
+ 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>'
498
+ +'<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;
502
+ 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
+ document.getElementById("cls").addEventListener("input",function(e){var t=e.target;
504
+ 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
+ else if(t.id==="cln")CS.name=t.value;else if(t.id==="cle")CS.email=t.value;
506
+ 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()}});
508
+ 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
+ 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
+ else if(a==="close"){closeClaim()}
511
+ 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
+ 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
+ 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}
514
+ else if(a==="tok"){e.stopPropagation();var kk=+b.getAttribute("data-k"),hs=CS.hide[i];if(hs.has(kk))hs.delete(kk);else hs.add(kk);b.classList.toggle("on");var mt=b.closest(".cl-pc").querySelector(".cl-pch span");mt.textContent=mt.textContent.replace(/ \u00B7 \d+ hidden$/,"")+(hidCount(i)?" \u00B7 "+hidCount(i)+" hidden":"")}
515
+ else if(a==="pick"){if(CS.editing===i)return;var at=CS.picked.indexOf(i);CS.err="";if(at>=0)CS.picked.splice(at,1);else if(CS.picked.length>=3)CS.err="Pick up to 3. Untick one first.";else CS.picked.push(i);var y=document.getElementById("cll").scrollTop;renderClaim();document.getElementById("cll").scrollTop=y}
516
+ else if(a==="next2"){CS.err="";CS.editing=-1;CS.step=3;renderClaim()}
517
+ else if(a==="skip2"){CS.picked=[];CS.err="";CS.editing=-1;CS.step=3;renderClaim()}
518
+ else if(a==="data"){CS.data=!CS.data;renderClaim()}
519
+ 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)})}
520
+ else if(a==="create"){if(CS.busy)return;CS.busy=true;CS.err="";renderClaim();
521
+ 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
+ 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
+ else if(x.s===409){CS.step=1;CS.err="Someone just took @"+CS.handle+". Pick another."}
524
+ 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
+ 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}})();
527
+ `;
528
+
281
529
  /* --------------------------------------------------------------- the page */
282
- function pageHTML(m, C) {
283
- const data = JSON.stringify({ hours: m.hours, months: m.months.map((x) => x.prompts), cards: C }).replace(/</g, "\\u003c");
530
+ 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");
533
+ /* The page may talk to exactly one place: Hour's profile endpoint, and only
534
+ when you press the button. With --no-share it may talk to nothing. */
535
+ const connect = api ? new URL(api).origin : "'none'";
536
+ const escH = (t) => String(t).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
537
+ const meta = { period: `${monthYear(m.window.first)} – ${monthYear(m.window.last)}`, sessions: m.sessions.total.toLocaleString("en"), prompts: m.prompts.total.toLocaleString("en") };
538
+ 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
+ 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>';
540
+ 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'`;
284
541
  return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
542
+ <meta http-equiv="Content-Security-Policy" content="${csp}">
285
543
  <title>Your AI Wrapped · Hour</title>
286
544
  <style>
287
545
  @font-face{font-family:"Geist";src:url(data:font/woff2;base64,${FONT_GEIST}) format("woff2");font-weight:100 900}
288
546
  :root{--paper:#F7F3EC;--ink:#0B0B0A;--ink-3:#6C6A64;--hair:rgba(11,11,10,.1);--surface:#fff;--sans:"Geist",ui-sans-serif,system-ui,-apple-system,"Helvetica Neue",Arial,sans-serif;--mono:"Geist Mono",ui-monospace,"SF Mono",Menlo,monospace}
289
547
  *{box-sizing:border-box}body{background:var(--paper);color:var(--ink);font-family:var(--sans);margin:0;-webkit-font-smoothing:antialiased}
290
- .wrap{max-width:1180px;margin:0 auto;padding:24px clamp(16px,4vw,32px) 64px}
291
- .top{display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:22px}
292
- .brand{display:flex;align-items:center;gap:9px;font-weight:600}.brand svg{width:22px;height:22px}
293
- .note{font-size:14px;color:var(--ink-3);max-width:62ch;line-height:1.5}
294
- .stage{max-width:440px;margin:0 auto 14px;position:relative}
295
- .bars{position:absolute;left:4%;right:4%;top:2.6%;display:flex;gap:4px;z-index:3}.bars i{flex:1;height:3px;border-radius:2px;background:rgba(255,255,255,.35)}.bars i.on{background:rgba(255,255,255,.95)}
296
- .hit{position:absolute;top:0;bottom:0;z-index:4;background:transparent;border:0;cursor:pointer}.hit.l{left:0;width:40%}.hit.r{right:0;width:60%}
297
- .nav{display:flex;justify-content:center;gap:10px;margin-bottom:36px}.nav button{font:500 14px var(--sans);border:1px solid var(--hair);background:var(--surface);border-radius:999px;padding:8px 16px;cursor:pointer}
298
- h2{font-size:24px;letter-spacing:-.03em;margin:0 0 14px}
299
- .grid{display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:14px}@media(max-width:1000px){.grid{grid-template-columns:repeat(3,minmax(0,1fr))}}@media(max-width:560px){.grid{grid-template-columns:repeat(2,minmax(0,1fr))}}
300
- .grid .card{cursor:pointer}.save{display:block;margin-top:6px;font-size:12px;color:var(--ink-3);text-align:center}
548
+ .wrap{max-width:1180px;margin:0 auto;padding:24px clamp(16px,4vw,48px) 64px}
549
+ .top{display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap}
550
+ .brand{display:flex;align-items:center;gap:9px;font-weight:600;font-size:17px}.brand svg{width:22px;height:22px}
551
+ .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
+ .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
+ .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}
555
+ .hero-r{display:flex;flex-direction:column}
556
+ .hero h1{margin:4px 0 0;font-size:clamp(48px,6.4vw,76px);line-height:.9;letter-spacing:-.06em;font-weight:650}
557
+ .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}
560
+ .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}
562
+ .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
+ .claimbox p{margin:12px 0 0;max-width:460px;font-size:15.5px;line-height:1.42}
564
+ .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}
565
+ h2.sec{margin:64px 0 18px;font-size:30px;letter-spacing:-.04em;font-weight:650}
566
+ .grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:16px}@media(max-width:1000px){.grid{grid-template-columns:repeat(3,minmax(0,1fr))}}@media(max-width:640px){.grid{grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}}
567
+ .grid .card{cursor:pointer;transition:transform .15s}.grid .card:hover{transform:translateY(-3px)}
568
+ .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
+ .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}}
571
+ .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}
573
+ .intro>*{position:relative}.intro .top .note{color:#F4F1EA;opacity:.75}
574
+ .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
+ .intro h1{margin:18px 0 0;font-size:clamp(56px,9vw,112px);line-height:.88;letter-spacing:-.065em;font-weight:650}
576
+ .intro p{margin:24px 0 0;font-size:clamp(18px,2vw,22px);line-height:1.35;opacity:.9;max-width:560px}
577
+ .intro .go{margin-top:34px;display:flex;gap:12px;flex-wrap:wrap;align-items:center}
578
+ .intro .play{display:inline-flex;align-items:center;gap:12px;height:62px;padding:0 30px 0 24px;border-radius:999px;border:0;background:#F4F1EA;color:var(--ink);font:600 19px var(--sans);cursor:pointer}.intro .play svg{width:20px;height:20px}
579
+ .intro .skip{height:62px;padding:0 20px;border:0;background:none;color:#F4F1EA;opacity:.8;font:500 16px var(--sans);cursor:pointer}
580
+ .intro .tip{font-size:13px;opacity:.55}
581
+ .story{position:fixed;inset:0;z-index:40;background:#161614;color:#F4F1EA;display:flex;flex-direction:column;align-items:center;padding:18px clamp(12px,3vw,32px) 20px}
582
+ .story .top{align-self:stretch}.story .cnt{display:flex;align-items:center;gap:14px;font-size:14px}.story .cnt span{opacity:.6}
583
+ .rb{display:inline-flex;align-items:center;justify-content:center;width:44px;height:44px;border-radius:50%;border:0;background:rgba(244,241,234,.1);color:#F4F1EA;cursor:pointer;flex:none}.rb svg{width:18px;height:18px}
584
+ .st-row{flex:1;min-height:0;width:100%;display:flex;align-items:center;justify-content:center;gap:clamp(8px,3vw,36px)}
585
+ .st-col{--w:min(480px,88vw,calc((100svh - 190px) * .8));width:var(--w);display:flex;flex-direction:column;gap:10px}
586
+ .st-bars{display:flex;gap:4px}.st-bars i{flex:1;height:3px;border-radius:2px;background:rgba(244,241,234,.25);overflow:hidden}.st-bars i b{display:block;height:100%;width:0;background:#F4F1EA}
587
+ .st-slot{position:relative}.st-slot .card{box-shadow:0 40px 100px rgba(0,0,0,.5);border-radius:28px/22px}
588
+ .st-hit{position:absolute;top:0;bottom:0;z-index:4;background:transparent;border:0;cursor:pointer}.st-hit.l{left:0;width:32%}.st-hit.r{right:0;width:68%}
589
+ .nb{width:56px;height:56px}.nb.fw{background:#F4F1EA;color:var(--ink)}.nb svg{width:22px;height:22px}
590
+ @media(max-width:640px){.nb{display:none}}
591
+ .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}
301
592
  .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}
302
593
  .card canvas{position:absolute;inset:0;width:100%;height:100%}
303
594
  .card .in{position:absolute;inset:0;padding:9cqw 8cqw 7cqw;display:flex;flex-direction:column;z-index:1}
@@ -307,22 +598,43 @@ h2{font-size:24px;letter-spacing:-.03em;margin:0 0 14px}
307
598
  .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)}
308
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}
309
600
  .arch{font-size:11.5cqw;line-height:.92;letter-spacing:-.05em;font-weight:650;margin:2cqw 0 6cqw}
601
+ .rows{margin-top:7cqw;display:grid;gap:3.4cqw}.rows div{display:grid;grid-template-columns:1fr auto 10cqw;align-items:center;gap:3cqw;font-size:4.3cqw;font-weight:500}.rows em{display:flex;gap:1.1cqw}.rows u{width:2.7cqw;height:2.7cqw;border-radius:50%;background:currentColor;opacity:.14}.rows u.on{opacity:1;background:#8B5FD9}.rows b{text-align:right;font-weight:600}
602
+ ${api ? CLAIM_CSS : ""}
310
603
  .grad{background:linear-gradient(100deg,#4F88FF 0%,#B0ACFE 38%,#FB65C2 72%,#FD2980 100%);-webkit-background-clip:text;background-clip:text;color:transparent}
311
604
  </style></head><body>
312
605
  <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
+ <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>
612
+ <div class="tip">Tip: use the arrow keys, or tap the left and right of a card. Space pauses.</div>
613
+ </div>
614
+ <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>
616
+ <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
+ <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
+ <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
+ <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
+ </div>
313
621
  <div class="wrap">
314
- <div class="top"><div class="brand"><svg viewBox="0 0 64 64"><use href="#mark"/></svg>Hour</div><div class="note">Made on your machine from ${m.sessions.total} sessions. Nothing has been sent anywhere.</div></div>
315
- <div class="stage" id="stage"><div class="bars" id="bars"></div><div id="slot"></div><button class="hit l" id="prev" aria-label="Previous"></button><button class="hit r" id="next" aria-label="Next"></button></div>
316
- <div class="nav"><button id="b1">Back</button><button id="b2">Next</button></div>
317
- <h2>All cards</h2><div class="grid" id="grid"></div>
318
- <p class="note" style="margin-top:24px">To post a card, click "Save image" under it. The numbers are in wrapped.json next to this file. Private profile pages on usehour.com are coming soon.</p>
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>
623
+ <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>` : ""}
627
+ </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>
630
+ ${api ? CLAIM_HTML : ""}
319
631
  </div>
320
632
  <script>
321
633
  const D=${data};
322
634
  const esc=s=>String(s).replace(/[&<>"]/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c]));
323
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);
324
636
  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>';
325
- 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><div class="big'+(c.grad?" grad":"")+'">'+esc(c.big)+'</div><div class="line">'+esc(c.line)+'</div>'))+ft+'</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>';
326
638
  requestAnimationFrame(()=>draw(d,c));return d}
327
639
  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)}
328
640
  function drawOn(x,W,H,c){if(!c.pat)return;
@@ -349,13 +661,32 @@ c.stats.forEach((s,k)=>{const x=P+(k%2)*(cw+W*.05),yy=top+Math.floor(k/2)*rowH;g
349
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}
350
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;
351
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));
352
- 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}
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*.027,gap=W*.011,pw=W*.1,dx=W-P-pw-10*d-9*gap;c.rows.forEach((w,k)=>{const yy=W*.3+k*W*.1;g.fillStyle=c.fg;g.font="500 "+W*.043+"px "+F;g.fillText(w[0],P,yy);g.font="600 "+W*.043+"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})}}
353
666
  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()}
354
- let cur=0;const slot=document.getElementById("slot"),bars=document.getElementById("bars"),grid=document.getElementById("grid");
355
- D.cards.forEach(()=>bars.appendChild(document.createElement("i")));
356
- function show(n){cur=(n+D.cards.length)%D.cards.length;slot.innerHTML="";slot.appendChild(el(D.cards[cur]));[...bars.children].forEach((b,k)=>b.classList.toggle("on",k<=cur))}
357
- D.cards.forEach((c,k)=>{const w=document.createElement("div");const e=el(c);e.onclick=()=>show(k);const s=document.createElement("a");s.href="#";s.className="save";s.textContent="Save image";s.onclick=ev=>{ev.preventDefault();save(c)};w.append(e,s);grid.appendChild(w)});
358
- prev.onclick=b1.onclick=()=>show(cur-1);next.onclick=b2.onclick=()=>show(cur+1);addEventListener("keydown",e=>{if(e.key==="ArrowRight")show(cur+1);if(e.key==="ArrowLeft")show(cur-1)});show(0);
667
+ let cur=0,timer=null,t0=0,paused=false;const DUR=6000;
668
+ const $=id=>document.getElementById(id),slot=$("slot"),bars=$("bars"),grid=$("grid");
669
+ const N=D.cards.length+(D.api?1:0);for(let k=0;k<N;k++){const i=document.createElement("i");i.appendChild(document.createElement("b"));bars.appendChild(i)}
670
+ function fill(){[...bars.children].forEach((b,k)=>{b.firstChild.style.width=k<cur?"100%":k>cur?"0":b.firstChild.style.width})}
671
+ function tick(){if(paused||$("story").hidden)return;const p=Math.min(1,(performance.now()-t0)/DUR);bars.children[cur].firstChild.style.width=(p*100)+"%";if(p>=1){go(cur+1);return}timer=requestAnimationFrame(tick)}
672
+ function show(n){cancelAnimationFrame(timer);cur=Math.max(0,n);const cl=cur>=D.cards.length;slot.innerHTML="";slot.appendChild(cl?claimEl():el(D.cards[cur]));
673
+ $("hl").style.display=$("hr").style.display=cl?"none":"";$("sts").style.visibility=cl?"hidden":"";$("stz").style.visibility=cl?"hidden":"";
674
+ $("stn").textContent=(cur+1)+" of "+N;[...bars.children].forEach((b,k)=>{b.firstChild.style.width=k<cur?"100%":"0"});
675
+ if(cl){bars.children[cur].firstChild.style.width="100%";return}t0=performance.now();if(!paused)timer=requestAnimationFrame(tick)}
676
+ function go(n){if(n>=N){closeStory();return}show(n)}
677
+ function openStory(n){$("intro").hidden=true;$("story").hidden=false;document.body.style.overflow="hidden";paused=false;setPause();show(n||0)}
678
+ function closeStory(){cancelAnimationFrame(timer);$("story").hidden=true;$("intro").hidden=true;document.body.style.overflow=""}
679
+ function setPause(){$("stz").querySelector("span").textContent=paused?"Play":"Pause"}
680
+ function togglePause(){if(cur>=D.cards.length)return;paused=!paused;setPause();if(!paused){const w=parseFloat(bars.children[cur].firstChild.style.width)||0;t0=performance.now()-w/100*DUR;timer=requestAnimationFrame(tick)}else cancelAnimationFrame(timer)}
681
+ $("play").onclick=()=>openStory(0);$("skip").onclick=closeStory;$("replay").onclick=()=>openStory(0);$("stx").onclick=closeStory;
682
+ $("stp").onclick=$("hl").onclick=()=>show(cur-1);$("stf").onclick=$("hr").onclick=()=>go(cur+1);$("stz").onclick=togglePause;$("sts").onclick=()=>save(D.cards[cur]);
683
+ 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
+ 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()});
685
+ 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
+ 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
+ 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};
689
+ ${api ? CLAIM_JS : ""}
359
690
  </script></body></html>`;
360
691
  }
361
692
 
@@ -380,12 +711,10 @@ function openFile(f) {
380
711
  const args = process.platform === "win32" ? ["/c", "start", "", f] : [f];
381
712
  try { spawn(cmd, args, { stdio: "ignore", detached: true }).unref(); } catch { /* the path is printed anyway */ }
382
713
  }
383
- /* Sharing stays off until usehour.com can store profiles. */
384
- const SHARE_OPEN = false;
385
714
  async function main() {
386
715
  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; };
387
716
  if (flag("--help") || flag("-h")) {
388
- console.log(`Hour Wrapped ${VERSION}\n\n npx usehour [--yes] [--no-open] [--out DIR] [--claude-dir DIR] [--codex-dir DIR] [--no-share]\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 never ask to share\n --out DIR where to write hour-wrapped/ (default: current folder)\n`);
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`);
389
718
  return;
390
719
  }
391
720
  const home = os.homedir();
@@ -394,6 +723,7 @@ async function main() {
394
723
  { src: "codex", label: "Codex", dir: opt("--codex-dir") || path.join(process.env.CODEX_HOME || path.join(home, ".codex"), "sessions") },
395
724
  ].map((s) => ({ ...s, files: walk(s.dir) }));
396
725
  const found = sources.filter((s) => s.files.length);
726
+ const share = !flag("--no-share");
397
727
  console.log(`\n ${B("Hour Wrapped")} ${DIM("v" + VERSION)} Your year of building with AI.\n`);
398
728
  if (!found.length) {
399
729
  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`);
@@ -402,8 +732,8 @@ async function main() {
402
732
  const bytes = found.reduce((a, s) => a + s.files.reduce((b, f) => { try { return b + fs.statSync(f).size; } catch { return b; } }, 0), 0);
403
733
  console.log(" This reads your session logs, on this machine only:");
404
734
  for (const s of found) console.log(` ${s.label.padEnd(12)} ${s.files.length} files ${DIM(s.dir)}`);
405
- console.log(`\n It keeps counts only. Your prompts and code are not saved or sent.`);
406
- console.log(` Nothing leaves this computer unless you say yes at the end.\n`);
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`);
407
737
  if (!flag("--yes")) {
408
738
  if (!process.stdin.isTTY) { console.log(" Run this in a terminal (or add --yes)."); process.exitCode = 1; return; }
409
739
  const a = await ask(` Read ${(bytes / 1e9).toFixed(1)} GB of logs and make your cards? [y/N] `);
@@ -421,7 +751,7 @@ async function main() {
421
751
  if (next >= jobs.length) { w.terminate(); if (--active === 0) resolve(); return; }
422
752
  const job = jobs[next++];
423
753
  w.once("message", (r) => { if (r.ok) summaries.push(r.s); done++; doneBytes += job.size; tick(); feed(w); });
424
- w.postMessage({ file: job.file, src: job.src });
754
+ w.postMessage({ file: job.file, src: job.src, examples: share });
425
755
  };
426
756
  workers.forEach((w) => { active++; feed(w); });
427
757
  });
@@ -434,35 +764,18 @@ async function main() {
434
764
  fs.mkdirSync(outDir, { recursive: true });
435
765
  fs.writeFileSync(path.join(outDir, "wrapped.json"), JSON.stringify(m, null, 2));
436
766
  const page = path.join(outDir, "index.html");
437
- fs.writeFileSync(page, pageHTML(m, C));
767
+ const examples = share ? pickExamples(summaries, 10) : [];
768
+ 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
+ fs.writeFileSync(page, pageHTML(m, C, { api: share ? (opt("--api") || API_DEFAULT).replace(/\/+$/, "") : null, examples, suggest: suggest.length >= 3 ? suggest : "" }));
438
770
  const type = TYPES[builderType(m)].name;
439
771
  console.log(`\n ${B("You're " + type + ".")} ${m.sessions.total} sessions, ${m.prompts.total.toLocaleString("en")} prompts, busiest at ${hourLabel(m.busiestHour)}.`);
440
772
  console.log(` Your cards: ${page}\n`);
441
773
  if (!flag("--no-open")) openFile(page);
442
- if (flag("--no-share") || !process.stdin.isTTY) return;
443
- if (!SHARE_OPEN) { console.log(" Private profile pages on usehour.com are coming soon. Nothing was sent.\n"); return; }
444
-
445
- /* Sharing is a separate, explicit choice. */
446
- const a1 = await ask(` Want a shareable page on usehour.com? This sends Hour the counts in wrapped.json only. [y/N] `);
447
- if (!yes(a1)) { console.log("\n Done. Nothing was sent.\n"); return; }
448
- const email = await ask(" Your email (so we can send you the link): ");
449
- if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) { console.log("\n That doesn't look like an email. Nothing was sent.\n"); return; }
450
- const name = await ask(" Your name, as it should appear on the page: ");
451
- const linkedin = await ask(` LinkedIn URL ${DIM("(optional, press Enter to skip)")}: `);
452
- const pub = yes(await ask(" Make your page public so you can share the link? [y/N] "));
453
- const feature = yes(await ask(" OK for Hour to feature your cards in posts about great AI builders? [y/N] "));
454
- const roles = yes(await ask(" Open to hearing about roles where this matters? [y/N] "));
455
- const payload = { email, name, linkedin, public: pub, feature, roles, wrapped: m };
456
- console.log(`\n This is exactly what will be sent to ${opt("--share-url") || SHARE_URL_DEFAULT}:\n`);
457
- console.log(JSON.stringify(payload, null, 2).split("\n").map((l) => " " + l).join("\n"));
458
- if (!yes(await ask("\n Send it? [y/N] "))) { console.log("\n Not sent. Your cards are still on your machine.\n"); return; }
459
- const body = new URLSearchParams({ "form-name": "wrapped", email, name, linkedin, public: String(pub), feature: String(feature), roles: String(roles), data: JSON.stringify(m) });
460
- try {
461
- const r = await fetch(opt("--share-url") || SHARE_URL_DEFAULT, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body, redirect: "manual" });
462
- if (r.status >= 200 && r.status < 400) console.log(`\n Sent. We'll email ${email} your link within a day.\n`);
463
- else console.log(`\n The upload didn't go through (${r.status}). Email wrapped.json to lush@usehour.com instead.\n`);
464
- } catch (e) {
465
- console.log(`\n Couldn't reach usehour.com (${e.message}). Email wrapped.json to lush@usehour.com instead.\n`);
774
+ 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.`);
777
+ 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
+ console.log("");
466
779
  }
467
780
  }
468
781
  /* ------------------------------------------------------------- assets
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "usehour",
3
- "version": "0.1.0",
4
- "description": "Your year of building with AI, as Wrapped-style cards. Reads your Claude Code and Codex logs locally. Nothing is sent.",
3
+ "version": "0.3.0",
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" },
7
7
  "files": ["hour.mjs"],