@cratis/cast 0.0.1 → 0.1.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 (102) hide show
  1. package/README.md +11 -1
  2. package/badges/RegistryMember.ts +16 -0
  3. package/badges/ResolvedTier.ts +17 -0
  4. package/badges/Tier.ts +14 -0
  5. package/badges/TierSource.ts +14 -0
  6. package/badges/badgeSvg.ts +137 -0
  7. package/badges/constants.ts +17 -0
  8. package/badges/cratisMark.ts +15 -0
  9. package/badges/index.ts +12 -0
  10. package/badges/registry.ts +63 -0
  11. package/badges/resolveTier.ts +24 -0
  12. package/cast.schema.json +1113 -0
  13. package/config/CastConfig.ts +17 -0
  14. package/config/CastConfigError.ts +10 -0
  15. package/config/LiveStat.ts +17 -0
  16. package/config/ResolvedSection.ts +13 -0
  17. package/config/SectionId.ts +21 -0
  18. package/config/ThemeMode.ts +9 -0
  19. package/config/castSchema.ts +262 -0
  20. package/config/index.ts +12 -0
  21. package/config/parseCast.ts +58 -0
  22. package/config/resolveSections.ts +48 -0
  23. package/github/GitHubSnapshot.ts +66 -0
  24. package/github/fetchGitHub.ts +235 -0
  25. package/github/parse.ts +63 -0
  26. package/index.ts +88 -0
  27. package/integration/CastData.ts +20 -0
  28. package/integration/CastOptions.ts +17 -0
  29. package/integration/Snapshot.ts +12 -0
  30. package/integration/loadRegistry.ts +37 -0
  31. package/integration/resolveData.ts +138 -0
  32. package/integration/snapshotFile.ts +23 -0
  33. package/integration/virtualModule.ts +43 -0
  34. package/machine/castJson.ts +64 -0
  35. package/machine/llmsText.ts +69 -0
  36. package/machine/ogImageSvg.ts +79 -0
  37. package/machine/resumeJson.ts +87 -0
  38. package/package.json +76 -8
  39. package/registry/members.yaml +16 -0
  40. package/routes/404.astro +40 -0
  41. package/routes/apple-touch-icon.png.ts +17 -0
  42. package/routes/badge-card.svg.ts +13 -0
  43. package/routes/badge.svg.ts +11 -0
  44. package/routes/cast.json.ts +11 -0
  45. package/routes/cv.astro +371 -0
  46. package/routes/favicon.svg.ts +16 -0
  47. package/routes/index.astro +61 -0
  48. package/routes/llms.txt.ts +10 -0
  49. package/routes/og.png.ts +23 -0
  50. package/routes/resume.json.ts +13 -0
  51. package/routes/robots.txt.ts +10 -0
  52. package/site/components/Activity.astro +194 -0
  53. package/site/components/Agentic.astro +143 -0
  54. package/site/components/CareerReplay.astro +412 -0
  55. package/site/components/CastBadge.astro +132 -0
  56. package/site/components/CommandPalette.astro +116 -0
  57. package/site/components/Contact.astro +163 -0
  58. package/site/components/Cratis.astro +233 -0
  59. package/site/components/CratisMark.astro +18 -0
  60. package/site/components/EventModel.astro +262 -0
  61. package/site/components/Footer.astro +28 -0
  62. package/site/components/Hero.astro +208 -0
  63. package/site/components/Monogram.astro +25 -0
  64. package/site/components/Nav.astro +124 -0
  65. package/site/components/Now.astro +113 -0
  66. package/site/components/Projects.astro +188 -0
  67. package/site/components/PullRequestFeed.astro +86 -0
  68. package/site/components/SectionHeader.astro +27 -0
  69. package/site/components/Skills.astro +41 -0
  70. package/site/components/Stats.astro +101 -0
  71. package/site/components/Talks.astro +73 -0
  72. package/site/components/Terminal.astro +159 -0
  73. package/site/components/Writing.astro +126 -0
  74. package/site/data.ts +48 -0
  75. package/site/layouts/Base.astro +144 -0
  76. package/site/lib/accent.ts +52 -0
  77. package/site/lib/blog.ts +32 -0
  78. package/site/lib/career.ts +148 -0
  79. package/site/lib/clipboard.ts +12 -0
  80. package/site/lib/contributions.ts +63 -0
  81. package/site/lib/github.ts +55 -0
  82. package/site/lib/inline.ts +23 -0
  83. package/site/lib/lifecycle.ts +44 -0
  84. package/site/lib/motion.ts +43 -0
  85. package/site/lib/nav.ts +38 -0
  86. package/site/lib/reltime.ts +33 -0
  87. package/site/lib/safeJson.ts +15 -0
  88. package/site/lib/sections.ts +41 -0
  89. package/site/lib/theme.ts +28 -0
  90. package/site/scripts/ambient.ts +93 -0
  91. package/site/scripts/event-board.ts +403 -0
  92. package/site/scripts/event-model.ts +155 -0
  93. package/site/scripts/heatmap.ts +94 -0
  94. package/site/scripts/live.ts +145 -0
  95. package/site/scripts/palette.ts +296 -0
  96. package/site/scripts/replay.ts +251 -0
  97. package/site/scripts/terminal.ts +556 -0
  98. package/site/styles/global.css +508 -0
  99. package/virtual.d.ts +15 -0
  100. package/writing/WritingSnapshot.ts +20 -0
  101. package/writing/fetchWriting.ts +54 -0
  102. package/writing/parse.ts +72 -0
@@ -0,0 +1,145 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ // Public, unauthenticated home-page refresh. Snapshot rendering never depends on it.
5
+ import { onPageLoad } from '../lib/lifecycle';
6
+ import { relativeTime } from '../lib/reltime';
7
+ import type { PullRequest } from '../lib/github';
8
+ import { config } from '../data';
9
+
10
+ type SearchItem = { repository_url?: string; pull_request?: { merged_at?: string }; title?: unknown; number?: unknown; html_url?: string };
11
+ type SearchResponse = { total_count?: unknown; incomplete_results?: boolean; items?: unknown };
12
+ type PublicEvent = { type?: string; repo?: { name?: string }; created_at?: string; payload?: { action?: string; ref_type?: string; pull_request?: { merged?: boolean } } };
13
+
14
+ const login = encodeURIComponent(config.github.login);
15
+ const orgs = config.github.orgs;
16
+ const orgPattern = new RegExp(`^https://api\\.github\\.com/repos/(${orgs.map(org => org.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})/([^/]+)$`, 'i');
17
+
18
+ const TTL = 5 * 60_000;
19
+ type Merged = { total: number; latest: PullRequest[] };
20
+ type Activity = { type: string; repo: string; at: string };
21
+ let merged: { at: number; data: Merged } | undefined;
22
+ const pending = new Map<string, Promise<unknown>>();
23
+
24
+ export const getLiveMerged = (): Merged | undefined => merged && Date.now() - merged.at < TTL ? merged.data : undefined;
25
+
26
+ async function cached<T>(key: string, path: string, parse: (data: unknown) => T): Promise<T> {
27
+ try {
28
+ const entry = JSON.parse(sessionStorage.getItem(`github:${key}`) ?? 'null');
29
+ if (entry && Date.now() - entry.at >= 0 && Date.now() - entry.at < TTL) return parse(entry.data);
30
+ } catch { /* Storage may be disabled; the snapshot still works. */ }
31
+ const existing = pending.get(key);
32
+ if (existing) return existing as Promise<T>;
33
+ const request = (async () => {
34
+ const res = await fetch(`https://api.github.com${path}`, { signal: AbortSignal.timeout(8000) });
35
+ if (res.status !== 200) throw new Error('GitHub unavailable');
36
+ const data: unknown = await res.json();
37
+ const parsed = parse(data);
38
+ try { sessionStorage.setItem(`github:${key}`, JSON.stringify({ at: Date.now(), data })); } catch { /* Optional cache. */ }
39
+ return parsed;
40
+ })();
41
+ pending.set(key, request);
42
+ try { return await request; } finally { pending.delete(key); }
43
+ }
44
+
45
+ function parseMerged(raw: unknown): Merged {
46
+ const data = raw as SearchResponse | null;
47
+ const total = data?.total_count;
48
+ if (typeof total !== 'number' || !Number.isSafeInteger(total) || total < 0 || data?.incomplete_results || !Array.isArray(data?.items)) throw new Error('Invalid search');
49
+ const latest: PullRequest[] = (data.items as SearchItem[]).flatMap((item) => {
50
+ const match = orgPattern.exec(item?.repository_url ?? '');
51
+ const at = item?.pull_request?.merged_at ?? '';
52
+ if (!match || !Number.isFinite(Date.parse(at)) || typeof item.title !== 'string' || typeof item.number !== 'number' || !Number.isSafeInteger(item.number)) return [];
53
+ const repository = `${match[1]}/${match[2]}`;
54
+ if (item.html_url?.toLowerCase() !== `https://github.com/${repository}/pull/${item.number}`.toLowerCase()) return [];
55
+ return [{ title: item.title, repository, number: item.number, url: item.html_url, mergedAt: at }];
56
+ }).sort((a, b) => b.mergedAt.localeCompare(a.mergedAt));
57
+ return { total, latest };
58
+ }
59
+
60
+ function parseEvents(raw: unknown): Activity[] {
61
+ if (!Array.isArray(raw)) throw new Error('Invalid events');
62
+ return (raw as PublicEvent[]).flatMap((event) => {
63
+ if (!event?.repo?.name || !event.created_at || !Number.isFinite(Date.parse(event.created_at))) return [];
64
+ const p = event.payload ?? {};
65
+ let type: string | undefined;
66
+ switch (event.type) {
67
+ case 'PushEvent': type = 'CommitsPushed'; break;
68
+ case 'PullRequestEvent': if (p.action === 'closed' && p.pull_request?.merged) type = 'PullRequestMerged'; break;
69
+ case 'CreateEvent': type = p.ref_type === 'branch' ? 'BranchCreated' : p.ref_type === 'tag' ? 'TagCreated' : undefined; break;
70
+ case 'ReleaseEvent': if (p.action === 'published') type = 'ReleasePublished'; break;
71
+ case 'IssuesEvent': type = p.action === 'opened' ? 'IssueOpened' : p.action === 'closed' ? 'IssueClosed' : undefined; break;
72
+ case 'IssueCommentEvent': if (p.action === 'created') type = 'CommentAdded'; break;
73
+ case 'PullRequestReviewEvent': if (p.action === 'submitted') type = 'ReviewSubmitted'; break;
74
+ }
75
+ return type ? [{ type, repo: event.repo.name, at: event.created_at }] : [];
76
+ }).sort((a: Activity, b: Activity) => b.at.localeCompare(a.at));
77
+ }
78
+
79
+ function renderMerged(data: Merged): void {
80
+ const fmt = new Intl.NumberFormat('en-US');
81
+ document.querySelectorAll<HTMLElement>('[data-merged-prs]').forEach((el) => {
82
+ const kind = el.dataset['mergedPrs'];
83
+ el.dataset['live'] = 'true'; // Supersede any snapshot count-up animation.
84
+ el.textContent = fmt.format(kind === 'exact' || data.total < 100 ? data.total : Math.floor(data.total / 100) * 100) + (kind === 'floor' && data.total >= 100 ? '+' : '');
85
+ });
86
+ document.querySelectorAll('[data-pr-status]').forEach((el) => { el.textContent = 'live'; });
87
+ const list = document.querySelector('[data-pr-feed]');
88
+ const template = document.querySelector<HTMLTemplateElement>('[data-pr-template]');
89
+ if (!list || !template || !data.latest.length) return;
90
+ const rows = data.latest.slice(0, 8).map((pr, i) => {
91
+ const row = template.content.firstElementChild!.cloneNode(true) as HTMLElement;
92
+ row.style.setProperty('--i', String(i));
93
+ const set = (selector: string, text: string) => { row.querySelector(selector)!.textContent = text; };
94
+ row.querySelector('a')!.href = pr.url;
95
+ set('[data-pr-head]', i === 0 ? 'HEAD' : `HEAD~${i}`);
96
+ set('[data-pr-repo]', pr.repository);
97
+ set('[data-pr-number]', `#${pr.number}`);
98
+ set('[data-pr-title]', pr.title);
99
+ const time = row.querySelector('time')!;
100
+ time.dateTime = pr.mergedAt;
101
+ time.textContent = relativeTime(pr.mergedAt);
102
+ return row;
103
+ });
104
+ list.replaceChildren(...rows);
105
+ }
106
+
107
+ onPageLoad(() => {
108
+ if (!document.querySelector('[data-hero]')) return;
109
+ let active = true;
110
+ let idle: number | undefined;
111
+ let timer: ReturnType<typeof setTimeout> | undefined;
112
+ const refresh = () => {
113
+ if (!active || document.visibilityState !== 'visible') return;
114
+ void cached('merged', `/search/issues?q=author:${login}+${orgs.map(org => `org:${encodeURIComponent(org)}`).join('+')}+is:public+is:pr+is:merged&sort=updated&order=desc&per_page=12`, parseMerged)
115
+ .then((data) => {
116
+ merged = { at: Date.now(), data };
117
+ if (active && document.visibilityState === 'visible') renderMerged(data);
118
+ }).catch(() => { /* Quietly keep the build-time snapshot. */ });
119
+ void cached('events', `/users/${login}/events/public?per_page=30`, parseEvents)
120
+ .then((events) => {
121
+ if (!active || document.visibilityState !== 'visible' || !events.length) return;
122
+ const event = events[0]!;
123
+ const el = document.querySelector('[data-public-event]');
124
+ if (el) {
125
+ el.textContent = `last event ${relativeTime(event.at)}`;
126
+ el.setAttribute('title', `${event.type} · ${event.repo} · ${event.at}`);
127
+ }
128
+ const status = document.querySelector('[data-event-status]');
129
+ if (status) status.textContent = 'live';
130
+ }).catch(() => { /* Quietly keep the career-stream position. */ });
131
+ };
132
+ const schedule = () => {
133
+ if (document.visibilityState !== 'visible' || idle !== undefined || timer !== undefined) return;
134
+ if ('requestIdleCallback' in window) idle = window.requestIdleCallback(() => { idle = undefined; refresh(); });
135
+ else timer = setTimeout(() => { timer = undefined; refresh(); }, 0);
136
+ };
137
+ document.addEventListener('visibilitychange', schedule);
138
+ schedule();
139
+ return () => {
140
+ active = false;
141
+ document.removeEventListener('visibilitychange', schedule);
142
+ if (idle !== undefined) window.cancelIdleCallback(idle);
143
+ if (timer !== undefined) clearTimeout(timer);
144
+ };
145
+ });
@@ -0,0 +1,296 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ // ⌘K command palette: fuzzy filtering, full keyboard navigation, focus return.
5
+ import { person, sections, profileLinks, cratis, eventModel } from '../data';
6
+ import { anchorFor } from '../lib/sections';
7
+ import { copyText } from '../lib/clipboard';
8
+ import { motionReduced, toggleMotion } from '../lib/motion';
9
+ import { currentTheme, toggleTheme } from '../lib/theme';
10
+ import { go, goSection, openExternal, startReplay } from '../lib/nav';
11
+
12
+ type Kind = 'command' | 'event' | 'view';
13
+ type Item = {
14
+ id: string;
15
+ group: 'Navigate' | 'Actions' | 'Links';
16
+ label: string;
17
+ keywords?: string;
18
+ kind: Kind;
19
+ hint?: () => string;
20
+ run: () => void | Promise<void>;
21
+ };
22
+
23
+ const SWATCH: Record<Kind, string> = {
24
+ command: 'var(--color-command)',
25
+ event: 'var(--color-event)',
26
+ view: 'var(--color-view)',
27
+ };
28
+
29
+ const open = openExternal;
30
+
31
+ /** Subsequence match with bonuses for word starts and contiguous runs. */
32
+ export function fuzzyScore(query: string, text: string): number {
33
+ const q = query.toLowerCase().trim();
34
+ if (!q) return 1;
35
+ const t = text.toLowerCase();
36
+ if (t.includes(q)) return 100 - t.indexOf(q);
37
+ let score = 0;
38
+ let ti = 0;
39
+ let run = 0;
40
+ let first = -1;
41
+ const chars = q.replace(/\s+/g, '');
42
+ for (const ch of chars) {
43
+ const found = t.indexOf(ch, ti);
44
+ if (found === -1) return 0;
45
+ if (first === -1) first = found;
46
+ // Matches scattered across the whole string are noise, not intent.
47
+ if (found - first > chars.length * 3 + 2) return 0;
48
+ const wordStart = found === 0 || /[\s\-/.]/.test(t[found - 1] ?? '');
49
+ run = found === ti ? run + 1 : 0;
50
+ score += 1 + (wordStart ? 3 : 0) + run * 2;
51
+ ti = found + 1;
52
+ }
53
+ return score;
54
+ }
55
+
56
+ export function mountPalette(): void {
57
+ const dialog = document.querySelector<HTMLDialogElement>('[data-palette]');
58
+ const input = dialog?.querySelector<HTMLInputElement>('[data-palette-input]');
59
+ const list = dialog?.querySelector<HTMLUListElement>('[data-palette-list]');
60
+ if (!dialog || !input || !list) return;
61
+
62
+ let toast: HTMLElement | null = null;
63
+ const notify = (msg: string) => {
64
+ toast?.remove();
65
+ toast = document.createElement('div');
66
+ toast.setAttribute('role', 'status');
67
+ toast.className =
68
+ 'fixed bottom-6 left-1/2 z-[90] -translate-x-1/2 rounded-full border border-line-strong bg-ink-800/95 px-4 py-2 font-mono text-xs text-fg shadow-2xl backdrop-blur';
69
+ toast.textContent = msg;
70
+ document.body.append(toast);
71
+ const el = toast;
72
+ window.setTimeout(() => el.remove(), 2200);
73
+ };
74
+
75
+ const allItems: Item[] = [
76
+ { id: 'about', group: 'Navigate', label: 'Now & about', keywords: 'read model projection photo', kind: 'view', run: () => goSection('about') },
77
+ { id: 'career', group: 'Navigate', label: 'Career event stream', keywords: 'experience jobs timeline', kind: 'event', run: () => goSection('career') },
78
+ { id: 'model', group: 'Navigate', label: 'How I think: event model', keywords: `${eventModel?.name ?? ''} event modeling board slice`, kind: 'command', run: () => goSection('model') },
79
+ { id: 'cratis', group: 'Navigate', label: 'Cratis', keywords: 'open source chronicle arc', kind: 'command', run: () => goSection('cratis') },
80
+ { id: 'activity', group: 'Navigate', label: 'Contribution event store', keywords: 'github heatmap activity calendar', kind: 'event', run: () => goSection('activity') },
81
+ { id: 'agentic', group: 'Navigate', label: 'Agentic engineering', keywords: 'ai agents harness pi review workflow', kind: 'view', run: () => goSection('agentic') },
82
+ { id: 'projects', group: 'Navigate', label: 'Things I build', keywords: 'projects cli agents apps', kind: 'command', run: () => goSection('projects') },
83
+ { id: 'writing', group: 'Navigate', label: 'Writing', keywords: 'blog posts articles', kind: 'event', run: () => goSection('writing') },
84
+ { id: 'talks', group: 'Navigate', label: 'Talks & community', keywords: 'webinar conference event modeling', kind: 'event', run: () => goSection('talks') },
85
+ { id: 'skills', group: 'Navigate', label: 'Skills', keywords: 'toolbox stack', kind: 'view', run: () => goSection('skills') },
86
+ { id: 'contact', group: 'Navigate', label: 'Contact', keywords: 'email message hire', kind: 'command', run: () => goSection('contact') },
87
+ { id: 'top', group: 'Navigate', label: 'Back to top', keywords: 'home hero start', kind: 'view', run: () => goSection('top') },
88
+
89
+ {
90
+ id: 'replay', group: 'Actions', label: 'Replay career', keywords: 'play event stream projection', kind: 'event',
91
+ run: startReplay,
92
+ },
93
+ {
94
+ id: 'terminal', group: 'Actions', label: 'Open terminal', keywords: 'shell console cli bash git log', kind: 'command',
95
+ hint: () => '`',
96
+ run: () => void document.dispatchEvent(new CustomEvent('terminal:open')),
97
+ },
98
+ { id: 'cv', group: 'Actions', label: 'Open CV', keywords: 'resume curriculum vitae', kind: 'view', run: () => go('/cv') },
99
+ {
100
+ id: 'print', group: 'Actions', label: 'Print CV / save as PDF', keywords: 'resume pdf download', kind: 'view',
101
+ run: () => {
102
+ if (location.pathname.startsWith('/cv')) window.print();
103
+ else void go('/cv?print');
104
+ },
105
+ },
106
+ ...(person.email ? [<Item>{
107
+ id: 'email', group: 'Actions', label: 'Copy email address', keywords: person.email, kind: 'command',
108
+ hint: () => person.email!,
109
+ run: async () => notify((await copyText(person.email!)) ? `Copied ${person.email}` : 'Copy failed'),
110
+ }] : []),
111
+ {
112
+ id: 'theme', group: 'Actions', label: 'Toggle light / dark theme', keywords: 'appearance colour color mode', kind: 'view',
113
+ hint: () => currentTheme(),
114
+ run: () => notify(`Theme: ${toggleTheme()}`),
115
+ },
116
+ {
117
+ id: 'motion', group: 'Actions', label: 'Toggle motion', keywords: 'animation reduce accessibility', kind: 'command',
118
+ hint: () => (motionReduced() ? 'off' : 'on'),
119
+ run: () => notify(toggleMotion() ? 'Motion reduced' : 'Motion on'),
120
+ },
121
+
122
+ ...profileLinks.map((link): Item => ({
123
+ id: `link-${link.key}`, group: 'Links' as const, label: link.label, keywords: `${link.key} ${person.handle} profile`, kind: 'event',
124
+ hint: () => link.url.replace(/^https?:\/\//, ''), run: () => open(link.url),
125
+ })),
126
+ ...Object.entries(cratis?.links ?? {}).map(([label, url]): Item => ({
127
+ id: `cratis-${label}`, group: 'Links' as const, label, keywords: 'cratis website docs repositories', kind: 'event', run: () => open(url),
128
+ })),
129
+ { id: 'cast', group: 'Links' as const, label: 'Cast verification', keywords: 'badge cratis cast tier verified', kind: 'view', run: () => open(`https://cast.cratis.io/${person.handle.toLowerCase()}`) },
130
+ ];
131
+ const items = allItems.filter((item) => item.group !== 'Navigate' || item.id === 'top' || sections.some((section) => anchorFor[section.id] === item.id));
132
+
133
+ let results: Item[] = items;
134
+ let active = 0;
135
+ let returnFocus: HTMLElement | null = null;
136
+
137
+ function render() {
138
+ const q = input!.value;
139
+ results = items
140
+ .map((item) => ({ item, score: fuzzyScore(q, `${item.label} ${item.keywords ?? ''}`) * (fuzzyScore(q, item.label) ? 2 : 1) }))
141
+ .filter((r) => r.score > 0)
142
+ .sort((a, b) => (q ? b.score - a.score : 0))
143
+ .map((r) => r.item);
144
+ active = Math.min(active, Math.max(0, results.length - 1));
145
+
146
+ list!.replaceChildren();
147
+ if (!results.length) {
148
+ const li = document.createElement('li');
149
+ li.className = 'px-3 py-8 text-center font-mono text-sm text-fg-faint';
150
+ li.setAttribute('role', 'presentation');
151
+ li.textContent = 'EventNotFound: no matching commands';
152
+ list!.append(li);
153
+ input!.removeAttribute('aria-activedescendant');
154
+ return;
155
+ }
156
+ let lastGroup = '';
157
+ results.forEach((item, i) => {
158
+ if (!q && item.group !== lastGroup) {
159
+ lastGroup = item.group;
160
+ const g = document.createElement('li');
161
+ g.className = 'grp';
162
+ g.setAttribute('role', 'presentation');
163
+ g.textContent = item.group;
164
+ list!.append(g);
165
+ }
166
+ const li = document.createElement('li');
167
+ li.id = `pal-${item.id}`;
168
+ li.setAttribute('role', 'option');
169
+ li.setAttribute('aria-selected', String(i === active));
170
+ li.dataset['index'] = String(i);
171
+ const sw = document.createElement('span');
172
+ sw.className = 'sw';
173
+ sw.style.background = SWATCH[item.kind];
174
+ sw.setAttribute('aria-hidden', 'true');
175
+ const label = document.createElement('span');
176
+ label.textContent = item.label;
177
+ li.append(sw, label);
178
+ const hint = item.hint?.();
179
+ if (hint) {
180
+ const h = document.createElement('span');
181
+ h.className = 'hint';
182
+ h.textContent = hint;
183
+ li.append(h);
184
+ }
185
+ list!.append(li);
186
+ });
187
+ syncActive();
188
+ }
189
+
190
+ function syncActive() {
191
+ list!.querySelectorAll<HTMLElement>('[role="option"]').forEach((el) => {
192
+ const on = Number(el.dataset['index']) === active;
193
+ el.setAttribute('aria-selected', String(on));
194
+ if (on) {
195
+ input!.setAttribute('aria-activedescendant', el.id);
196
+ el.scrollIntoView({ block: 'nearest' });
197
+ }
198
+ });
199
+ }
200
+
201
+ function show() {
202
+ if (dialog!.open) return;
203
+ returnFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
204
+ input!.value = '';
205
+ active = 0;
206
+ render();
207
+ dialog!.showModal();
208
+ document.documentElement.style.overflow = 'hidden';
209
+ input!.focus();
210
+ }
211
+
212
+ function hide() {
213
+ if (dialog!.open) dialog!.close();
214
+ }
215
+
216
+ dialog.addEventListener('close', () => {
217
+ document.documentElement.style.overflow = '';
218
+ returnFocus?.focus({ preventScroll: true });
219
+ });
220
+
221
+ async function runActive(index = active) {
222
+ const item = results[index];
223
+ if (!item) return;
224
+ // Navigation moves focus to the target section; everything else returns focus.
225
+ if (item.group === 'Navigate' || item.id === 'replay' || item.id === 'terminal') returnFocus = null;
226
+ hide();
227
+ await item.run();
228
+ }
229
+
230
+ input.addEventListener('input', () => {
231
+ active = 0;
232
+ render();
233
+ });
234
+
235
+ input.addEventListener('keydown', (e) => {
236
+ if (e.key === 'ArrowDown' || (e.key === 'n' && e.ctrlKey)) {
237
+ e.preventDefault();
238
+ active = (active + 1) % Math.max(1, results.length);
239
+ syncActive();
240
+ } else if (e.key === 'ArrowUp' || (e.key === 'p' && e.ctrlKey)) {
241
+ e.preventDefault();
242
+ active = (active - 1 + results.length) % Math.max(1, results.length);
243
+ syncActive();
244
+ } else if (e.key === 'Home' && e.ctrlKey) {
245
+ active = 0;
246
+ syncActive();
247
+ } else if (e.key === 'Enter') {
248
+ e.preventDefault();
249
+ void runActive();
250
+ }
251
+ });
252
+
253
+ // Keep Tab inside the dialog (the input and close button are the only stops).
254
+ dialog.addEventListener('keydown', (e) => {
255
+ if (e.key !== 'Tab') return;
256
+ const stops = [...dialog.querySelectorAll<HTMLElement>('input, button')];
257
+ const first = stops[0];
258
+ const last = stops[stops.length - 1];
259
+ if (e.shiftKey && document.activeElement === first) {
260
+ e.preventDefault();
261
+ last?.focus();
262
+ } else if (!e.shiftKey && document.activeElement === last) {
263
+ e.preventDefault();
264
+ first?.focus();
265
+ }
266
+ });
267
+
268
+ list.addEventListener('click', (e) => {
269
+ const li = (e.target as Element).closest<HTMLElement>('[role="option"]');
270
+ if (li) void runActive(Number(li.dataset['index']));
271
+ });
272
+ list.addEventListener('pointermove', (e) => {
273
+ const li = (e.target as Element).closest<HTMLElement>('[role="option"]');
274
+ const i = Number(li?.dataset['index']);
275
+ if (li && i !== active) {
276
+ active = i;
277
+ syncActive();
278
+ }
279
+ });
280
+
281
+ dialog.addEventListener('click', (e) => {
282
+ if (e.target === dialog) hide();
283
+ });
284
+ dialog.querySelector('[data-palette-close]')?.addEventListener('click', hide);
285
+
286
+ document.addEventListener('click', (e) => {
287
+ if ((e.target as Element).closest('[data-palette-open]')) show();
288
+ });
289
+ document.addEventListener('keydown', (e) => {
290
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
291
+ e.preventDefault();
292
+ if (dialog.open) hide();
293
+ else show();
294
+ }
295
+ });
296
+ }