@cratis/cast 0.0.1 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -1
- package/badges/RegistryMember.ts +16 -0
- package/badges/ResolvedTier.ts +17 -0
- package/badges/Tier.ts +14 -0
- package/badges/TierSource.ts +14 -0
- package/badges/badgeSvg.ts +137 -0
- package/badges/constants.ts +17 -0
- package/badges/cratisMark.ts +15 -0
- package/badges/index.ts +12 -0
- package/badges/registry.ts +63 -0
- package/badges/resolveTier.ts +24 -0
- package/cast.schema.json +1113 -0
- package/config/CastConfig.ts +17 -0
- package/config/CastConfigError.ts +10 -0
- package/config/LiveStat.ts +17 -0
- package/config/ResolvedSection.ts +13 -0
- package/config/SectionId.ts +21 -0
- package/config/ThemeMode.ts +9 -0
- package/config/castSchema.ts +262 -0
- package/config/index.ts +12 -0
- package/config/parseCast.ts +58 -0
- package/config/resolveSections.ts +48 -0
- package/github/GitHubSnapshot.ts +66 -0
- package/github/fetchGitHub.ts +235 -0
- package/github/parse.ts +63 -0
- package/index.ts +88 -0
- package/integration/CastData.ts +20 -0
- package/integration/CastOptions.ts +17 -0
- package/integration/Snapshot.ts +12 -0
- package/integration/loadRegistry.ts +37 -0
- package/integration/resolveData.ts +138 -0
- package/integration/snapshotFile.ts +23 -0
- package/integration/virtualModule.ts +43 -0
- package/machine/castJson.ts +64 -0
- package/machine/llmsText.ts +69 -0
- package/machine/ogImageSvg.ts +79 -0
- package/machine/resumeJson.ts +87 -0
- package/package.json +76 -8
- package/registry/members.yaml +16 -0
- package/routes/404.astro +40 -0
- package/routes/apple-touch-icon.png.ts +17 -0
- package/routes/badge-card.svg.ts +13 -0
- package/routes/badge.svg.ts +11 -0
- package/routes/cast.json.ts +11 -0
- package/routes/cv.astro +371 -0
- package/routes/favicon.svg.ts +16 -0
- package/routes/index.astro +61 -0
- package/routes/llms.txt.ts +10 -0
- package/routes/og.png.ts +23 -0
- package/routes/resume.json.ts +13 -0
- package/routes/robots.txt.ts +10 -0
- package/site/components/Activity.astro +194 -0
- package/site/components/Agentic.astro +143 -0
- package/site/components/CareerReplay.astro +412 -0
- package/site/components/CastBadge.astro +132 -0
- package/site/components/CommandPalette.astro +116 -0
- package/site/components/Contact.astro +163 -0
- package/site/components/Cratis.astro +233 -0
- package/site/components/CratisMark.astro +18 -0
- package/site/components/EventModel.astro +262 -0
- package/site/components/Footer.astro +28 -0
- package/site/components/Hero.astro +208 -0
- package/site/components/Monogram.astro +25 -0
- package/site/components/Nav.astro +124 -0
- package/site/components/Now.astro +113 -0
- package/site/components/Projects.astro +188 -0
- package/site/components/PullRequestFeed.astro +86 -0
- package/site/components/SectionHeader.astro +27 -0
- package/site/components/Skills.astro +41 -0
- package/site/components/Stats.astro +101 -0
- package/site/components/Talks.astro +73 -0
- package/site/components/Terminal.astro +159 -0
- package/site/components/Writing.astro +126 -0
- package/site/data.ts +48 -0
- package/site/layouts/Base.astro +144 -0
- package/site/lib/accent.ts +52 -0
- package/site/lib/blog.ts +32 -0
- package/site/lib/career.ts +148 -0
- package/site/lib/clipboard.ts +12 -0
- package/site/lib/contributions.ts +63 -0
- package/site/lib/github.ts +55 -0
- package/site/lib/inline.ts +23 -0
- package/site/lib/lifecycle.ts +44 -0
- package/site/lib/motion.ts +43 -0
- package/site/lib/nav.ts +38 -0
- package/site/lib/reltime.ts +33 -0
- package/site/lib/safeJson.ts +15 -0
- package/site/lib/sections.ts +41 -0
- package/site/lib/theme.ts +28 -0
- package/site/scripts/ambient.ts +93 -0
- package/site/scripts/event-board.ts +403 -0
- package/site/scripts/event-model.ts +155 -0
- package/site/scripts/heatmap.ts +94 -0
- package/site/scripts/live.ts +145 -0
- package/site/scripts/palette.ts +296 -0
- package/site/scripts/replay.ts +251 -0
- package/site/scripts/terminal.ts +556 -0
- package/site/styles/global.css +508 -0
- package/virtual.d.ts +15 -0
- package/writing/WritingSnapshot.ts +20 -0
- package/writing/fetchWriting.ts +54 -0
- package/writing/parse.ts +72 -0
|
@@ -0,0 +1,508 @@
|
|
|
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
|
+
/* Cratis Cast design system: the cratis.no ground and type, the Event Modeling sticky-note colors,
|
|
5
|
+
and one person accent (--color-accent) that defaults to Cratis violet. */
|
|
6
|
+
@import 'tailwindcss';
|
|
7
|
+
|
|
8
|
+
/* The engine ships its components as source; let Tailwind see them. */
|
|
9
|
+
@source "../../";
|
|
10
|
+
|
|
11
|
+
/* ---------------------------------------------------------------------------
|
|
12
|
+
Size-adjusted local fallbacks so swapping in the web fonts does not shift
|
|
13
|
+
layout. Values are tuned against Arial / Courier New metrics.
|
|
14
|
+
--------------------------------------------------------------------------- */
|
|
15
|
+
@font-face {
|
|
16
|
+
font-family: 'Inter Fallback';
|
|
17
|
+
src: local('Arial'), local('Helvetica Neue'), local('Helvetica');
|
|
18
|
+
size-adjust: 107.12%;
|
|
19
|
+
ascent-override: 90.44%;
|
|
20
|
+
descent-override: 22.52%;
|
|
21
|
+
line-gap-override: 0%;
|
|
22
|
+
}
|
|
23
|
+
@font-face {
|
|
24
|
+
font-family: 'Fraunces Fallback';
|
|
25
|
+
src: local('Georgia'), local('Times New Roman');
|
|
26
|
+
size-adjust: 104%;
|
|
27
|
+
ascent-override: 92%;
|
|
28
|
+
descent-override: 24%;
|
|
29
|
+
line-gap-override: 0%;
|
|
30
|
+
}
|
|
31
|
+
@font-face {
|
|
32
|
+
font-family: 'Mono Fallback';
|
|
33
|
+
src: local('Courier New'), local('Menlo');
|
|
34
|
+
size-adjust: 100%;
|
|
35
|
+
ascent-override: 102%;
|
|
36
|
+
descent-override: 30%;
|
|
37
|
+
line-gap-override: 0%;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
@theme {
|
|
41
|
+
--font-sans: 'Inter Variable', 'Inter Fallback', ui-sans-serif, system-ui, sans-serif;
|
|
42
|
+
--font-display: 'Inter Variable', 'Inter Fallback', ui-sans-serif, system-ui, sans-serif;
|
|
43
|
+
--font-serif: 'Fraunces Variable', 'Fraunces Fallback', Georgia, serif;
|
|
44
|
+
--font-mono: 'JetBrains Mono Variable', 'Mono Fallback', ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
45
|
+
|
|
46
|
+
/* The person's accent. Overridden per site from cast.yaml (theme.accent); defaults to Cratis violet. */
|
|
47
|
+
--color-accent: #8b5cf6;
|
|
48
|
+
--color-accent-soft: #c9baff;
|
|
49
|
+
--color-accent-ink: #ffffff;
|
|
50
|
+
|
|
51
|
+
/* Cratis brand pair, for the Cast mark and Core gradients. */
|
|
52
|
+
--color-violet: #8b5cf6;
|
|
53
|
+
--color-teal: #2dd4bf;
|
|
54
|
+
|
|
55
|
+
/* Event Modeling sticky-note language: command blue, event amber, read model teal. */
|
|
56
|
+
--color-event: #f2994a;
|
|
57
|
+
--color-event-soft: #f8bd85;
|
|
58
|
+
--color-command: #5b86f2;
|
|
59
|
+
--color-command-soft: #a3bbf8;
|
|
60
|
+
--color-view: #2dd4bf;
|
|
61
|
+
--color-view-soft: #8eeadc;
|
|
62
|
+
|
|
63
|
+
/* cratis.no ground */
|
|
64
|
+
--color-ink-950: #08080e;
|
|
65
|
+
--color-ink-900: #0c0c15;
|
|
66
|
+
--color-ink-850: #10101a;
|
|
67
|
+
--color-ink-800: #14141f;
|
|
68
|
+
--color-ink-700: #1d1d2b;
|
|
69
|
+
--color-fg: #f5f3f0;
|
|
70
|
+
--color-fg-muted: #a09fad;
|
|
71
|
+
--color-fg-faint: #7c7b8b;
|
|
72
|
+
--color-line: color-mix(in srgb, var(--color-fg) 8%, transparent);
|
|
73
|
+
--color-line-strong: color-mix(in srgb, var(--color-fg) 15%, transparent);
|
|
74
|
+
|
|
75
|
+
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
|
|
76
|
+
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
|
|
77
|
+
|
|
78
|
+
--animate-pop-in: pop-in 520ms var(--ease-spring) both;
|
|
79
|
+
--animate-pulse-dot: pulse-dot 2.4s ease-in-out infinite;
|
|
80
|
+
|
|
81
|
+
@keyframes pop-in {
|
|
82
|
+
0% { opacity: 0; transform: translateY(6px) scale(0.85); }
|
|
83
|
+
100% { opacity: 1; transform: none; }
|
|
84
|
+
}
|
|
85
|
+
@keyframes pulse-dot {
|
|
86
|
+
0%, 100% { box-shadow: 0 0 0 0 rgb(45 212 191 / 0.55); }
|
|
87
|
+
50% { box-shadow: 0 0 0 6px rgb(45 212 191 / 0); }
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/* Light theme, designed rather than inverted (as on cratis.no): warm off-white ground, white panels,
|
|
92
|
+
accents darkened so text stays AA on paper. */
|
|
93
|
+
html[data-theme='light'] {
|
|
94
|
+
color-scheme: light;
|
|
95
|
+
--color-event: #a8500c;
|
|
96
|
+
--color-event-soft: #8a4009;
|
|
97
|
+
--color-command: #2150cf;
|
|
98
|
+
--color-command-soft: #1b43ad;
|
|
99
|
+
--color-view: #0f766e;
|
|
100
|
+
--color-view-soft: #0b5e57;
|
|
101
|
+
--color-violet: #6d28d9;
|
|
102
|
+
--color-teal: #0f766e;
|
|
103
|
+
--color-ink-950: #faf9f6;
|
|
104
|
+
--color-ink-900: #ffffff;
|
|
105
|
+
--color-ink-850: #f5f2fc;
|
|
106
|
+
--color-ink-800: #efecf6;
|
|
107
|
+
--color-ink-700: #e2deec;
|
|
108
|
+
--color-fg: #17141f;
|
|
109
|
+
--color-fg-muted: #4e4a5c;
|
|
110
|
+
--color-fg-faint: #69657a;
|
|
111
|
+
--color-line: rgb(23 20 40 / 0.1);
|
|
112
|
+
--color-line-strong: rgb(23 20 40 / 0.2);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
@layer base {
|
|
116
|
+
:root {
|
|
117
|
+
color-scheme: dark;
|
|
118
|
+
--nav-h: 4rem;
|
|
119
|
+
}
|
|
120
|
+
html {
|
|
121
|
+
background: var(--color-ink-950);
|
|
122
|
+
color: var(--color-fg);
|
|
123
|
+
font-family: var(--font-sans);
|
|
124
|
+
font-feature-settings: 'cv11', 'ss01', 'ss03';
|
|
125
|
+
-webkit-font-smoothing: antialiased;
|
|
126
|
+
-moz-osx-font-smoothing: grayscale;
|
|
127
|
+
text-rendering: optimizeLegibility;
|
|
128
|
+
scroll-padding-top: var(--nav-h);
|
|
129
|
+
}
|
|
130
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
131
|
+
html:not([data-motion='reduced']) {
|
|
132
|
+
scroll-behavior: smooth;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
body {
|
|
136
|
+
min-height: 100dvh;
|
|
137
|
+
overflow-x: clip;
|
|
138
|
+
}
|
|
139
|
+
::selection {
|
|
140
|
+
background: color-mix(in srgb, var(--color-accent) 40%, transparent);
|
|
141
|
+
color: var(--color-fg);
|
|
142
|
+
}
|
|
143
|
+
:focus-visible {
|
|
144
|
+
outline: 2px solid var(--color-command);
|
|
145
|
+
outline-offset: 3px;
|
|
146
|
+
border-radius: 6px;
|
|
147
|
+
}
|
|
148
|
+
h1, h2, h3, .font-display {
|
|
149
|
+
font-family: var(--font-display);
|
|
150
|
+
font-optical-sizing: auto;
|
|
151
|
+
text-wrap: balance;
|
|
152
|
+
}
|
|
153
|
+
code.inline-code {
|
|
154
|
+
font-family: var(--font-mono);
|
|
155
|
+
font-size: 0.85em;
|
|
156
|
+
padding: 0.08em 0.35em;
|
|
157
|
+
border-radius: 0.35em;
|
|
158
|
+
border: 1px solid var(--color-line);
|
|
159
|
+
background: color-mix(in srgb, var(--color-fg) 4%, transparent);
|
|
160
|
+
}
|
|
161
|
+
p {
|
|
162
|
+
text-wrap: pretty;
|
|
163
|
+
}
|
|
164
|
+
a {
|
|
165
|
+
text-underline-offset: 0.2em;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
@layer components {
|
|
170
|
+
/* Film grain over everything; purely decorative. */
|
|
171
|
+
.grain::after {
|
|
172
|
+
content: '';
|
|
173
|
+
position: fixed;
|
|
174
|
+
inset: 0;
|
|
175
|
+
z-index: 60;
|
|
176
|
+
pointer-events: none;
|
|
177
|
+
opacity: 0.05;
|
|
178
|
+
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.9 0'/></filter><rect width='100%' height='100%' filter='url(%23n)'/></svg>");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/* The italic serif, the Cratis signature move: accent words in Fraunces, softly glowing. */
|
|
182
|
+
.serif {
|
|
183
|
+
font-family: var(--font-serif);
|
|
184
|
+
font-style: italic;
|
|
185
|
+
font-weight: 560;
|
|
186
|
+
letter-spacing: -0.01em;
|
|
187
|
+
color: var(--color-accent-soft);
|
|
188
|
+
text-shadow: 0 0 44px color-mix(in srgb, var(--color-accent) 45%, transparent);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
.container-x {
|
|
192
|
+
width: 100%;
|
|
193
|
+
max-width: 76rem;
|
|
194
|
+
margin-inline: auto;
|
|
195
|
+
padding-inline: clamp(1.25rem, 4vw, 2.5rem);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
.eyebrow {
|
|
199
|
+
font-family: var(--font-mono);
|
|
200
|
+
font-size: 0.75rem;
|
|
201
|
+
letter-spacing: 0.08em;
|
|
202
|
+
text-transform: uppercase;
|
|
203
|
+
color: var(--color-fg-muted);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
.panel {
|
|
207
|
+
position: relative;
|
|
208
|
+
border-radius: 1.25rem;
|
|
209
|
+
border: 1px solid var(--color-line);
|
|
210
|
+
background:
|
|
211
|
+
linear-gradient(180deg, color-mix(in srgb, var(--color-fg) 3.5%, transparent), color-mix(in srgb, var(--color-fg) 1.2%, transparent)),
|
|
212
|
+
color-mix(in srgb, var(--color-ink-900) 72%, transparent);
|
|
213
|
+
box-shadow:
|
|
214
|
+
inset 0 1px 0 color-mix(in srgb, var(--color-fg) 5%, transparent),
|
|
215
|
+
0 30px 60px -30px rgb(0 0 0 / 0.6);
|
|
216
|
+
backdrop-filter: blur(14px) saturate(1.2);
|
|
217
|
+
-webkit-backdrop-filter: blur(14px) saturate(1.2);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/* Pointer-following glow border. JS sets --mx/--my on pointermove. */
|
|
221
|
+
.glow {
|
|
222
|
+
--mx: 50%;
|
|
223
|
+
--my: 50%;
|
|
224
|
+
--glow: var(--color-accent);
|
|
225
|
+
}
|
|
226
|
+
.glow::before {
|
|
227
|
+
content: '';
|
|
228
|
+
position: absolute;
|
|
229
|
+
inset: -1px;
|
|
230
|
+
border-radius: inherit;
|
|
231
|
+
padding: 1px;
|
|
232
|
+
background: radial-gradient(
|
|
233
|
+
420px circle at var(--mx) var(--my),
|
|
234
|
+
color-mix(in oklab, var(--glow) 85%, transparent),
|
|
235
|
+
transparent 45%
|
|
236
|
+
);
|
|
237
|
+
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
|
|
238
|
+
-webkit-mask-composite: xor;
|
|
239
|
+
mask: linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0);
|
|
240
|
+
opacity: 0;
|
|
241
|
+
transition: opacity 300ms ease;
|
|
242
|
+
pointer-events: none;
|
|
243
|
+
}
|
|
244
|
+
.glow::after {
|
|
245
|
+
content: '';
|
|
246
|
+
position: absolute;
|
|
247
|
+
inset: 0;
|
|
248
|
+
border-radius: inherit;
|
|
249
|
+
background: radial-gradient(
|
|
250
|
+
600px circle at var(--mx) var(--my),
|
|
251
|
+
color-mix(in oklab, var(--glow) 7%, transparent),
|
|
252
|
+
transparent 40%
|
|
253
|
+
);
|
|
254
|
+
opacity: 0;
|
|
255
|
+
transition: opacity 300ms ease;
|
|
256
|
+
pointer-events: none;
|
|
257
|
+
}
|
|
258
|
+
.glow:hover::before,
|
|
259
|
+
.glow:hover::after,
|
|
260
|
+
.glow:focus-within::before {
|
|
261
|
+
opacity: 1;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
.tag {
|
|
265
|
+
display: inline-flex;
|
|
266
|
+
align-items: center;
|
|
267
|
+
gap: 0.375rem;
|
|
268
|
+
border-radius: 999px;
|
|
269
|
+
border: 1px solid var(--color-line);
|
|
270
|
+
background: color-mix(in srgb, var(--color-fg) 2.5%, transparent);
|
|
271
|
+
padding: 0.25rem 0.65rem;
|
|
272
|
+
font-size: 0.8125rem;
|
|
273
|
+
line-height: 1.25rem;
|
|
274
|
+
color: var(--color-fg-muted);
|
|
275
|
+
white-space: nowrap;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
.btn {
|
|
279
|
+
display: inline-flex;
|
|
280
|
+
align-items: center;
|
|
281
|
+
justify-content: center;
|
|
282
|
+
gap: 0.5rem;
|
|
283
|
+
height: 2.75rem;
|
|
284
|
+
padding-inline: 1.15rem;
|
|
285
|
+
border-radius: 0.8rem;
|
|
286
|
+
font-weight: 550;
|
|
287
|
+
font-size: 0.9375rem;
|
|
288
|
+
border: 1px solid var(--color-line-strong);
|
|
289
|
+
background: color-mix(in srgb, var(--color-fg) 4%, transparent);
|
|
290
|
+
color: var(--color-fg);
|
|
291
|
+
transition:
|
|
292
|
+
transform 180ms var(--ease-out-expo),
|
|
293
|
+
background-color 180ms ease,
|
|
294
|
+
border-color 180ms ease,
|
|
295
|
+
box-shadow 180ms ease;
|
|
296
|
+
white-space: nowrap;
|
|
297
|
+
cursor: pointer;
|
|
298
|
+
}
|
|
299
|
+
.btn:hover {
|
|
300
|
+
background: color-mix(in srgb, var(--color-fg) 8%, transparent);
|
|
301
|
+
border-color: color-mix(in srgb, var(--color-fg) 22%, transparent);
|
|
302
|
+
}
|
|
303
|
+
.btn:active {
|
|
304
|
+
transform: translateY(1px) scale(0.98);
|
|
305
|
+
}
|
|
306
|
+
.btn-primary {
|
|
307
|
+
background: var(--color-accent);
|
|
308
|
+
border-color: transparent;
|
|
309
|
+
color: var(--color-accent-ink);
|
|
310
|
+
box-shadow:
|
|
311
|
+
inset 0 1px 0 rgb(255 255 255 / 0.28),
|
|
312
|
+
0 10px 30px -10px color-mix(in srgb, var(--color-accent) 70%, transparent);
|
|
313
|
+
}
|
|
314
|
+
.btn-primary:hover {
|
|
315
|
+
background: color-mix(in srgb, var(--color-accent) 86%, #fff);
|
|
316
|
+
border-color: transparent;
|
|
317
|
+
}
|
|
318
|
+
.btn-sm {
|
|
319
|
+
height: 2.25rem;
|
|
320
|
+
padding-inline: 0.8rem;
|
|
321
|
+
border-radius: 0.65rem;
|
|
322
|
+
font-size: 0.8125rem;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
.kbd {
|
|
326
|
+
display: inline-flex;
|
|
327
|
+
align-items: center;
|
|
328
|
+
justify-content: center;
|
|
329
|
+
min-width: 1.4rem;
|
|
330
|
+
height: 1.4rem;
|
|
331
|
+
padding-inline: 0.3rem;
|
|
332
|
+
border-radius: 0.375rem;
|
|
333
|
+
border: 1px solid var(--color-line-strong);
|
|
334
|
+
border-bottom-width: 2px;
|
|
335
|
+
font-family: var(--font-mono);
|
|
336
|
+
font-size: 0.6875rem;
|
|
337
|
+
color: var(--color-fg-muted);
|
|
338
|
+
background: color-mix(in srgb, var(--color-fg) 3%, transparent);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/* Event Modeling sticky-note chips */
|
|
342
|
+
.note {
|
|
343
|
+
--note: var(--color-event);
|
|
344
|
+
display: inline-flex;
|
|
345
|
+
align-items: center;
|
|
346
|
+
gap: 0.4rem;
|
|
347
|
+
font-family: var(--font-mono);
|
|
348
|
+
font-size: 0.75rem;
|
|
349
|
+
font-weight: 500;
|
|
350
|
+
color: var(--note);
|
|
351
|
+
}
|
|
352
|
+
.note::before {
|
|
353
|
+
content: '';
|
|
354
|
+
width: 0.55rem;
|
|
355
|
+
height: 0.55rem;
|
|
356
|
+
border-radius: 2px;
|
|
357
|
+
background: var(--note);
|
|
358
|
+
box-shadow: 0 0 12px color-mix(in oklab, var(--note) 60%, transparent);
|
|
359
|
+
}
|
|
360
|
+
.note-command { --note: var(--color-command); }
|
|
361
|
+
.note-view { --note: var(--color-view); }
|
|
362
|
+
|
|
363
|
+
/* Filter chips (projects, writing). */
|
|
364
|
+
.filter {
|
|
365
|
+
display: inline-flex;
|
|
366
|
+
align-items: center;
|
|
367
|
+
gap: 0.5rem;
|
|
368
|
+
height: 2.25rem;
|
|
369
|
+
padding-inline: 0.95rem;
|
|
370
|
+
border-radius: 999px;
|
|
371
|
+
border: 1px solid var(--color-line);
|
|
372
|
+
color: var(--color-fg-muted);
|
|
373
|
+
font-size: 0.875rem;
|
|
374
|
+
transition: all 200ms ease;
|
|
375
|
+
cursor: pointer;
|
|
376
|
+
}
|
|
377
|
+
.filter:hover {
|
|
378
|
+
color: var(--color-fg);
|
|
379
|
+
border-color: var(--color-line-strong);
|
|
380
|
+
}
|
|
381
|
+
.filter[aria-pressed='true'] {
|
|
382
|
+
color: var(--color-ink-950);
|
|
383
|
+
background: var(--color-fg);
|
|
384
|
+
border-color: var(--color-fg);
|
|
385
|
+
}
|
|
386
|
+
.section-title {
|
|
387
|
+
font-size: clamp(2.25rem, 5vw, 3.75rem);
|
|
388
|
+
line-height: 1.02;
|
|
389
|
+
letter-spacing: -0.035em;
|
|
390
|
+
font-weight: 800;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
.link-quiet {
|
|
394
|
+
color: var(--color-fg-muted);
|
|
395
|
+
transition: color 150ms ease;
|
|
396
|
+
}
|
|
397
|
+
.link-quiet:hover {
|
|
398
|
+
color: var(--color-fg);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
.sr-only-focusable:not(:focus) {
|
|
402
|
+
position: absolute;
|
|
403
|
+
width: 1px;
|
|
404
|
+
height: 1px;
|
|
405
|
+
overflow: hidden;
|
|
406
|
+
clip-path: inset(50%);
|
|
407
|
+
white-space: nowrap;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/* Reveal-on-scroll. Only hidden once JS has confirmed it will reveal. */
|
|
411
|
+
.js-motion [data-reveal] {
|
|
412
|
+
opacity: 0;
|
|
413
|
+
transform: translateY(18px);
|
|
414
|
+
transition:
|
|
415
|
+
opacity 900ms var(--ease-out-expo),
|
|
416
|
+
transform 900ms var(--ease-out-expo);
|
|
417
|
+
transition-delay: var(--reveal-delay, 0ms);
|
|
418
|
+
}
|
|
419
|
+
.js-motion [data-reveal].is-visible {
|
|
420
|
+
opacity: 1;
|
|
421
|
+
transform: none;
|
|
422
|
+
}
|
|
423
|
+
/* Section headings are scroll-driven where the browser supports it; elsewhere the
|
|
424
|
+
IntersectionObserver reveal above applies. */
|
|
425
|
+
@supports (animation-timeline: view()) {
|
|
426
|
+
.js-motion header[data-reveal] {
|
|
427
|
+
opacity: 1;
|
|
428
|
+
transform: none;
|
|
429
|
+
transition: none;
|
|
430
|
+
}
|
|
431
|
+
.js-motion header[data-reveal] > * {
|
|
432
|
+
animation: heading-in linear both;
|
|
433
|
+
animation-timeline: view();
|
|
434
|
+
animation-range: entry 5% cover 28%;
|
|
435
|
+
}
|
|
436
|
+
.js-motion header[data-reveal] > h2 {
|
|
437
|
+
animation-range: entry 10% cover 32%;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
@keyframes heading-in {
|
|
441
|
+
from {
|
|
442
|
+
opacity: 0;
|
|
443
|
+
transform: translateY(28px);
|
|
444
|
+
filter: blur(6px);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/* Light-theme details that tokens alone do not cover. */
|
|
450
|
+
html[data-theme='light'] .grain::after {
|
|
451
|
+
filter: invert(1);
|
|
452
|
+
opacity: 0.035;
|
|
453
|
+
}
|
|
454
|
+
html[data-theme='light'] .btn-primary,
|
|
455
|
+
html[data-theme='light'] .btn-primary:hover {
|
|
456
|
+
color: #fff;
|
|
457
|
+
background: var(--color-accent);
|
|
458
|
+
box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.25), 0 10px 30px -12px color-mix(in srgb, var(--color-accent) 55%, transparent);
|
|
459
|
+
}
|
|
460
|
+
html[data-theme='light'] .serif {
|
|
461
|
+
color: var(--color-accent);
|
|
462
|
+
text-shadow: none;
|
|
463
|
+
}
|
|
464
|
+
html[data-theme='light'] .panel {
|
|
465
|
+
box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.7), 0 24px 50px -32px rgb(20 22 30 / 0.25);
|
|
466
|
+
}
|
|
467
|
+
html[data-theme='light'] ::backdrop {
|
|
468
|
+
background: rgb(20 22 30 / 0.28);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/* Smooth cross-fade when the theme flips (not on first paint). */
|
|
472
|
+
html.theme-flip,
|
|
473
|
+
html.theme-flip * {
|
|
474
|
+
transition:
|
|
475
|
+
background-color 400ms ease,
|
|
476
|
+
color 400ms ease,
|
|
477
|
+
border-color 400ms ease !important;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
@media (prefers-reduced-motion: reduce) {
|
|
481
|
+
*, *::before, *::after {
|
|
482
|
+
animation-duration: 1ms !important;
|
|
483
|
+
animation-iteration-count: 1 !important;
|
|
484
|
+
transition-duration: 1ms !important;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
html[data-motion='reduced'] *,
|
|
488
|
+
html[data-motion='reduced'] *::before,
|
|
489
|
+
html[data-motion='reduced'] *::after {
|
|
490
|
+
animation-duration: 1ms !important;
|
|
491
|
+
animation-iteration-count: 1 !important;
|
|
492
|
+
transition-duration: 1ms !important;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
@media print {
|
|
496
|
+
:root {
|
|
497
|
+
color-scheme: light;
|
|
498
|
+
}
|
|
499
|
+
html,
|
|
500
|
+
body {
|
|
501
|
+
background: #fff !important;
|
|
502
|
+
color: #111 !important;
|
|
503
|
+
font-size: 8.8pt;
|
|
504
|
+
}
|
|
505
|
+
.grain::after {
|
|
506
|
+
display: none !important;
|
|
507
|
+
}
|
|
508
|
+
}
|
package/virtual.d.ts
ADDED
|
@@ -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
|
+
declare module 'virtual:cast' {
|
|
5
|
+
type CastData = import('./integration/CastData').CastData;
|
|
6
|
+
|
|
7
|
+
export const config: CastData['config'];
|
|
8
|
+
export const sections: CastData['sections'];
|
|
9
|
+
export const github: CastData['github'];
|
|
10
|
+
export const writing: CastData['writing'];
|
|
11
|
+
export const tier: CastData['tier'];
|
|
12
|
+
export const photoUrl: CastData['photoUrl'];
|
|
13
|
+
export const build: CastData['build'];
|
|
14
|
+
export const photo: import('astro').ImageMetadata | undefined;
|
|
15
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
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
|
+
/** A post as captured from a feed or an author page. */
|
|
5
|
+
export type Post = {
|
|
6
|
+
title: string;
|
|
7
|
+
url: string;
|
|
8
|
+
date: string;
|
|
9
|
+
minutes?: number;
|
|
10
|
+
description: string;
|
|
11
|
+
tags: string[];
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/** Everything Cast captured from the configured writing sources. */
|
|
15
|
+
export type WritingSnapshot = {
|
|
16
|
+
fetchedAt?: string;
|
|
17
|
+
/** Where the posts came from, for "on blog.example.com" links. */
|
|
18
|
+
sources: string[];
|
|
19
|
+
posts: Post[];
|
|
20
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
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 { z } from 'zod';
|
|
5
|
+
import type { writingSourceSchema } from '../config/castSchema';
|
|
6
|
+
import { mergePosts, parseFeed, parseStarlightAuthorPage } from './parse';
|
|
7
|
+
import type { Post, WritingSnapshot } from './WritingSnapshot';
|
|
8
|
+
|
|
9
|
+
type WritingSource = z.output<typeof writingSourceSchema>;
|
|
10
|
+
const maximumPages = 10;
|
|
11
|
+
|
|
12
|
+
async function text(url: URL): Promise<string | undefined> {
|
|
13
|
+
const response = await fetch(url, { headers: { 'user-agent': 'cratis-cast-build' }, signal: AbortSignal.timeout(20_000) });
|
|
14
|
+
if (response.status === 404) return undefined;
|
|
15
|
+
if (!response.ok) throw new Error(`${url} answered ${response.status}`);
|
|
16
|
+
return response.text();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function starlightAuthor(authorPage: string): Promise<Post[]> {
|
|
20
|
+
const base = new URL(authorPage.endsWith('/') ? authorPage : `${authorPage}/`);
|
|
21
|
+
const posts: Post[] = [];
|
|
22
|
+
for (let page = 1; page <= maximumPages; page++) {
|
|
23
|
+
const html = await text(page === 1 ? base : new URL(`${page}/`, base));
|
|
24
|
+
if (!html) break;
|
|
25
|
+
const found = parseStarlightAuthorPage(html, base.origin);
|
|
26
|
+
if (found.length === 0) break;
|
|
27
|
+
posts.push(...found);
|
|
28
|
+
if (!html.includes(`${base.pathname}${page + 1}/`)) break;
|
|
29
|
+
}
|
|
30
|
+
return posts;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Captures posts from every source. Returns undefined when no source produced posts. Never throws. */
|
|
34
|
+
export async function fetchWriting(sources: readonly WritingSource[], warn: (message: string) => void): Promise<WritingSnapshot | undefined> {
|
|
35
|
+
const posts: Post[] = [];
|
|
36
|
+
const origins: string[] = [];
|
|
37
|
+
for (const source of sources) {
|
|
38
|
+
try {
|
|
39
|
+
if ('rss' in source) {
|
|
40
|
+
const xml = await text(new URL(source.rss));
|
|
41
|
+
const found = xml ? parseFeed(xml, source.author) : [];
|
|
42
|
+
posts.push(...found);
|
|
43
|
+
origins.push(new URL(source.rss).origin);
|
|
44
|
+
} else {
|
|
45
|
+
posts.push(...await starlightAuthor(source.starlightBlogAuthor));
|
|
46
|
+
origins.push(source.starlightBlogAuthor);
|
|
47
|
+
}
|
|
48
|
+
} catch (error) {
|
|
49
|
+
warn(`writing source ${'rss' in source ? source.rss : source.starlightBlogAuthor}: ${error instanceof Error ? error.message : String(error)}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (posts.length === 0) return undefined;
|
|
53
|
+
return { fetchedAt: new Date().toISOString(), sources: origins, posts: mergePosts(posts) };
|
|
54
|
+
}
|
package/writing/parse.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
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 { Post } from './WritingSnapshot';
|
|
5
|
+
|
|
6
|
+
/** Strips tags, decodes the common entities and collapses whitespace. */
|
|
7
|
+
export const decodeHtml = (value: string): string =>
|
|
8
|
+
value
|
|
9
|
+
.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1')
|
|
10
|
+
.replace(/<[^>]+>/g, '')
|
|
11
|
+
.replace(/</g, '<')
|
|
12
|
+
.replace(/>/g, '>')
|
|
13
|
+
.replace(/"/g, '"')
|
|
14
|
+
.replace(/'|'|'/g, '\'')
|
|
15
|
+
.replace(/ /g, ' ')
|
|
16
|
+
.replace(/&/g, '&')
|
|
17
|
+
.replace(/\s+/g, ' ')
|
|
18
|
+
.trim();
|
|
19
|
+
|
|
20
|
+
/** Parses the post previews on a Starlight Blog author page. Only links on `origin` are kept. */
|
|
21
|
+
export function parseStarlightAuthorPage(html: string, origin: string): Post[] {
|
|
22
|
+
const posts: Post[] = [];
|
|
23
|
+
for (const block of html.split('<article class="sl-blog-preview').slice(1)) {
|
|
24
|
+
const href = block.match(/class="sl-blog-preview-link[^"]*" href="([^"]+)"/)?.[1];
|
|
25
|
+
const title = block.match(/<h2 class="sl-blog-preview-title[^"]*">([\s\S]*?)<\/h2>/)?.[1];
|
|
26
|
+
const date = block.match(/<time[^>]*datetime="([^"]+)"/)?.[1];
|
|
27
|
+
if (!href || !title || !date) continue;
|
|
28
|
+
const url = new URL(href, origin);
|
|
29
|
+
if (url.protocol !== 'https:' || url.origin !== origin) continue;
|
|
30
|
+
const minutes = Number(block.match(/(\d+) min read/)?.[1]) || undefined;
|
|
31
|
+
const excerpt = block.match(/sl-blog-preview-excerpt[^>]*>([\s\S]*?)<\/div>/)?.[1] ?? '';
|
|
32
|
+
const tags = [...block.matchAll(/class="sl-blog-tag[^"]*" href="[^"]*">([^<]+)<\/a>/g)].map(match => decodeHtml(match[1]!));
|
|
33
|
+
posts.push({ title: decodeHtml(title), url: url.toString(), date, minutes, description: decodeHtml(excerpt), tags });
|
|
34
|
+
}
|
|
35
|
+
return posts;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const tagValue = (block: string, name: string): string | undefined =>
|
|
39
|
+
block.match(new RegExp(`<${name}(?:\\s[^>]*)?>([\\s\\S]*?)</${name}>`, 'i'))?.[1];
|
|
40
|
+
|
|
41
|
+
/** Parses RSS 2.0 items and Atom entries. With `author`, keeps only entries naming that author. */
|
|
42
|
+
export function parseFeed(xml: string, author?: string): Post[] {
|
|
43
|
+
const blocks = [...xml.matchAll(/<(item|entry)[\s>][\s\S]*?<\/\1>/gi)].map(match => match[0]);
|
|
44
|
+
const posts: Post[] = [];
|
|
45
|
+
for (const block of blocks) {
|
|
46
|
+
const title = tagValue(block, 'title');
|
|
47
|
+
const link = block.match(/<link[^>]*href="([^"]+)"/i)?.[1] ?? tagValue(block, 'link');
|
|
48
|
+
const date = tagValue(block, 'pubDate') ?? tagValue(block, 'published') ?? tagValue(block, 'updated') ?? tagValue(block, 'dc:date');
|
|
49
|
+
if (!title || !link || !date) continue;
|
|
50
|
+
const authors = [tagValue(block, 'author'), tagValue(block, 'dc:creator'), tagValue(block, 'name')].filter(Boolean).map(value => decodeHtml(value!));
|
|
51
|
+
if (author && !authors.some(name => name.toLowerCase().includes(author.toLowerCase()))) continue;
|
|
52
|
+
let url: URL;
|
|
53
|
+
try {
|
|
54
|
+
url = new URL(decodeHtml(link));
|
|
55
|
+
} catch {
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:') continue;
|
|
59
|
+
const parsedDate = new Date(decodeHtml(date));
|
|
60
|
+
if (Number.isNaN(parsedDate.getTime())) continue;
|
|
61
|
+
const description = decodeHtml(tagValue(block, 'description') ?? tagValue(block, 'summary') ?? '').slice(0, 400);
|
|
62
|
+
const tags = [...block.matchAll(/<category(?:[^>]*term="([^"]+)"[^>]*\/?>|[^>]*>([\s\S]*?)<\/category>)/gi)]
|
|
63
|
+
.map(match => decodeHtml(match[1] ?? match[2] ?? '').toLowerCase().replace(/\s+/g, '-'))
|
|
64
|
+
.filter(Boolean);
|
|
65
|
+
posts.push({ title: decodeHtml(title), url: url.toString(), date: parsedDate.toISOString(), description, tags });
|
|
66
|
+
}
|
|
67
|
+
return posts;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Newest first, one entry per URL. */
|
|
71
|
+
export const mergePosts = (posts: readonly Post[]): Post[] =>
|
|
72
|
+
[...new Map(posts.map(post => [post.url, post])).values()].sort((left, right) => right.date.localeCompare(left.date));
|