@cratis/cast 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +3 -1
  2. package/badges/RegistryMember.ts +2 -0
  3. package/badges/badgeSvg.ts +10 -1
  4. package/badges/registry.ts +5 -0
  5. package/cast-json.schema.json +1496 -0
  6. package/config/calendar.ts +34 -0
  7. package/config/castSchema.ts +3 -2
  8. package/github/gitHubSnapshotSchema.ts +58 -0
  9. package/machine/castJson.ts +17 -4
  10. package/machine/castJsonSchema.ts +35 -0
  11. package/machine/index.ts +6 -0
  12. package/machine/profileFromCastJson.ts +215 -0
  13. package/package.json +5 -1
  14. package/routes/404.astro +6 -4
  15. package/routes/cast.json.ts +7 -1
  16. package/routes/cv.astro +3 -365
  17. package/routes/index.astro +3 -55
  18. package/site/components/Activity.astro +9 -8
  19. package/site/components/Agentic.astro +11 -10
  20. package/site/components/CareerReplay.astro +11 -8
  21. package/site/components/CommandPalette.astro +10 -4
  22. package/site/components/Contact.astro +8 -5
  23. package/site/components/Cratis.astro +18 -16
  24. package/site/components/EventModel.astro +8 -5
  25. package/site/components/Footer.astro +9 -4
  26. package/site/components/Hero.astro +13 -10
  27. package/site/components/Inline.astro +45 -0
  28. package/site/components/Monogram.astro +4 -1
  29. package/site/components/Nav.astro +12 -9
  30. package/site/components/Now.astro +13 -10
  31. package/site/components/Projects.astro +11 -14
  32. package/site/components/PullRequestFeed.astro +6 -4
  33. package/site/components/SectionHeader.astro +6 -3
  34. package/site/components/Skills.astro +5 -2
  35. package/site/components/Stats.astro +9 -4
  36. package/site/components/Talks.astro +8 -5
  37. package/site/components/Terminal.astro +7 -2
  38. package/site/components/Writing.astro +9 -7
  39. package/site/data.ts +6 -43
  40. package/site/layouts/Base.astro +31 -19
  41. package/site/lib/blog.ts +42 -16
  42. package/site/lib/github.ts +56 -31
  43. package/site/lib/inline.ts +65 -9
  44. package/site/lib/nav.ts +14 -3
  45. package/site/pages/CvPage.astro +379 -0
  46. package/site/pages/ProfilePage.astro +78 -0
  47. package/site/profile/CastHost.ts +32 -0
  48. package/site/profile/CastView.ts +96 -0
  49. package/site/profile/assertRenderable.ts +151 -0
  50. package/site/profile/clientCast.ts +120 -0
  51. package/site/profile/index.ts +8 -0
  52. package/site/scripts/live.ts +21 -11
  53. package/site/scripts/palette.ts +30 -13
  54. package/site/scripts/replay.ts +2 -1
  55. package/site/scripts/terminal.ts +40 -29
  56. package/writing/fetchWriting.ts +17 -2
  57. package/writing/writingSnapshotSchema.ts +27 -0
@@ -0,0 +1,151 @@
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
+ import { isCalendarDate, isCalendarMonth, isInstant, isPartialDate } from '../../config/calendar';
5
+ import { accentTokens } from '../lib/accent';
6
+ import { alsoText, apply, formatMonth, initialProjection, normalizeDate, yearsBetween } from '../lib/career';
7
+ import { foldCalendar, formatDay, formatMonthLong } from '../lib/contributions';
8
+ import { parseInline, plainText } from '../lib/inline';
9
+ import { safeJson } from '../lib/safeJson';
10
+ import { clientCast } from './clientCast';
11
+ import type { CastView } from './CastView';
12
+
13
+ const webProtocols = new Set(['http:', 'https:']);
14
+
15
+ function fail(what: string, value: unknown): never {
16
+ throw new Error(`${what}: cannot render ${JSON.stringify(value)?.slice(0, 120)}`);
17
+ }
18
+
19
+ function url(what: string, value: string, base?: string, protocols: ReadonlySet<string> = webProtocols): void {
20
+ let parsed: URL;
21
+ try {
22
+ parsed = new URL(value, base);
23
+ } catch {
24
+ fail(what, value);
25
+ }
26
+ if (!protocols.has(parsed.protocol)) fail(what, value);
27
+ }
28
+
29
+ function count(what: string, value: number | undefined): void {
30
+ if (value !== undefined && !(Number.isSafeInteger(value) && value >= 0)) fail(what, value);
31
+ }
32
+
33
+ /** A date the components format: an unreadable one shows as 'Invalid Date', or as a month name that does not exist. */
34
+ function shown(what: string, formatted: string, value: string): void {
35
+ if (formatted.includes('Invalid Date') || formatted.startsWith(' ')) fail(what, value);
36
+ }
37
+
38
+ function inline(what: string, text: string | undefined): void {
39
+ if (text === undefined) return;
40
+ if (typeof text !== 'string') fail(what, text);
41
+ parseInline(text);
42
+ plainText(text);
43
+ }
44
+
45
+ function links(cast: CastView): void {
46
+ const { config, person, github, posts } = cast;
47
+ for (const [key, value] of Object.entries(person.links)) url(`person.links.${key}`, value);
48
+ if (person.email) url('person.email', `mailto:${person.email}`, undefined, new Set(['mailto:']));
49
+ url('site.url', config.site.url);
50
+ if (config.site.source) url('site.source', config.site.source);
51
+ // Base resolves the portrait against the site; the directory resolves relative links in text against the member's site.
52
+ if (cast.photoUrl) url('photo', cast.photoUrl, config.site.url, new Set(['https:']));
53
+ if (cast.host.linkOrigin) url('linkOrigin', cast.host.linkOrigin);
54
+ for (const [key, value] of Object.entries(cast.cratis?.links ?? {})) url(`cratis.links.${key}`, value);
55
+ for (const repo of cast.cratisRepos) if (repo.url) url(`cratis.repos.${repo.name}`, repo.url);
56
+ for (const project of cast.liveProjects) url(`projects.${project.name}`, project.url);
57
+ for (const talk of cast.talks) if (talk.url) url(`talks.${talk.title}`, talk.url);
58
+ for (const certification of cast.certifications) if (certification.url) url(`certifications.${certification.title}`, certification.url);
59
+ for (const item of cast.agentic?.items ?? []) if (item.url) url(`agentic.items.${item.name}`, item.url);
60
+ if (cast.eventModel) url('eventModel.repo', cast.eventModel.repo);
61
+ for (const event of cast.careerEvents) if (event.orgUrl) url(`career.${event.title}.orgUrl`, event.orgUrl);
62
+ for (const pullRequest of github.mergedPullRequests.latest) url(`github.pullRequest.${pullRequest.number}`, pullRequest.url);
63
+ for (const [name, release] of Object.entries(github.releases)) url(`github.releases.${name}`, release.url);
64
+ if (github.profile?.avatarUrl) url('github.avatarUrl', github.profile.avatarUrl);
65
+ for (const source of cast.writing.sources) url('writing.sources', source);
66
+ for (const post of posts) url(`writing.posts.${post.title}`, post.url);
67
+ }
68
+
69
+ function texts(cast: CastView): void {
70
+ const { person, cratis, agentic, eventModel } = cast;
71
+ for (const [what, text] of [['tagline', person.tagline], ['intro', person.intro], ['cratis.pitch', cratis?.pitch], ['cratis.role', cratis?.role], ['agentic.pitch', agentic?.pitch], ['eventModel.intro', eventModel?.intro]] as const) inline(what, text);
72
+ person.about.forEach((text, index) => inline(`about.${index}`, text));
73
+ cast.careerEvents.forEach((event, index) => inline(`career.${index}.summary`, event.summary));
74
+ cast.projects.forEach((project, index) => inline(`projects.${index}.pitch`, project.pitch));
75
+ cast.talks.forEach((talk, index) => inline(`talks.${index}.summary`, talk.summary));
76
+ cast.cratisRepos.forEach((repo, index) => inline(`cratis.repos.${index}.desc`, repo.desc));
77
+ agentic?.workflow.forEach((step, index) => inline(`agentic.workflow.${index}`, step.text));
78
+ agentic?.items.forEach((item, index) => inline(`agentic.items.${index}`, item.text));
79
+ eventModel?.cards.forEach((card, index) => inline(`eventModel.cards.${index}`, card.note));
80
+ eventModel?.steps.forEach((step, index) => inline(`eventModel.steps.${index}`, step.text));
81
+ cast.sections.forEach((section, index) => {
82
+ inline(`sections.${index}.title`, section.title);
83
+ inline(`sections.${index}.intro`, section.intro);
84
+ });
85
+ }
86
+
87
+ /** The career replay and the CV fold every event in order: each position must reduce to dates they can show. */
88
+ function career(cast: CastView): void {
89
+ let state = initialProjection;
90
+ for (const event of cast.careerEvents) {
91
+ if (!isCalendarMonth(event.date)) fail(`career.${event.title}.date`, event.date);
92
+ if (event.endDate !== undefined && !isCalendarMonth(event.endDate)) fail(`career.${event.title}.endDate`, event.endDate);
93
+ state = apply(state, event);
94
+ alsoText(state);
95
+ if (!isCalendarMonth(normalizeDate(event.date))) fail(`career.${event.title}.date`, event.date);
96
+ }
97
+ for (const role of state.roles) [role.from, role.to].forEach(date => date !== undefined && shown(`career.roles.${role.org}`, formatMonth(date), date));
98
+ if (!Number.isFinite(yearsBetween(state.shippingSince, state.clock))) fail('career.years', state.clock);
99
+ }
100
+
101
+ function calendar(cast: CastView): void {
102
+ const { github } = cast;
103
+ for (const week of github.contributions.weeks) {
104
+ if (!isCalendarDate(week.start)) fail('github.contributions.weeks.start', week.start);
105
+ week.counts.forEach(value => count('github.contributions.weeks.counts', value));
106
+ }
107
+ const folded = foldCalendar(github.contributions.weeks);
108
+ for (const day of [folded.first, folded.last, folded.busiest.date].filter(Boolean)) shown('github.contributions', formatDay(day), day);
109
+ for (const { month } of folded.months) shown('github.contributions.months', formatMonthLong(month), month);
110
+ }
111
+
112
+ function dates(cast: CastView): void {
113
+ const { github } = cast;
114
+ for (const [what, value] of [['github.fetchedAt', github.fetchedAt], ['writing.fetchedAt', cast.blogFetchedAt]] as const) {
115
+ if (value !== undefined && !isInstant(value)) fail(what, value);
116
+ }
117
+ shown('snapshotDate', cast.snapshotDate(), String(github.fetchedAt));
118
+ for (const pullRequest of github.mergedPullRequests.latest) if (!isInstant(pullRequest.mergedAt)) fail('github.pullRequest.mergedAt', pullRequest.mergedAt);
119
+ for (const release of Object.values(github.releases)) if (!isInstant(release.publishedAt)) fail('github.releases.publishedAt', release.publishedAt);
120
+ for (const post of cast.posts) if (!isInstant(post.date)) fail('writing.posts.date', post.date);
121
+ for (const entry of [...cast.talks, ...cast.community]) if (!isPartialDate(entry.date)) fail('talks.date', entry.date);
122
+ for (const value of Object.values(cast.now)) if (typeof value === 'string' && /^\d{4}-\d{2}$/.test(value)) shown('now', formatMonth(value), value);
123
+ }
124
+
125
+ function counts(cast: CastView): void {
126
+ const { github } = cast;
127
+ count('github.contributions.total', github.contributions.total);
128
+ count('github.contributions.commits', github.contributions.commits);
129
+ count('github.mergedPullRequests.total', github.mergedPullRequests.total);
130
+ for (const value of Object.values(github.mergedPullRequests.byOrg)) count('github.mergedPullRequests.byOrg', value);
131
+ for (const standing of Object.values(github.repositories)) for (const value of Object.values(standing)) count('github.repositories', value);
132
+ for (const value of Object.values(github.stars)) count('github.stars', value);
133
+ count('mergedPrTotal', cast.mergedPrTotal);
134
+ for (const stat of cast.stats) if (!Number.isFinite(stat.value)) fail(`stats.${stat.label}`, stat.value);
135
+ }
136
+
137
+ /**
138
+ * Computes what the profile components compute from a view (URLs, the contribution calendar, the career
139
+ * projection, dates, inline markup and the browser data), without rendering. Throws on the first value they
140
+ * could not render, so a builder can refuse that data before a page fails the whole build.
141
+ */
142
+ export function assertRenderable(cast: CastView): void {
143
+ links(cast);
144
+ texts(cast);
145
+ career(cast);
146
+ calendar(cast);
147
+ dates(cast);
148
+ counts(cast);
149
+ accentTokens(cast.config.theme.accent);
150
+ safeJson(clientCast(cast));
151
+ }
@@ -0,0 +1,120 @@
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
+ import type { CareerEvent } from '../../config/CastConfig';
5
+ import type { PullRequest } from '../../github/GitHubSnapshot';
6
+ import type { WritingTheme } from '../lib/blog';
7
+ import { anchorFor } from '../lib/sections';
8
+ import type { CastView, ProfileLink } from './CastView';
9
+
10
+ /**
11
+ * What the browser scripts (live feed, replay, palette, terminal) read about the profile on the page. The
12
+ * layout embeds it as a JSON island, so the same scripts serve every profile the directory renders.
13
+ */
14
+ export type ClientCast = {
15
+ /** Path of the profile's home page, ending with '/'. */
16
+ base: string;
17
+ verificationUrl: string;
18
+ /** rel for links from the member's data. */
19
+ rel: string;
20
+ login: string;
21
+ orgs: string[];
22
+ feedOrg: string;
23
+ person: { name: string; handle: string; title: string; location?: string; tagline: string; email?: string };
24
+ firstName: string;
25
+ now: Record<string, string | string[]>;
26
+ careerEvents: CareerEvent[];
27
+ skills: Record<string, string[]>;
28
+ talks: { date: string; title: string; host: string; url?: string }[];
29
+ profileLinks: ProfileLink[];
30
+ cratis?: { pitch?: string; links: Record<string, string>; repos: { name: string; role: string; url?: string; desc: string }[] };
31
+ eventModelName?: string;
32
+ /** DOM ids of the sections that render, in page order. */
33
+ sections: string[];
34
+ projects: { name: string; url: string; pitch: string; version?: string; category: string; features: string[]; stack: string[]; install?: string }[];
35
+ pullRequests: PullRequest[];
36
+ mergedPrTotal: number;
37
+ /** The total is the cast.yaml fallback, not a capture. */
38
+ mergedPrSelfReported: boolean;
39
+ snapshotDate: string;
40
+ posts: { headline: string; url: string; part?: number; minutes?: number; themes: string[] }[];
41
+ themes: WritingTheme[];
42
+ blogHost: string;
43
+ };
44
+
45
+ /** The client data for one profile view. */
46
+ export function clientCast(cast: CastView): ClientCast {
47
+ const { person } = cast;
48
+ return {
49
+ base: cast.host.base,
50
+ verificationUrl: cast.verificationUrl,
51
+ rel: cast.host.rel,
52
+ login: cast.config.github.login,
53
+ orgs: cast.config.github.orgs,
54
+ feedOrg: cast.feedOrg,
55
+ person: {
56
+ name: person.name,
57
+ handle: person.handle,
58
+ title: person.title,
59
+ tagline: person.tagline,
60
+ ...(person.location ? { location: person.location } : {}),
61
+ ...(person.email ? { email: person.email } : {}),
62
+ },
63
+ firstName: cast.firstName,
64
+ now: cast.now,
65
+ careerEvents: cast.careerEvents,
66
+ skills: cast.skills,
67
+ talks: cast.talks.map(talk => ({ date: talk.date, title: talk.title, host: talk.host, ...(talk.url ? { url: talk.url } : {}) })),
68
+ profileLinks: cast.profileLinks,
69
+ ...(cast.cratis ? {
70
+ cratis: {
71
+ ...(cast.cratis.pitch ? { pitch: cast.cratis.pitch } : {}),
72
+ links: cast.cratis.links,
73
+ repos: cast.cratisRepos.map(repo => ({ name: repo.name, role: repo.role, desc: repo.desc, ...(repo.url ? { url: repo.url } : {}) })),
74
+ },
75
+ } : {}),
76
+ ...(cast.eventModel ? { eventModelName: cast.eventModel.name } : {}),
77
+ sections: cast.sections.map(section => anchorFor[section.id]),
78
+ projects: cast.liveProjects.map(project => ({
79
+ name: project.name,
80
+ url: project.url,
81
+ pitch: project.pitch,
82
+ category: project.category,
83
+ features: project.features,
84
+ stack: project.stack,
85
+ ...(project.version ? { version: project.version } : {}),
86
+ ...(project.install ? { install: project.install } : {}),
87
+ })),
88
+ pullRequests: cast.github.mergedPullRequests.latest,
89
+ mergedPrTotal: cast.mergedPrTotal,
90
+ mergedPrSelfReported: cast.mergedPrSelfReported,
91
+ snapshotDate: cast.snapshotDate(),
92
+ posts: cast.posts.map(post => ({
93
+ headline: post.headline,
94
+ url: post.url,
95
+ themes: post.themes,
96
+ ...(post.part !== undefined ? { part: post.part } : {}),
97
+ ...(post.minutes !== undefined ? { minutes: post.minutes } : {}),
98
+ })),
99
+ themes: cast.themes,
100
+ blogHost: cast.blogHost,
101
+ };
102
+ }
103
+
104
+ let cachedText: string | undefined;
105
+ let cachedValue: ClientCast | undefined;
106
+
107
+ /** Reads the client data of the page currently shown. Read again on every use: the router swaps pages. */
108
+ export function readClientCast(): ClientCast | undefined {
109
+ const text = document.querySelector('script[data-cast-client]')?.textContent ?? undefined;
110
+ if (!text) return undefined;
111
+ if (text !== cachedText) {
112
+ try {
113
+ cachedValue = JSON.parse(text) as ClientCast;
114
+ cachedText = text;
115
+ } catch {
116
+ return undefined;
117
+ }
118
+ }
119
+ return cachedValue;
120
+ }
@@ -0,0 +1,8 @@
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
+ export * from './assertRenderable';
5
+ export * from './CastHost';
6
+ export * from './CastView';
7
+ export * from './clientCast';
8
+ export { parseInline, plainText, type InlineToken } from '../lib/inline';
@@ -5,23 +5,28 @@
5
5
  import { onPageLoad } from '../lib/lifecycle';
6
6
  import { relativeTime } from '../lib/reltime';
7
7
  import type { PullRequest } from '../lib/github';
8
- import { config } from '../data';
8
+ import { readClientCast } from '../profile/clientCast';
9
9
 
10
10
  type SearchItem = { repository_url?: string; pull_request?: { merged_at?: string }; title?: unknown; number?: unknown; html_url?: string };
11
11
  type SearchResponse = { total_count?: unknown; incomplete_results?: boolean; items?: unknown };
12
12
  type PublicEvent = { type?: string; repo?: { name?: string }; created_at?: string; payload?: { action?: string; ref_type?: string; pull_request?: { merged?: boolean } } };
13
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');
14
+ const orgPatternFor = (orgs: string[]) =>
15
+ new RegExp(`^https://api\\.github\\.com/repos/(${orgs.map(org => org.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})/([^/]+)$`, 'i');
17
16
 
18
17
  const TTL = 5 * 60_000;
19
18
  type Merged = { total: number; latest: PullRequest[] };
20
19
  type Activity = { type: string; repo: string; at: string };
21
- let merged: { at: number; data: Merged } | undefined;
20
+ // Per login: the directory renders many profiles on one origin.
21
+ const merged = new Map<string, { at: number; data: Merged }>();
22
22
  const pending = new Map<string, Promise<unknown>>();
23
23
 
24
- export const getLiveMerged = (): Merged | undefined => merged && Date.now() - merged.at < TTL ? merged.data : undefined;
24
+ /** The live merged pull requests of the profile on the page, while fresh. */
25
+ export const getLiveMerged = (): Merged | undefined => {
26
+ const login = readClientCast()?.login.toLowerCase();
27
+ const entry = login ? merged.get(login) : undefined;
28
+ return entry && Date.now() - entry.at < TTL ? entry.data : undefined;
29
+ };
25
30
 
26
31
  async function cached<T>(key: string, path: string, parse: (data: unknown) => T): Promise<T> {
27
32
  try {
@@ -42,7 +47,7 @@ async function cached<T>(key: string, path: string, parse: (data: unknown) => T)
42
47
  try { return await request; } finally { pending.delete(key); }
43
48
  }
44
49
 
45
- function parseMerged(raw: unknown): Merged {
50
+ function parseMerged(raw: unknown, orgPattern: RegExp): Merged {
46
51
  const data = raw as SearchResponse | null;
47
52
  const total = data?.total_count;
48
53
  if (typeof total !== 'number' || !Number.isSafeInteger(total) || total < 0 || data?.incomplete_results || !Array.isArray(data?.items)) throw new Error('Invalid search');
@@ -105,18 +110,23 @@ function renderMerged(data: Merged): void {
105
110
  }
106
111
 
107
112
  onPageLoad(() => {
108
- if (!document.querySelector('[data-hero]')) return;
113
+ const cast = readClientCast();
114
+ if (!document.querySelector('[data-hero]') || !cast) return;
115
+ const login = encodeURIComponent(cast.login);
116
+ const key = cast.login.toLowerCase();
117
+ const orgs = cast.orgs;
118
+ const orgPattern = orgPatternFor(orgs);
109
119
  let active = true;
110
120
  let idle: number | undefined;
111
121
  let timer: ReturnType<typeof setTimeout> | undefined;
112
122
  const refresh = () => {
113
123
  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)
124
+ void cached(`${key}: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`, (raw) => parseMerged(raw, orgPattern))
115
125
  .then((data) => {
116
- merged = { at: Date.now(), data };
126
+ merged.set(key, { at: Date.now(), data });
117
127
  if (active && document.visibilityState === 'visible') renderMerged(data);
118
128
  }).catch(() => { /* Quietly keep the build-time snapshot. */ });
119
- void cached('events', `/users/${login}/events/public?per_page=30`, parseEvents)
129
+ void cached(`${key}:events`, `/users/${login}/events/public?per_page=30`, parseEvents)
120
130
  .then((events) => {
121
131
  if (!active || document.visibilityState !== 'visible' || !events.length) return;
122
132
  const event = events[0]!;
@@ -2,12 +2,11 @@
2
2
  // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
3
 
4
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';
5
+ import { readClientCast } from '../profile/clientCast';
7
6
  import { copyText } from '../lib/clipboard';
8
7
  import { motionReduced, toggleMotion } from '../lib/motion';
9
8
  import { currentTheme, toggleTheme } from '../lib/theme';
10
- import { go, goSection, openExternal, startReplay } from '../lib/nav';
9
+ import { go, goSection, openExternal, profilePath, startReplay } from '../lib/nav';
11
10
 
12
11
  type Kind = 'command' | 'event' | 'view';
13
12
  type Item = {
@@ -53,11 +52,24 @@ export function fuzzyScore(query: string, text: string): number {
53
52
  return score;
54
53
  }
55
54
 
55
+ type Controller = { show: () => void; hide: () => void; isOpen: () => boolean };
56
+
57
+ // One controller per dialog: the dialog persists across pages of one profile and is replaced for another.
58
+ const controllers = new WeakMap<HTMLDialogElement, Controller>();
59
+ const current = (): Controller | undefined => {
60
+ const dialog = document.querySelector<HTMLDialogElement>('[data-palette]');
61
+ return dialog ? controllers.get(dialog) : undefined;
62
+ };
63
+ let documentBound = false;
64
+
65
+ /** Mounts the palette of the page shown, once per dialog element. Safe to call on every page load. */
56
66
  export function mountPalette(): void {
57
67
  const dialog = document.querySelector<HTMLDialogElement>('[data-palette]');
58
68
  const input = dialog?.querySelector<HTMLInputElement>('[data-palette-input]');
59
69
  const list = dialog?.querySelector<HTMLUListElement>('[data-palette-list]');
60
- if (!dialog || !input || !list) return;
70
+ const cast = readClientCast();
71
+ if (!dialog || !input || !list || !cast || controllers.has(dialog)) return;
72
+ const { person, sections, profileLinks, cratis } = cast;
61
73
 
62
74
  let toast: HTMLElement | null = null;
63
75
  const notify = (msg: string) => {
@@ -75,7 +87,7 @@ export function mountPalette(): void {
75
87
  const allItems: Item[] = [
76
88
  { id: 'about', group: 'Navigate', label: 'Now & about', keywords: 'read model projection photo', kind: 'view', run: () => goSection('about') },
77
89
  { 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') },
90
+ { id: 'model', group: 'Navigate', label: 'How I think: event model', keywords: `${cast.eventModelName ?? ''} event modeling board slice`, kind: 'command', run: () => goSection('model') },
79
91
  { id: 'cratis', group: 'Navigate', label: 'Cratis', keywords: 'open source chronicle arc', kind: 'command', run: () => goSection('cratis') },
80
92
  { id: 'activity', group: 'Navigate', label: 'Contribution event store', keywords: 'github heatmap activity calendar', kind: 'event', run: () => goSection('activity') },
81
93
  { id: 'agentic', group: 'Navigate', label: 'Agentic engineering', keywords: 'ai agents harness pi review workflow', kind: 'view', run: () => goSection('agentic') },
@@ -95,12 +107,12 @@ export function mountPalette(): void {
95
107
  hint: () => '`',
96
108
  run: () => void document.dispatchEvent(new CustomEvent('terminal:open')),
97
109
  },
98
- { id: 'cv', group: 'Actions', label: 'Open CV', keywords: 'resume curriculum vitae', kind: 'view', run: () => go('/cv') },
110
+ { id: 'cv', group: 'Actions', label: 'Open CV', keywords: 'resume curriculum vitae', kind: 'view', run: () => go(profilePath('cv')) },
99
111
  {
100
112
  id: 'print', group: 'Actions', label: 'Print CV / save as PDF', keywords: 'resume pdf download', kind: 'view',
101
113
  run: () => {
102
- if (location.pathname.startsWith('/cv')) window.print();
103
- else void go('/cv?print');
114
+ if (location.pathname.startsWith(profilePath('cv'))) window.print();
115
+ else void go(profilePath('cv?print'));
104
116
  },
105
117
  },
106
118
  ...(person.email ? [<Item>{
@@ -126,9 +138,9 @@ export function mountPalette(): void {
126
138
  ...Object.entries(cratis?.links ?? {}).map(([label, url]): Item => ({
127
139
  id: `cratis-${label}`, group: 'Links' as const, label, keywords: 'cratis website docs repositories', kind: 'event', run: () => open(url),
128
140
  })),
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()}`) },
141
+ { id: 'cast', group: 'Links' as const, label: 'Cast verification', keywords: 'badge cratis cast tier verified', kind: 'view', run: () => open(cast.verificationUrl) },
130
142
  ];
131
- const items = allItems.filter((item) => item.group !== 'Navigate' || item.id === 'top' || sections.some((section) => anchorFor[section.id] === item.id));
143
+ const items = allItems.filter((item) => item.group !== 'Navigate' || item.id === 'top' || sections.includes(item.id));
132
144
 
133
145
  let results: Item[] = items;
134
146
  let active = 0;
@@ -283,14 +295,19 @@ export function mountPalette(): void {
283
295
  });
284
296
  dialog.querySelector('[data-palette-close]')?.addEventListener('click', hide);
285
297
 
298
+ controllers.set(dialog, { show, hide, isOpen: () => dialog.open });
299
+ if (documentBound) return;
300
+ documentBound = true;
286
301
  document.addEventListener('click', (e) => {
287
- if ((e.target as Element).closest('[data-palette-open]')) show();
302
+ if ((e.target as Element).closest('[data-palette-open]')) current()?.show();
288
303
  });
289
304
  document.addEventListener('keydown', (e) => {
290
305
  if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
306
+ const palette = current();
307
+ if (!palette) return;
291
308
  e.preventDefault();
292
- if (dialog.open) hide();
293
- else show();
309
+ if (palette.isOpen()) palette.hide();
310
+ else palette.show();
294
311
  }
295
312
  });
296
313
  }
@@ -2,7 +2,7 @@
2
2
  // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
3
 
4
4
  // Career replay: renders the projection produced by folding career events up to a position.
5
- import { careerEvents } from '../data';
5
+ import { readClientCast } from '../profile/clientCast';
6
6
  import { project, formatMonth, yearsBetween, alsoText, type CareerProjection } from '../lib/career';
7
7
  import { motionReduced } from '../lib/motion';
8
8
 
@@ -11,6 +11,7 @@ const pad = (n: number) => String(n).padStart(2, '0');
11
11
 
12
12
  export function mountReplay(root: HTMLElement): () => void {
13
13
  const cleanups: (() => void)[] = [];
14
+ const careerEvents = readClientCast()?.careerEvents ?? [];
14
15
  const total = careerEvents.length;
15
16
  const q = <T extends Element = HTMLElement>(sel: string) => root.querySelector<T>(sel);
16
17
  const qa = <T extends Element = HTMLElement>(sel: string) => [...root.querySelectorAll<T>(sel)];