@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,113 @@
1
+ ---
2
+ // Copyright (c) Cratis. All rights reserved.
3
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
4
+
5
+ import { Picture } from 'astro:assets';
6
+ import { person, now, careerEvents, firstName, photo, photoUrl } from '../data';
7
+ import { formatMonth } from '../lib/career';
8
+ import { renderInline } from '../lib/inline';
9
+ import SectionHeader from './SectionHeader.astro';
10
+
11
+ interface Props {
12
+ index: string;
13
+ title?: string;
14
+ label?: string;
15
+ }
16
+ const { index, title = 'Where the stream is *now.*', label = 'Read model' } = Astro.props;
17
+ const rows: { key: string; value: string | string[] }[] = Object.entries(now).map(([key, value]) => ({
18
+ key,
19
+ value: typeof value === 'string' && /^\d{4}-\d{2}$/.test(value) ? formatMonth(value) : value,
20
+ }));
21
+ const hasPortrait = photo !== undefined || photoUrl !== undefined;
22
+ ---
23
+
24
+ <section id="about" class="relative py-24 md:py-36" aria-labelledby="about-title">
25
+ <div class="container-x">
26
+ <SectionHeader index={index} kind="view" label={label} title={title} id="about-title" />
27
+
28
+ <div class="grid grid-cols-1 gap-6 lg:grid-cols-12">
29
+ {hasPortrait && <figure class="avatar group panel overflow-hidden p-2 lg:col-span-4" data-reveal>
30
+ <div class="relative aspect-square overflow-hidden rounded-[0.9rem]">
31
+ {photo ? <Picture
32
+ src={photo}
33
+ alt={`Portrait of ${person.name}`}
34
+ widths={[320, 500]}
35
+ sizes="(min-width: 1024px) 380px, 90vw"
36
+ formats={['avif', 'webp']}
37
+ loading="lazy"
38
+ class="size-full object-cover"
39
+ /> : <img src={photoUrl} alt={`Portrait of ${person.name}`} width="460" height="460" loading="lazy" class="size-full object-cover" />}
40
+ <div class="tint pointer-events-none absolute inset-0" aria-hidden="true"></div>
41
+ <div class="pointer-events-none absolute inset-x-0 bottom-0 h-1/3 bg-gradient-to-t from-ink-950/80 to-transparent" aria-hidden="true"></div>
42
+ </div>
43
+ <figcaption class="flex items-center justify-between px-2 pt-3 pb-1 font-mono text-xs text-fg-muted">
44
+ <span>{person.fullName ?? person.name}</span>
45
+ <span class="text-fg-faint">{person.location}</span>
46
+ </figcaption>
47
+ </figure>}
48
+
49
+ {rows.length > 0 && <div class:list={['panel overflow-hidden', hasPortrait ? 'lg:col-span-8' : 'lg:col-span-12']} data-reveal style="--reveal-delay:120ms">
50
+ <div class="flex items-center justify-between gap-4 border-b border-line px-5 py-3.5 md:px-7">
51
+ <p class="font-mono text-sm">
52
+ <span class="text-fg-faint">Projection:</span> <span class="text-view">{firstName}.Now</span>
53
+ </p>
54
+ <p class="flex items-center gap-2 font-mono text-xs text-fg-muted">
55
+ <span class="size-1.5 animate-pulse-dot rounded-full bg-view" aria-hidden="true"></span>
56
+ projected from {careerEvents.length} events
57
+ </p>
58
+ </div>
59
+ <dl class="divide-y divide-line">
60
+ {
61
+ rows.map((r) => (
62
+ <div class="grid gap-1 px-5 py-4 sm:grid-cols-[9rem_1fr] sm:gap-6 md:px-7">
63
+ <dt class="font-mono text-xs leading-6 text-fg-faint">{r.key}</dt>
64
+ <dd class="text-[0.975rem] leading-6 text-fg">
65
+ {Array.isArray(r.value) ? (
66
+ <ul class="flex flex-wrap gap-2" aria-label={r.key}>
67
+ {r.value.map((v) => (
68
+ <li class="tag border-view/25 bg-view/5 text-view-soft">{v}</li>
69
+ ))}
70
+ </ul>
71
+ ) : (
72
+ <Fragment set:html={renderInline(r.value)} />
73
+ )}
74
+ </dd>
75
+ </div>
76
+ ))
77
+ }
78
+ </dl>
79
+ </div>}
80
+ </div>
81
+
82
+ <div class="mt-16 grid grid-cols-1 gap-10 md:mt-24 md:grid-cols-12">
83
+ <h3 class="eyebrow md:col-span-3" data-reveal>About</h3>
84
+ <div class="text-lg leading-relaxed text-fg-muted md:col-span-9 md:columns-2 md:gap-10 [&>p]:mb-6 [&>p]:break-inside-avoid" data-reveal>
85
+ {person.about.map((p, i) => <p class:list={[i === 0 && 'text-fg']} set:html={renderInline(p)} />)}
86
+ </div>
87
+ {person.interests.length > 0 && <h3 class="eyebrow md:col-span-3" data-reveal>Interests</h3>}
88
+ {person.interests.length > 0 && <ul class="flex flex-wrap gap-2 md:col-span-9" data-reveal>
89
+ {person.interests.map((i) => <li class="tag">{i}</li>)}
90
+ </ul>}
91
+ </div>
92
+ </div>
93
+ </section>
94
+
95
+ <style>
96
+ .avatar :global(img) {
97
+ filter: grayscale(1) contrast(1.08) brightness(0.95);
98
+ transition: filter 900ms var(--ease-out-expo), transform 900ms var(--ease-out-expo);
99
+ }
100
+ .tint {
101
+ background: linear-gradient(160deg, var(--color-accent) 0%, #2dd4bf 100%);
102
+ mix-blend-mode: color;
103
+ opacity: 0.55;
104
+ transition: opacity 900ms var(--ease-out-expo);
105
+ }
106
+ .avatar:hover :global(img) {
107
+ filter: none;
108
+ transform: scale(1.03);
109
+ }
110
+ .avatar:hover .tint {
111
+ opacity: 0;
112
+ }
113
+ </style>
@@ -0,0 +1,188 @@
1
+ ---
2
+ // Copyright (c) Cratis. All rights reserved.
3
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
4
+
5
+ import { liveProjects as projects } from '../lib/github';
6
+ import { config } from '../data';
7
+ import { renderInline } from '../lib/inline';
8
+ import SectionHeader from './SectionHeader.astro';
9
+
10
+ interface Props {
11
+ index: string;
12
+ title?: string;
13
+ label?: string;
14
+ intro?: string;
15
+ }
16
+ const { index, title = 'Things I build *after hours.*', label = 'Side projects', intro } = Astro.props;
17
+ // Categories in cast.yaml order, then any others in first-use order.
18
+ const used = [...new Set(projects.map((p) => p.category))];
19
+ const declared = config.projectCategories.filter((c) => used.includes(c.id));
20
+ const categories = [...declared, ...used.filter((id) => !declared.some((c) => c.id === id)).map((id) => ({ id, label: id.charAt(0).toUpperCase() + id.slice(1), filter: undefined }))];
21
+ const filters = categories.length > 1 ? [{ id: 'all', label: 'All' }, ...categories.map((c) => ({ id: c.id, label: c.filter ?? c.label }))] : [];
22
+ const countFor = (id: string) => (id === 'all' ? projects.length : projects.filter((p) => p.category === id).length);
23
+ const categoryLabel: Record<string, string> = Object.fromEntries(categories.map((c) => [c.id, c.label]));
24
+ const escape = (t: string) => t.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
25
+ /** Lets a command wrap only after spaces and slashes, with no added visible characters. */
26
+ const breakable = (cmd: string) =>
27
+ cmd
28
+ .split(/(?<=[/ ])/)
29
+ .map((part) => `<span class="whitespace-nowrap">${escape(part)}</span>`)
30
+ .join('<wbr>');
31
+ ---
32
+
33
+ <section id="projects" class="relative py-24 md:py-36" aria-labelledby="projects-title">
34
+ <div class="container-x">
35
+ <SectionHeader index={index} label={label} title={title} id="projects-title">
36
+ {intro && <p class="mt-6 max-w-[62ch] text-lg leading-relaxed text-fg-muted" set:html={renderInline(intro)} />}
37
+ </SectionHeader>
38
+
39
+ <div class="mb-8 flex flex-wrap gap-2" role="group" aria-label="Filter projects by category" data-filters hidden>
40
+ {
41
+ filters.map((f, i) => (
42
+ <button type="button" class="filter" data-filter={f.id} aria-pressed={i === 0 ? 'true' : 'false'}>
43
+ {f.label}
44
+ <span class="font-mono text-[0.6875rem] opacity-60">{countFor(f.id)}</span>
45
+ </button>
46
+ ))
47
+ }
48
+ </div>
49
+ <p class="sr-only" aria-live="polite" data-filter-status></p>
50
+
51
+ <ul class="bento grid grid-cols-1 gap-4 md:grid-cols-3" data-bento>
52
+ {
53
+ projects.map((p, i) => (
54
+ <li class:list={['proj', p.featured && 'md:col-span-2']} data-category={p.category} data-reveal style={`--reveal-delay:${(i % 3) * 70}ms`}>
55
+ <article class="panel glow flex h-full flex-col p-6 md:p-7">
56
+ <header class="flex items-start justify-between gap-4">
57
+ <div>
58
+ <p class="font-mono text-[0.6875rem] tracking-wider text-fg-faint uppercase">{categoryLabel[p.category]}</p>
59
+ <h3 class="mt-2 flex items-center gap-3 font-display text-[1.75rem] leading-none font-[650] tracking-[-0.03em]">
60
+ <a href={p.url} rel="noopener" class="hover:text-event-soft">
61
+ {p.name}
62
+ </a>
63
+ {p.version && (
64
+ <span class="rounded-md border border-event/30 bg-event/10 px-1.5 py-0.5 font-mono text-[0.6875rem] font-medium tracking-normal text-event-soft">
65
+ {p.version}
66
+ </span>
67
+ )}
68
+ </h3>
69
+ </div>
70
+ <a href={p.url} rel="noopener" class="grid size-9 shrink-0 place-items-center rounded-lg border border-line text-fg-muted transition-colors hover:border-line-strong hover:text-fg" aria-label={`${p.name} on GitHub`}>
71
+ <svg viewBox="0 0 16 16" class="size-4" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><path d="M5 11 11 5M6 5h5v5" stroke-linecap="round" stroke-linejoin="round" /></svg>
72
+ </a>
73
+ </header>
74
+
75
+ <p class="mt-4 text-[1.0625rem] leading-relaxed text-fg">{p.pitch}</p>
76
+
77
+ <ul class:list={['mt-5 grid gap-2 text-sm text-fg-muted', p.featured && 'sm:grid-cols-2 sm:gap-x-6']}>
78
+ {p.features.map((f) => (
79
+ <li class="flex gap-2.5">
80
+ <span class="mt-[0.45rem] size-1.5 shrink-0 rounded-[2px] bg-view/80" aria-hidden="true" />
81
+ {f}
82
+ </li>
83
+ ))}
84
+ </ul>
85
+
86
+ <div class="mt-auto pt-6">
87
+ {p.install && (
88
+ <div class="term mb-4 flex items-start gap-2.5 rounded-xl border border-line bg-ink-950/80 py-1.5 pr-1.5 pl-3.5 font-mono text-[0.8125rem] leading-5">
89
+ <span class="py-[0.3rem] text-view select-none" aria-hidden="true">$</span>
90
+ <code class="min-w-0 flex-1 py-[0.3rem] [overflow-wrap:anywhere] text-fg">
91
+ <Fragment set:html={breakable(p.install)} />
92
+ </code>
93
+ <button type="button" class="copy" data-copy={p.install} aria-label={`Copy install command for ${p.name}`}>
94
+ <svg viewBox="0 0 16 16" class="ic-copy size-3.5" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><rect x="5" y="5" width="9" height="9" rx="2" /><path d="M11 5V3.5A1.5 1.5 0 0 0 9.5 2h-6A1.5 1.5 0 0 0 2 3.5v6A1.5 1.5 0 0 0 3.5 11H5" /></svg>
95
+ <svg viewBox="0 0 16 16" class="ic-ok size-3.5" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="m3 8.5 3 3 7-7" stroke-linecap="round" stroke-linejoin="round" /></svg>
96
+ <span data-copy-label>Copy</span>
97
+ </button>
98
+ <span class="sr-only" aria-live="polite" data-copy-status />
99
+ </div>
100
+ )}
101
+ <ul class="flex flex-wrap gap-1.5" aria-label="Stack">
102
+ {p.stack.map((s) => (
103
+ <li class="rounded-md bg-fg/[0.04] px-2 py-0.5 font-mono text-[0.6875rem] text-fg-muted">{s}</li>
104
+ ))}
105
+ </ul>
106
+ </div>
107
+ </article>
108
+ </li>
109
+ ))
110
+ }
111
+ </ul>
112
+ </div>
113
+ </section>
114
+
115
+ <style>
116
+ .bento[data-filtered] .proj {
117
+ grid-column: auto;
118
+ }
119
+ .copy {
120
+ display: inline-flex;
121
+ align-items: center;
122
+ gap: 0.4rem;
123
+ height: 1.9rem;
124
+ padding-inline: 0.6rem;
125
+ border-radius: 0.55rem;
126
+ font-size: 0.75rem;
127
+ color: var(--color-fg-muted);
128
+ border: 1px solid var(--color-line);
129
+ background: color-mix(in srgb, var(--color-fg) 3%, transparent);
130
+ transition: all 160ms ease;
131
+ cursor: pointer;
132
+ flex-shrink: 0;
133
+ }
134
+ .copy:hover {
135
+ color: var(--color-fg);
136
+ border-color: var(--color-line-strong);
137
+ }
138
+ .copy .ic-ok,
139
+ .copy[data-copied='true'] .ic-copy {
140
+ display: none;
141
+ }
142
+ .copy[data-copied='true'] {
143
+ color: var(--color-view);
144
+ border-color: rgb(45 212 191 / 0.4);
145
+ background: rgb(45 212 191 / 0.08);
146
+ }
147
+ .copy[data-copied='true'] .ic-ok {
148
+ display: block;
149
+ }
150
+ .proj[hidden] {
151
+ display: none;
152
+ }
153
+ :global(.js-motion) .proj.enter {
154
+ animation: var(--animate-pop-in);
155
+ }
156
+ </style>
157
+
158
+ <script>
159
+ import { onPageLoad } from '../lib/lifecycle';
160
+ onPageLoad(() => {
161
+ const bar = document.querySelector<HTMLElement>('[data-filters]');
162
+ const grid = document.querySelector<HTMLElement>('[data-bento]');
163
+ const status = document.querySelector<HTMLElement>('[data-filter-status]');
164
+ if (bar && grid) {
165
+ bar.hidden = false;
166
+ const cards = [...grid.querySelectorAll<HTMLElement>('.proj')];
167
+ bar.addEventListener('click', (e) => {
168
+ const btn = (e.target as Element).closest<HTMLButtonElement>('[data-filter]');
169
+ if (!btn) return;
170
+ const id = btn.dataset['filter'];
171
+ bar.querySelectorAll('[data-filter]').forEach((b) => b.setAttribute('aria-pressed', String(b === btn)));
172
+ grid.toggleAttribute('data-filtered', id !== 'all');
173
+ let shown = 0;
174
+ for (const card of cards) {
175
+ const match = id === 'all' || card.dataset['category'] === id;
176
+ card.hidden = !match;
177
+ card.classList.remove('enter');
178
+ if (match) {
179
+ shown++;
180
+ void card.offsetWidth;
181
+ card.classList.add('enter', 'is-visible');
182
+ }
183
+ }
184
+ if (status) status.textContent = `Showing ${shown} project${shown === 1 ? '' : 's'}`;
185
+ });
186
+ }
187
+ });
188
+ </script>
@@ -0,0 +1,86 @@
1
+ ---
2
+ // Copyright (c) Cratis. All rights reserved.
3
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
4
+
5
+ // The live feed of merged public pull requests: rendered from the build-time snapshot, refreshed in the browser.
6
+ import { config } from '../data';
7
+ import { github, snapshotDate } from '../lib/github';
8
+ import { relativeTime } from '../lib/reltime';
9
+
10
+ interface Props {
11
+ class?: string;
12
+ }
13
+ const { class: className = '' } = Astro.props;
14
+ // The section shows the newest eight; the terminal's `stream tail` gets the full snapshot.
15
+ const feed = github.mergedPullRequests.latest.slice(0, 8);
16
+ const builtAt = github.fetchedAt ? Date.parse(github.fetchedAt) : Date.now();
17
+ const orgs = config.github.orgs.join(', ');
18
+ ---
19
+
20
+ <div class:list={['panel overflow-hidden', className]} data-reveal data-feed>
21
+ <div class="flex flex-wrap items-center justify-between gap-3 border-b border-line px-5 py-3.5">
22
+ <h3 class="flex items-center gap-2.5 font-mono text-sm">
23
+ <span class="size-1.5 animate-pulse-dot rounded-full bg-view" aria-hidden="true"></span>
24
+ <span class="text-fg-faint">tail -f</span> <span class="text-fg">Tailing the stream</span>
25
+ </h3>
26
+ <p data-pr-status class="font-mono text-xs text-fg-faint">as of <time datetime={github.fetchedAt}>{snapshotDate()}</time></p>
27
+ </div>
28
+ <template data-pr-template>
29
+ <li class="feed-row">
30
+ <a rel="noopener" class="group grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 px-5 py-3.5 transition-colors hover:bg-fg/[0.025] sm:grid-cols-[4.5rem_1fr_auto]">
31
+ <span data-pr-head class="font-mono text-xs leading-6 text-fg-faint tabular-nums"></span>
32
+ <span class="min-w-0">
33
+ <span class="flex flex-wrap items-center gap-x-2.5 gap-y-1 font-mono text-xs">
34
+ <span class="text-event">PullRequestMerged</span>
35
+ <span data-pr-repo class="rounded-md border border-command/25 bg-command/8 px-1.5 py-px text-command-soft"></span>
36
+ <span data-pr-number class="text-fg-faint"></span>
37
+ </span>
38
+ <span data-pr-title class="mt-1 block text-[0.9375rem] leading-snug text-fg group-hover:underline"></span>
39
+ </span>
40
+ <time data-rel class="col-start-2 font-mono text-xs text-fg-faint sm:col-start-3 sm:leading-6"></time>
41
+ </a>
42
+ </li>
43
+ </template>
44
+ {
45
+ feed.length ? (
46
+ <ol data-pr-feed class="divide-y divide-line" aria-label={`Latest merged pull requests in ${orgs}`}>
47
+ {feed.map((pr, i) => (
48
+ <li class="feed-row" style={`--i:${i}`}>
49
+ <a href={pr.url} rel="noopener" class="group grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 px-5 py-3.5 transition-colors hover:bg-fg/[0.025] sm:grid-cols-[4.5rem_1fr_auto]">
50
+ <span class="font-mono text-xs leading-6 text-fg-faint tabular-nums">{i === 0 ? 'HEAD' : `HEAD~${i}`}</span>
51
+ <span class="min-w-0">
52
+ <span class="flex flex-wrap items-center gap-x-2.5 gap-y-1 font-mono text-xs">
53
+ <span class="text-event">PullRequestMerged</span>
54
+ <span class="rounded-md border border-command/25 bg-command/8 px-1.5 py-px text-command-soft">{pr.repository}</span>
55
+ <span class="text-fg-faint">#{pr.number}</span>
56
+ </span>
57
+ <span class="mt-1 block text-[0.9375rem] leading-snug text-fg group-hover:underline">{pr.title}</span>
58
+ </span>
59
+ <time datetime={pr.mergedAt} data-rel class="col-start-2 font-mono text-xs text-fg-faint sm:col-start-3 sm:leading-6">{relativeTime(pr.mergedAt, builtAt)}</time>
60
+ </a>
61
+ </li>
62
+ ))}
63
+ </ol>
64
+ ) : (
65
+ <ol data-pr-feed class="divide-y divide-line" aria-label={`Latest merged pull requests in ${orgs}`}>
66
+ <li class="px-5 py-8 font-mono text-sm text-fg-faint">EventNotFound: the snapshot has no merged pull requests yet.</li>
67
+ </ol>
68
+ )
69
+ }
70
+ <p class="border-t border-line px-5 py-3 font-mono text-[0.6875rem] text-fg-faint">
71
+ Public repositories only, newest first. Refreshed hourly and on page load.
72
+ </p>
73
+ </div>
74
+
75
+ <style>
76
+ :global(.js-motion) [data-feed].is-visible :global(.feed-row) {
77
+ animation: feed-in 700ms var(--ease-out-expo) both;
78
+ animation-delay: calc(var(--i) * 45ms + 200ms);
79
+ }
80
+ @keyframes feed-in {
81
+ from {
82
+ opacity: 0;
83
+ transform: translateY(-6px);
84
+ }
85
+ }
86
+ </style>
@@ -0,0 +1,27 @@
1
+ ---
2
+ // Copyright (c) Cratis. All rights reserved.
3
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
4
+
5
+ import { renderInline } from '../lib/inline';
6
+
7
+ interface Props {
8
+ index: string;
9
+ kind?: 'event' | 'command' | 'view';
10
+ label: string;
11
+ /** May use *emphasis* for the italic serif accent. */
12
+ title: string;
13
+ id?: string;
14
+ }
15
+ const { index, kind = 'event', label, title, id } = Astro.props;
16
+ const noteClass = kind === 'command' ? 'note note-command' : kind === 'view' ? 'note note-view' : 'note';
17
+ ---
18
+
19
+ <header class="mb-12 md:mb-16" data-reveal>
20
+ <p class="mb-5 flex items-center gap-3 font-mono text-xs text-fg-faint">
21
+ {index && <span class="text-fg-muted">{index}</span>}
22
+ {index && <span class="h-px w-8 bg-line-strong" aria-hidden="true"></span>}
23
+ <span class={noteClass}>{label}</span>
24
+ </p>
25
+ <h2 id={id} class="section-title max-w-[22ch]" set:html={renderInline(title)} />
26
+ <slot />
27
+ </header>
@@ -0,0 +1,41 @@
1
+ ---
2
+ // Copyright (c) Cratis. All rights reserved.
3
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
4
+
5
+ import { skills } from '../data';
6
+ import SectionHeader from './SectionHeader.astro';
7
+
8
+ interface Props {
9
+ index: string;
10
+ title?: string;
11
+ label?: string;
12
+ /** Sits directly under the talks section, so it needs less top padding there. */
13
+ compact?: boolean;
14
+ }
15
+ const { index, title = 'The *toolbox.*', label = 'Read model', compact = false } = Astro.props;
16
+ const groups = Object.entries(skills);
17
+ const accents = ['bg-event', 'bg-command', 'bg-view', 'bg-accent'];
18
+ ---
19
+
20
+ <section id="skills" class:list={['relative pb-24 md:pb-36', compact ? '' : 'pt-24 md:pt-32']} aria-labelledby="skills-title">
21
+ <div class="container-x">
22
+ <SectionHeader index={index} kind="view" label={label} title={title} id="skills-title" />
23
+ <div class="grid grid-cols-1 gap-px overflow-hidden rounded-[1.25rem] border border-line bg-line sm:grid-cols-2 lg:grid-cols-3">
24
+ {
25
+ groups.map(([group, list], i) => (
26
+ <div class="bg-ink-950 p-6 md:p-7" data-reveal style={`--reveal-delay:${(i % 3) * 60}ms`}>
27
+ <h3 class="mb-4 flex items-center gap-2.5 font-mono text-xs tracking-wider text-fg-muted uppercase">
28
+ <span class={`size-2 rounded-[2px] ${accents[i % accents.length]}`} aria-hidden="true" />
29
+ {group}
30
+ </h3>
31
+ <ul class="flex flex-wrap gap-1.5">
32
+ {list.map((s) => (
33
+ <li class="tag">{s}</li>
34
+ ))}
35
+ </ul>
36
+ </div>
37
+ ))
38
+ }
39
+ </div>
40
+ </div>
41
+ </section>
@@ -0,0 +1,101 @@
1
+ ---
2
+ // Copyright (c) Cratis. All rights reserved.
3
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
4
+
5
+ import { stats as siteStats, cratis } from '../data';
6
+ import { github, cratisRepos, mergedPrFloor, snapshotDate } from '../lib/github';
7
+
8
+ type Stat = (typeof siteStats)[number];
9
+ const rankOne = cratisRepos.filter((r) => r.rank === 1).length;
10
+ const live: Record<string, (s: Stat) => Partial<Stat>> = {
11
+ // Floored to hundreds so the "+" stays true between hourly builds.
12
+ contributionsLastYear: () => (github.contributions.total ? { value: Math.floor(github.contributions.total / 100) * 100 } : {}),
13
+ mergedPullRequests: () => (mergedPrFloor ? { value: mergedPrFloor } : {}),
14
+ rankOneRepositories: (s) => (rankOne ? { label: s.label.replace(/\d+/, String(rankOne)) } : {}),
15
+ clientLanguages: () => (cratis?.clientLanguages.length ? { value: cratis.clientLanguages.length } : {}),
16
+ };
17
+ const stats = siteStats.map((s) => ({ ...s, ...(s.live ? live[s.live]?.(s) ?? {} : {}) }));
18
+ const fmt = new Intl.NumberFormat('en-US');
19
+ const accents = ['text-event', 'text-command', 'text-view', 'text-fg'];
20
+ ---
21
+
22
+ <section id="stats" aria-label="By the numbers" class="relative border-y border-line bg-ink-900">
23
+ <div class="container-x">
24
+ <dl class="grid grid-cols-2 gap-px bg-line lg:grid-cols-4">
25
+ {
26
+ stats.map((s, i) => {
27
+ const plain = s.plain === true;
28
+ return (
29
+ <div
30
+ class="flex flex-col gap-3 bg-ink-900 px-5 py-10 first:pl-0 md:px-10 md:py-14 [&:nth-child(3)]:pl-0 lg:[&:nth-child(3)]:pl-10"
31
+ data-reveal
32
+ style={`--reveal-delay:${i * 80}ms`}
33
+ >
34
+ <dt class="order-2 max-w-[18ch] text-sm leading-snug text-fg-muted">{s.label}
35
+ {s.live === 'mergedPullRequests' && <span data-pr-status class="mt-1 block font-mono text-[0.6875rem] text-fg-faint">as of {snapshotDate()}</span>}
36
+ </dt>
37
+ <dd class={`order-1 font-display text-[clamp(2.5rem,5vw,4rem)] leading-none font-[650] tracking-[-0.04em] tabular-nums ${accents[i % accents.length]}`}>
38
+ {s.prefix}<span data-merged-prs={s.live === 'mergedPullRequests' ? 'number' : undefined} data-count={plain ? undefined : s.value}>{plain ? s.value : fmt.format(s.value)}</span>
39
+ {s.suffix && <span class="text-fg-faint">{s.suffix}</span>}
40
+ </dd>
41
+ </div>
42
+ );
43
+ })
44
+ }
45
+ </dl>
46
+ </div>
47
+ </section>
48
+
49
+ <script>
50
+ import { motionReduced } from '../lib/motion';
51
+ import { onPageLoad } from '../lib/lifecycle';
52
+ onPageLoad(() => {
53
+ const fmt = new Intl.NumberFormat('en-US');
54
+ const els = document.querySelectorAll<HTMLElement>('[data-count]');
55
+ const frames = new Set<number>();
56
+ let io: IntersectionObserver | undefined;
57
+ if (!motionReduced() && 'IntersectionObserver' in window) {
58
+ const run = (el: HTMLElement) => {
59
+ const target = Number(el.dataset['count']);
60
+ const duration = 1600;
61
+ const start = performance.now();
62
+ let frame: number;
63
+ const step = (ts: number) => {
64
+ frames.delete(frame);
65
+ // A browser refresh supersedes this build-time tween.
66
+ if (el.dataset['live'] === 'true') return;
67
+ const p = Math.min(1, (ts - start) / duration);
68
+ const eased = 1 - Math.pow(1 - p, 4);
69
+ el.textContent = fmt.format(Math.round(target * eased));
70
+ if (p < 1 && !motionReduced()) {
71
+ frame = requestAnimationFrame(step);
72
+ frames.add(frame);
73
+ } else el.textContent = fmt.format(target);
74
+ };
75
+ frame = requestAnimationFrame(step);
76
+ frames.add(frame);
77
+ };
78
+ io = new IntersectionObserver(
79
+ (entries) => {
80
+ for (const e of entries) {
81
+ if (!e.isIntersecting) continue;
82
+ io?.unobserve(e.target);
83
+ run(e.target as HTMLElement);
84
+ }
85
+ },
86
+ { threshold: 0.6 },
87
+ );
88
+ els.forEach((el) => {
89
+ // Reserve the final width so counting never shifts layout.
90
+ el.style.display = 'inline-block';
91
+ el.style.minWidth = `${el.getBoundingClientRect().width}px`;
92
+ el.textContent = '0';
93
+ io?.observe(el);
94
+ });
95
+ }
96
+ return () => {
97
+ io?.disconnect();
98
+ frames.forEach((frame) => cancelAnimationFrame(frame));
99
+ };
100
+ });
101
+ </script>
@@ -0,0 +1,73 @@
1
+ ---
2
+ // Copyright (c) Cratis. All rights reserved.
3
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
4
+
5
+ import { talks, community } from '../data';
6
+ import { formatMonth } from '../lib/career';
7
+ import { renderInline } from '../lib/inline';
8
+ import SectionHeader from './SectionHeader.astro';
9
+
10
+ interface Props {
11
+ index: string;
12
+ title?: string;
13
+ label?: string;
14
+ }
15
+ const { index, title = 'Talks & *community.*', label = 'TalkGiven' } = Astro.props;
16
+ const talksNewest = [...talks].sort((a, b) => b.date.localeCompare(a.date));
17
+ const day = (d: string) =>
18
+ d.length === 10 ? new Date(`${d}T00:00:00Z`).toLocaleDateString('en-US', { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' }) : formatMonth(d);
19
+ const languageName = (lang: string) => {
20
+ try {
21
+ return new Intl.DisplayNames(['en'], { type: 'language' }).of(lang) ?? lang;
22
+ } catch {
23
+ return lang;
24
+ }
25
+ };
26
+ ---
27
+
28
+ <section id="talks" class="relative py-24 md:py-32" aria-labelledby="talks-title">
29
+ <div class="container-x grid grid-cols-1 items-start gap-x-12 lg:grid-cols-12">
30
+ <div class="lg:sticky lg:top-[calc(var(--nav-h)+2rem)] lg:col-span-5 [&>header]:mb-10 lg:[&>header]:mb-0">
31
+ <SectionHeader index={index} label={label} title={title} id="talks-title" />
32
+ </div>
33
+ <div class="grid gap-4 lg:col-span-7">
34
+ {talksNewest.length > 0 && <ul class="grid grid-cols-1 gap-4">
35
+ {
36
+ talksNewest.map((t) => {
37
+ const Tag = t.url ? 'a' : 'div';
38
+ return (
39
+ <li data-reveal>
40
+ <Tag href={t.url} rel={t.url ? 'noopener' : undefined} class:list={['panel flex h-full flex-col gap-4 p-6 md:p-7', t.url && 'glow group']}>
41
+ <span class="flex flex-wrap items-center justify-between gap-2 font-mono text-xs text-fg-faint">
42
+ <span class="note">TalkGiven</span>
43
+ <span><time datetime={t.date}>{day(t.date)}</time>{!t.lang.startsWith('en') && <> · in {languageName(t.lang)}</>}</span>
44
+ </span>
45
+ <span lang={t.lang} class="font-display text-2xl leading-tight font-[700] tracking-[-0.025em] transition-colors group-hover:text-event-soft md:text-[1.75rem]">{t.title}</span>
46
+ <span class="text-fg-muted" set:html={renderInline(t.summary)} />
47
+ <span class="mt-auto flex flex-wrap items-center gap-x-2 gap-y-1 pt-2 text-sm text-fg-muted">
48
+ {t.url && <svg viewBox="0 0 16 16" class="size-4 text-event" fill="currentColor" aria-hidden="true"><path d="M5 3.5v9a.5.5 0 0 0 .77.42l7-4.5a.5.5 0 0 0 0-.84l-7-4.5A.5.5 0 0 0 5 3.5Z" /></svg>}
49
+ {t.url ? 'Watch · ' : ''}{t.host}
50
+ {t.with && <span class="text-fg-faint">· with {t.with}</span>}
51
+ </span>
52
+ </Tag>
53
+ </li>
54
+ );
55
+ })
56
+ }
57
+ </ul>}
58
+ {community.length > 0 && <div class="panel p-5 md:px-7" data-reveal>
59
+ <p class="note note-view">Community</p>
60
+ <ul class="mt-3 divide-y divide-line">
61
+ {
62
+ community.map((c) => (
63
+ <li class="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 py-2.5">
64
+ <span class="text-fg">{c.title}{c.detail && <span class="text-fg-muted"> · {c.detail}</span>}</span>
65
+ <span class="font-mono text-xs text-fg-faint">attended · {formatMonth(c.date)}</span>
66
+ </li>
67
+ ))
68
+ }
69
+ </ul>
70
+ </div>}
71
+ </div>
72
+ </div>
73
+ </section>