@cratis/cast 0.0.0-stage → 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 +12 -2
  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 +75 -4
  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,148 @@
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
+
6
+ /**
7
+ * The career projection. Every field is derived by folding career events in
8
+ * stream order; nothing here is hard-coded per position.
9
+ */
10
+ export type Role = {
11
+ title: string;
12
+ org: string;
13
+ orgUrl?: string | undefined;
14
+ location?: string | undefined;
15
+ from: string;
16
+ to?: string | undefined;
17
+ summary: string;
18
+ skills: string[];
19
+ };
20
+
21
+ export type CareerProjection = {
22
+ /** Number of events applied (stream position). */
23
+ position: number;
24
+ lastEvent: CareerEvent | undefined;
25
+ /** Latest event date seen; monotonic, so year-only dates never move time backwards. */
26
+ clock: string | undefined;
27
+ /** Date of the first event that is not education. */
28
+ shippingSince: string | undefined;
29
+ currentRole: Role | undefined;
30
+ /** Every role still open, newest first. */
31
+ currentRoles: Role[];
32
+ roles: Role[];
33
+ affiliations: string[];
34
+ skills: string[];
35
+ /** Skills introduced by the last applied event. */
36
+ newSkills: string[];
37
+ eventTypes: Record<string, number>;
38
+ };
39
+
40
+ /** Event types that mean "joined an organization". Presentation rule only. */
41
+ const AFFILIATION_EVENTS = new Set([
42
+ 'StudiesStarted',
43
+ 'VolunteerTeamJoined',
44
+ 'PartTimeRoleStarted',
45
+ 'ContractStarted',
46
+ 'EmploymentStarted',
47
+ 'CompanyCoFounded',
48
+ ]);
49
+
50
+ const EDUCATION_EVENTS = new Set(['StudiesStarted', 'DegreeCompleted', 'CertificationsEarned']);
51
+
52
+ export const initialProjection: CareerProjection = {
53
+ position: 0,
54
+ lastEvent: undefined,
55
+ clock: undefined,
56
+ shippingSince: undefined,
57
+ currentRole: undefined,
58
+ currentRoles: [],
59
+ roles: [],
60
+ affiliations: [],
61
+ skills: [],
62
+ newSkills: [],
63
+ eventTypes: {},
64
+ };
65
+
66
+ /** Normalizes 'YYYY' and 'YYYY-MM' to a sortable 'YYYY-MM'. */
67
+ export const normalizeDate = (date: string): string => (date.length === 4 ? `${date}-01` : date);
68
+
69
+ const later = (a: string | undefined, b: string): string => (!a || b > a ? b : a);
70
+
71
+ /** Pure reducer: applies one event to a projection. */
72
+ export function apply(state: CareerProjection, event: CareerEvent): CareerProjection {
73
+ const date = normalizeDate(event.date);
74
+
75
+ let roles = state.roles;
76
+ if (event.endsRole) {
77
+ roles = roles.map((r) => (r.org === event.endsRole && !r.to ? { ...r, to: event.date } : r));
78
+ }
79
+ if (event.role && event.org) {
80
+ roles = [
81
+ ...roles,
82
+ {
83
+ title: event.role,
84
+ org: event.org,
85
+ orgUrl: event.orgUrl,
86
+ location: event.location,
87
+ from: event.date,
88
+ summary: event.summary,
89
+ skills: event.skills ?? [],
90
+ },
91
+ ];
92
+ }
93
+
94
+ const currentRoles = [...roles].reverse().filter((r) => !r.to);
95
+ const currentRole = currentRoles[0];
96
+
97
+ const affiliations =
98
+ event.org && AFFILIATION_EVENTS.has(event.type) && !state.affiliations.includes(event.org)
99
+ ? [...state.affiliations, event.org]
100
+ : state.affiliations;
101
+
102
+ const newSkills = (event.skills ?? []).filter((s) => !state.skills.includes(s));
103
+
104
+ return {
105
+ position: state.position + 1,
106
+ lastEvent: event,
107
+ clock: later(state.clock, date),
108
+ shippingSince: state.shippingSince ?? (EDUCATION_EVENTS.has(event.type) ? undefined : date),
109
+ currentRole,
110
+ currentRoles,
111
+ roles,
112
+ affiliations,
113
+ skills: [...state.skills, ...newSkills],
114
+ newSkills,
115
+ eventTypes: { ...state.eventTypes, [event.type]: (state.eventTypes[event.type] ?? 0) + 1 },
116
+ };
117
+ }
118
+
119
+ /** Replays the first `position` events of a stream. */
120
+ export const project = (events: readonly CareerEvent[], position = events.length): CareerProjection =>
121
+ events.slice(0, position).reduce(apply, initialProjection);
122
+
123
+ const monthIndex = (date: string): number => {
124
+ const [y, m] = normalizeDate(date).split('-').map(Number);
125
+ return (y ?? 0) * 12 + ((m ?? 1) - 1);
126
+ };
127
+
128
+ /** Years between two YYYY-MM dates, one decimal. */
129
+ export const yearsBetween = (from: string | undefined, to: string | undefined): number =>
130
+ from && to ? Math.max(0, Math.round(((monthIndex(to) - monthIndex(from)) / 12) * 10) / 10) : 0;
131
+
132
+ const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
133
+
134
+ /** 'YYYY-MM' -> 'Jun 2025', 'YYYY' -> '2025'. */
135
+ export function formatMonth(date: string): string {
136
+ if (date.length === 4) return date;
137
+ const [y, m] = date.split('-');
138
+ return `${MONTHS[Number(m) - 1] ?? ''} ${y}`;
139
+ }
140
+
141
+ /** "also Co-founder & Chief Architect · Cratis" for the open roles beyond the newest one. */
142
+ export const alsoText = (state: CareerProjection): string =>
143
+ state.currentRoles.length > 1
144
+ ? `also ${state.currentRoles
145
+ .slice(1)
146
+ .map((r) => `${r.title} · ${r.org}`)
147
+ .join(', ')}`
148
+ : '';
@@ -0,0 +1,12 @@
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
+ /** Copies text with the async Clipboard API. Returns false when the browser refuses. */
5
+ export async function copyText(text: string): Promise<boolean> {
6
+ try {
7
+ await navigator.clipboard.writeText(text);
8
+ return true;
9
+ } catch {
10
+ return false;
11
+ }
12
+ }
@@ -0,0 +1,63 @@
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
+ // Folds the contribution calendar from the GitHub snapshot into what the heatmap shows.
5
+ import type { Week } from './github';
6
+
7
+ export type Day = { date: string; count: number; level: 0 | 1 | 2 | 3 | 4; week: number; weekday: number };
8
+
9
+ const DAY_MS = 86_400_000;
10
+ const iso = (t: number) => new Date(t).toISOString().slice(0, 10);
11
+
12
+ export function foldCalendar(weeks: Week[]) {
13
+ const days: Day[] = [];
14
+ weeks.forEach((w, wi) => {
15
+ const start = Date.parse(`${w.start}T00:00:00Z`);
16
+ const offset = new Date(start).getUTCDay();
17
+ w.counts.forEach((count, i) =>
18
+ days.push({ date: iso(start + i * DAY_MS), count, level: 0, week: wi, weekday: (offset + i) % 7 }),
19
+ );
20
+ });
21
+
22
+ // Intensity levels from quartiles of active days, so one huge day does not wash out the rest.
23
+ const active = days.map((d) => d.count).filter((n) => n > 0).sort((a, b) => a - b);
24
+ const q = (p: number) => active[Math.min(active.length - 1, Math.floor(active.length * p))] ?? 1;
25
+ const [q1, q2, q3] = [q(0.25), q(0.5), q(0.75)];
26
+ for (const d of days) d.level = d.count === 0 ? 0 : d.count <= q1 ? 1 : d.count <= q2 ? 2 : d.count <= q3 ? 3 : 4;
27
+
28
+ let longest = 0;
29
+ let run = 0;
30
+ for (const d of days) {
31
+ run = d.count > 0 ? run + 1 : 0;
32
+ longest = Math.max(longest, run);
33
+ }
34
+ // Current streak: today may not have contributions yet, so it does not break the streak.
35
+ let current = 0;
36
+ for (let i = days.length - 1; i >= 0; i--) {
37
+ const d = days[i]!;
38
+ if (d.count > 0) current++;
39
+ else if (i !== days.length - 1) break;
40
+ }
41
+ const busiest = days.reduce((best, d) => (d.count > best.count ? d : best), days[0] ?? { date: '', count: 0 } as Day);
42
+
43
+ const months = new Map<string, number>();
44
+ for (const d of days) months.set(d.date.slice(0, 7), (months.get(d.date.slice(0, 7)) ?? 0) + d.count);
45
+
46
+ return {
47
+ days,
48
+ weekCount: weeks.length,
49
+ activeDays: active.length,
50
+ longest,
51
+ current,
52
+ busiest,
53
+ months: [...months.entries()].map(([month, total]) => ({ month, total })),
54
+ first: days[0]?.date ?? '',
55
+ last: days[days.length - 1]?.date ?? '',
56
+ };
57
+ }
58
+
59
+ export const formatDay = (date: string): string =>
60
+ new Date(`${date}T00:00:00Z`).toLocaleDateString('en-US', { weekday: 'short', day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' });
61
+
62
+ export const formatMonthLong = (month: string): string =>
63
+ new Date(`${month}-01T00:00:00Z`).toLocaleDateString('en-US', { month: 'long', year: 'numeric', timeZone: 'UTC' });
@@ -0,0 +1,55 @@
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
+ // Typed access to the GitHub data captured at build time.
5
+ import type { z } from 'zod';
6
+ import type { cratisRepositorySchema, projectSchema } from '../../config/castSchema';
7
+ import { config, cratis, github, projects } from '../data';
8
+
9
+ export type { PullRequest, ContributionWeek as Week } from '../../github/GitHubSnapshot';
10
+ export type CratisRepo = z.output<typeof cratisRepositorySchema> & { role: string };
11
+ export type Project = z.output<typeof projectSchema>;
12
+
13
+ export { github };
14
+
15
+ /** Merged pull requests across the configured organizations, falling back to a stat written in cast.yaml. */
16
+ export const mergedPrTotal: number = github.mergedPullRequests.total || (config.stats.find(stat => stat.live === 'mergedPullRequests')?.value ?? 0);
17
+
18
+ /** Floors to hundreds for an honest "3,100+" style figure. */
19
+ export const mergedPrFloor: number = mergedPrTotal >= 100 ? Math.floor(mergedPrTotal / 100) * 100 : mergedPrTotal;
20
+
21
+ const roleFor = (repo: z.output<typeof cratisRepositorySchema>, rank: number): string =>
22
+ repo.founded
23
+ ? (rank === 1 ? 'Founder & lead' : 'Founder')
24
+ : rank === 1
25
+ ? (repo.role?.startsWith('#') ? '#1 committer' : 'Lead developer')
26
+ : rank === 2
27
+ ? (repo.group === 'client' ? '#2 contributor' : '#2 all-time')
28
+ : 'Contributor';
29
+
30
+ /** Per-repository figures prefer the snapshot; values written in cast.yaml are fallbacks only. */
31
+ export const cratisRepos: CratisRepo[] = (cratis?.repos ?? []).map(repo => {
32
+ const counts = repo.url ? github.repositories[`Cratis/${repo.name}`] : undefined;
33
+ if (!counts) return { ...repo, role: repo.role ?? roleFor(repo, repo.rank) };
34
+ return { ...repo, ...counts, role: roleFor(repo, counts.rank) };
35
+ });
36
+
37
+ /** owner/name of a github.com URL. */
38
+ export const repositoryOf = (url: string): string | undefined => url.match(/^https:\/\/github\.com\/([^/]+\/[^/#?]+)/i)?.[1];
39
+
40
+ /** Projects with versions taken from the latest GitHub release when one was captured. */
41
+ export const liveProjects: Project[] = projects.map(project => {
42
+ const repository = repositoryOf(project.url);
43
+ const release = repository ? Object.entries(github.releases).find(([name]) => name.toLowerCase() === repository.toLowerCase())?.[1] : undefined;
44
+ return release ? { ...project, version: release.tag } : project;
45
+ });
46
+
47
+ /** Stars for an owner/name, if tracked. */
48
+ export const starsFor = (repository: string): number | undefined =>
49
+ Object.entries(github.stars).find(([name]) => name.toLowerCase() === repository.toLowerCase())?.[1];
50
+
51
+ export const snapshotDate = (date = github.fetchedAt): string =>
52
+ date ? new Date(date).toLocaleDateString('en-US', { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' }) : 'the last build';
53
+
54
+ /** Repository names in the live feed, most recent first, without duplicates. */
55
+ export const liveRepos: string[] = [...new Set(github.mergedPullRequests.latest.map(pullRequest => pullRequest.repository.split('/')[1] ?? pullRequest.repository))];
@@ -0,0 +1,23 @@
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
+ const escapeHtml = (value: string): string =>
5
+ value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
6
+
7
+ /**
8
+ * Renders the inline markup cast.yaml text may use, escaping everything else:
9
+ * `*emphasis*` (the italic serif accent), `**strong**`, `` `code` `` and `[links](https://…)`.
10
+ */
11
+ export function renderInline(text: string, emphasisClass = 'serif'): string {
12
+ let html = escapeHtml(text);
13
+ html = html.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>');
14
+ html = html.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+|\/[^)\s]*|#[^)\s]*|mailto:[^)\s]+)\)/g, (_, label: string, href: string) =>
15
+ `<a href="${href}" class="text-fg underline decoration-line-strong hover:decoration-fg"${href.startsWith('http') ? ' rel="noopener"' : ''}>${label}</a>`);
16
+ html = html.replace(/\*\*([^*]+)\*\*/g, '<strong class="text-fg font-semibold">$1</strong>');
17
+ html = html.replace(/\*([^*]+)\*/g, `<em class="${emphasisClass}">$1</em>`);
18
+ return html;
19
+ }
20
+
21
+ /** Removes inline markup, for meta tags, JSON and plain-text outputs. */
22
+ export const plainText = (text: string): string =>
23
+ text.replace(/`([^`]+)`/g, '$1').replace(/\[([^\]]+)\]\([^)]+\)/g, '$1').replace(/\*\*([^*]+)\*\*/g, '$1').replace(/\*([^*]+)\*/g, '$1');
@@ -0,0 +1,44 @@
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
+ // Page lifecycle that works with and without Astro's <ClientRouter />.
5
+ //
6
+ // With the router, module scripts run once per full load while the DOM is swapped on
7
+ // every navigation. `onPageLoad` runs `init` for every page view (initial and after each
8
+ // navigation) and runs the returned cleanup before the next swap, so nothing double-binds
9
+ // and observers on detached elements are released.
10
+ type Cleanup = (() => void) | void;
11
+
12
+ const routerActive = () => !!document.querySelector('meta[name="astro-view-transitions-enabled"]');
13
+
14
+ export function onPageLoad(init: () => Cleanup): void {
15
+ let cleanup: Cleanup;
16
+ const run = () => {
17
+ cleanup?.();
18
+ cleanup = init();
19
+ };
20
+ document.addEventListener('astro:page-load', run);
21
+ document.addEventListener('astro:before-swap', () => {
22
+ cleanup?.();
23
+ cleanup = undefined;
24
+ });
25
+ if (!routerActive()) run();
26
+ }
27
+
28
+ /** Collects teardown callbacks for one page view. */
29
+ export function disposer() {
30
+ const fns: (() => void)[] = [];
31
+ return {
32
+ add<T extends () => void>(fn: T): T {
33
+ fns.push(fn);
34
+ return fn;
35
+ },
36
+ /** addEventListener that is removed on dispose. */
37
+ on<E extends Event = Event>(target: EventTarget, type: string, fn: (e: E) => void, opts?: AddEventListenerOptions): void {
38
+ const listener = fn as unknown as EventListener;
39
+ target.addEventListener(type, listener, opts);
40
+ fns.push(() => target.removeEventListener(type, listener, opts));
41
+ },
42
+ dispose: () => fns.splice(0).forEach((fn) => fn()),
43
+ };
44
+ }
@@ -0,0 +1,43 @@
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
+ const KEY = 'cast:motion';
5
+ const query = typeof window !== 'undefined' ? window.matchMedia('(prefers-reduced-motion: reduce)') : undefined;
6
+
7
+ /** True when the OS asks for reduced motion or the visitor turned motion off. */
8
+ export function motionReduced(): boolean {
9
+ if (typeof document === 'undefined') return true;
10
+ const pref = document.documentElement.dataset['motion'];
11
+ if (pref === 'reduced') return true;
12
+ if (pref === 'full') return false;
13
+ return query?.matches ?? false;
14
+ }
15
+
16
+ type Listener = (reduced: boolean) => void;
17
+ const listeners = new Set<Listener>();
18
+
19
+ export function onMotionChange(listener: Listener): () => void {
20
+ listeners.add(listener);
21
+ return () => listeners.delete(listener);
22
+ }
23
+
24
+ const notify = () => {
25
+ const reduced = motionReduced();
26
+ document.documentElement.classList.toggle('js-motion', !reduced);
27
+ listeners.forEach((l) => l(reduced));
28
+ };
29
+
30
+ query?.addEventListener('change', notify);
31
+
32
+ /** Toggles the visitor's motion preference and persists it. */
33
+ export function toggleMotion(): boolean {
34
+ const next = motionReduced() ? 'full' : 'reduced';
35
+ document.documentElement.dataset['motion'] = next;
36
+ try {
37
+ localStorage.setItem(KEY, next);
38
+ } catch {
39
+ /* storage may be unavailable */
40
+ }
41
+ notify();
42
+ return next === 'reduced';
43
+ }
@@ -0,0 +1,38 @@
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 { motionReduced } from './motion';
5
+
6
+ export const onHome = (): boolean => location.pathname === '/' || location.pathname === '/index.html';
7
+
8
+ /** Navigates to another page, through the view-transition router when it is active. */
9
+ export async function go(href: string): Promise<void> {
10
+ if (document.querySelector('meta[name="astro-view-transitions-enabled"]')) {
11
+ const { navigate } = await import('astro:transitions/client');
12
+ await navigate(href);
13
+ } else {
14
+ location.href = href;
15
+ }
16
+ }
17
+
18
+ /** Scrolls to a home-page section and moves focus to its heading. */
19
+ export function goSection(id: string): void {
20
+ const el = document.getElementById(id);
21
+ if (onHome() && el) {
22
+ el.scrollIntoView({ behavior: motionReduced() ? 'auto' : 'smooth', block: 'start' });
23
+ history.replaceState(history.state, '', `#${id}`);
24
+ const focusTarget = el.querySelector<HTMLElement>('h2') ?? el;
25
+ focusTarget.setAttribute('tabindex', '-1');
26
+ focusTarget.focus({ preventScroll: true });
27
+ } else {
28
+ void go(`/#${id}`);
29
+ }
30
+ }
31
+
32
+ /** Scrolls to the career section and starts the replay, from any page. */
33
+ export function startReplay(): void {
34
+ if (onHome() && document.querySelector('[data-replay]')) document.dispatchEvent(new CustomEvent('replay:start'));
35
+ else void go('/?replay#career');
36
+ }
37
+
38
+ export const openExternal = (url: string): void => void window.open(url, '_blank', 'noopener');
@@ -0,0 +1,33 @@
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
+ const rtf = typeof Intl !== 'undefined' ? new Intl.RelativeTimeFormat('en', { numeric: 'auto' }) : undefined;
5
+ const UNITS: [Intl.RelativeTimeFormatUnit, number][] = [
6
+ ['year', 31_536_000_000],
7
+ ['month', 2_592_000_000],
8
+ ['week', 604_800_000],
9
+ ['day', 86_400_000],
10
+ ['hour', 3_600_000],
11
+ ['minute', 60_000],
12
+ ];
13
+
14
+ /** "3 days ago" style text for an ISO timestamp, relative to `now`. */
15
+ export function relativeTime(iso: string, now = Date.now()): string {
16
+ const diff = Date.parse(iso) - now;
17
+ for (const [unit, ms] of UNITS) {
18
+ if (Math.abs(diff) >= ms || unit === 'minute') {
19
+ return rtf?.format(Math.round(diff / ms), unit) ?? new Date(iso).toDateString();
20
+ }
21
+ }
22
+ return iso;
23
+ }
24
+
25
+ /** Replaces the build-time text of every <time data-rel> with a live relative time. */
26
+ export function hydrateRelativeTimes(root: ParentNode = document): void {
27
+ root.querySelectorAll<HTMLTimeElement>('time[data-rel]').forEach((el) => {
28
+ const iso = el.dateTime;
29
+ if (!iso) return;
30
+ el.title ||= new Date(iso).toLocaleString('en-US', { dateStyle: 'medium', timeStyle: 'short' });
31
+ el.textContent = relativeTime(iso);
32
+ });
33
+ }
@@ -0,0 +1,15 @@
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
+ /**
5
+ * JSON that is safe to place inside an HTML `<script>` element. `<`, `>` and `&` become Unicode escapes, so text
6
+ * such as `</script>` in cast.yaml cannot close the element, and U+2028/U+2029 are escaped for old parsers.
7
+ * The result parses back to the same value.
8
+ */
9
+ export const safeJson = (value: unknown): string =>
10
+ JSON.stringify(value)
11
+ .replace(/</g, '\\u003c')
12
+ .replace(/>/g, '\\u003e')
13
+ .replace(/&/g, '\\u0026')
14
+ .replace(/\u2028/g, '\\u2028')
15
+ .replace(/\u2029/g, '\\u2029');
@@ -0,0 +1,41 @@
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 { ResolvedSection } from '../../config/ResolvedSection';
5
+ import { SectionId } from '../../config/SectionId';
6
+
7
+ /** The DOM id each section renders with; kept stable so links like /#career keep working. */
8
+ export const anchorFor: Record<SectionId, string> = {
9
+ [SectionId.Hero]: 'top',
10
+ [SectionId.Now]: 'about',
11
+ [SectionId.Stats]: 'stats',
12
+ [SectionId.Career]: 'career',
13
+ [SectionId.Model]: 'model',
14
+ [SectionId.Cratis]: 'cratis',
15
+ [SectionId.Activity]: 'activity',
16
+ [SectionId.Agentic]: 'agentic',
17
+ [SectionId.Projects]: 'projects',
18
+ [SectionId.Writing]: 'writing',
19
+ [SectionId.Talks]: 'talks',
20
+ [SectionId.Skills]: 'skills',
21
+ [SectionId.Contact]: 'contact',
22
+ };
23
+
24
+ const navLabels: Partial<Record<SectionId, string>> = {
25
+ [SectionId.Now]: 'Now',
26
+ [SectionId.Career]: 'Career',
27
+ [SectionId.Cratis]: 'Cratis',
28
+ [SectionId.Projects]: 'Projects',
29
+ [SectionId.Contact]: 'Contact',
30
+ };
31
+
32
+ /** Primary navigation: up to five of the sections that render, in page order. */
33
+ export const navLinks = (sections: readonly ResolvedSection[]): { id: string; label: string }[] =>
34
+ sections
35
+ .filter(section => navLabels[section.id] !== undefined)
36
+ .slice(0, 5)
37
+ .map(section => ({ id: anchorFor[section.id], label: navLabels[section.id]! }));
38
+
39
+ /** The resolved entry for a section, if it renders. */
40
+ export const sectionFor = (sections: readonly ResolvedSection[], id: SectionId): ResolvedSection | undefined =>
41
+ sections.find(section => section.id === id);
@@ -0,0 +1,28 @@
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
+ // Light/dark theme. The inline script in Base.astro applies the stored choice before first
5
+ // paint; this module changes and persists it.
6
+ export type Theme = 'light' | 'dark';
7
+ const KEY = 'cast:theme';
8
+
9
+ export const currentTheme = (): Theme => (document.documentElement.dataset['theme'] === 'light' ? 'light' : 'dark');
10
+
11
+ export function setTheme(theme: Theme): Theme {
12
+ const root = document.documentElement;
13
+ root.classList.add('theme-flip');
14
+ window.setTimeout(() => root.classList.remove('theme-flip'), 450);
15
+ root.dataset['theme'] = theme;
16
+ root.style.colorScheme = theme;
17
+ document.querySelector('meta[name="theme-color"]')?.setAttribute('content', theme === 'light' ? '#faf9f6' : '#08080e');
18
+ try {
19
+ localStorage.setItem(KEY, theme);
20
+ } catch {
21
+ /* storage may be unavailable */
22
+ }
23
+ document.dispatchEvent(new CustomEvent('theme:change', { detail: theme }));
24
+ document.querySelectorAll('[data-theme-toggle]').forEach((b) => b.setAttribute('aria-pressed', String(theme === 'light')));
25
+ return theme;
26
+ }
27
+
28
+ export const toggleTheme = (): Theme => setTheme(currentTheme() === 'light' ? 'dark' : 'light');
@@ -0,0 +1,93 @@
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
+ // Small page-wide behaviours: reveal on scroll, pointer glow, copy buttons, nav state.
5
+ import { motionReduced, onMotionChange } from '../lib/motion';
6
+ import { copyText } from '../lib/clipboard';
7
+ import { onPageLoad, disposer } from '../lib/lifecycle';
8
+
9
+ // --- Pointer-following glow ---------------------------------------------------
10
+ document.addEventListener(
11
+ 'pointermove',
12
+ (e) => {
13
+ const target = (e.target as Element | null)?.closest<HTMLElement>('.glow');
14
+ if (!target) return;
15
+ const r = target.getBoundingClientRect();
16
+ target.style.setProperty('--mx', `${e.clientX - r.left}px`);
17
+ target.style.setProperty('--my', `${e.clientY - r.top}px`);
18
+ },
19
+ { passive: true },
20
+ );
21
+
22
+ // --- Copy to clipboard --------------------------------------------------------
23
+ document.addEventListener('click', async (e) => {
24
+ const btn = (e.target as Element | null)?.closest<HTMLButtonElement>('button[data-copy]');
25
+ if (!btn) return;
26
+ const text = btn.dataset['copy'] ?? '';
27
+ const ok = await copyText(text);
28
+ const label = btn.querySelector<HTMLElement>('[data-copy-label]');
29
+ const status = btn.parentElement?.querySelector<HTMLElement>('[data-copy-status]');
30
+ const original = label?.textContent ?? '';
31
+ btn.dataset['copied'] = ok ? 'true' : 'false';
32
+ if (label) label.textContent = ok ? 'Copied' : 'Press ⌘C';
33
+ if (status) status.textContent = ok ? `Copied ${text} to the clipboard` : 'Copy failed';
34
+ window.setTimeout(() => {
35
+ delete btn.dataset['copied'];
36
+ if (label) label.textContent = original;
37
+ }, 1600);
38
+ });
39
+
40
+ // --- Per page view: reveal on scroll and nav state ------------------------------
41
+ onPageLoad(() => {
42
+ const d = disposer();
43
+
44
+ const revealables = document.querySelectorAll<HTMLElement>('[data-reveal]');
45
+ const revealAll = () => revealables.forEach((el) => el.classList.add('is-visible'));
46
+ if (motionReduced() || !('IntersectionObserver' in window)) {
47
+ revealAll();
48
+ } else {
49
+ const io = new IntersectionObserver(
50
+ (entries) => {
51
+ for (const entry of entries) {
52
+ if (entry.isIntersecting) {
53
+ entry.target.classList.add('is-visible');
54
+ io.unobserve(entry.target);
55
+ }
56
+ }
57
+ },
58
+ { rootMargin: '0px 0px -8% 0px', threshold: 0.08 },
59
+ );
60
+ revealables.forEach((el) => io.observe(el));
61
+ d.add(() => io.disconnect());
62
+ }
63
+ d.add(onMotionChange((reduced) => reduced && revealAll()));
64
+
65
+ const nav = document.querySelector<HTMLElement>('[data-nav]');
66
+ if (nav) {
67
+ const update = () => nav.toggleAttribute('data-scrolled', window.scrollY > 8);
68
+ update();
69
+ d.on(window, 'scroll', update, { passive: true });
70
+
71
+ const links = nav.querySelectorAll<HTMLAnchorElement>('a[data-section]');
72
+ const sections = [...links]
73
+ .map((a) => document.getElementById(a.dataset['section'] ?? ''))
74
+ .filter((s): s is HTMLElement => !!s);
75
+ if (sections.length) {
76
+ const spy = new IntersectionObserver(
77
+ (entries) => {
78
+ for (const entry of entries) {
79
+ if (!entry.isIntersecting) continue;
80
+ links.forEach((a) => {
81
+ if (a.dataset['section'] === entry.target.id) a.setAttribute('aria-current', 'location');
82
+ else a.removeAttribute('aria-current');
83
+ });
84
+ }
85
+ },
86
+ { rootMargin: '-45% 0px -50% 0px' },
87
+ );
88
+ sections.forEach((s) => spy.observe(s));
89
+ d.add(() => spy.disconnect());
90
+ }
91
+ }
92
+ return d.dispose;
93
+ });