@cratis/cast 0.0.1 → 0.2.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 +13 -1
- package/badges/RegistryMember.ts +18 -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 +68 -0
- package/badges/resolveTier.ts +24 -0
- package/cast-json.schema.json +1496 -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/calendar.ts +34 -0
- package/config/castSchema.ts +263 -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/gitHubSnapshotSchema.ts +58 -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 +77 -0
- package/machine/castJsonSchema.ts +35 -0
- package/machine/index.ts +6 -0
- package/machine/llmsText.ts +69 -0
- package/machine/ogImageSvg.ts +79 -0
- package/machine/profileFromCastJson.ts +214 -0
- package/machine/resumeJson.ts +87 -0
- package/package.json +80 -8
- package/registry/members.yaml +16 -0
- package/routes/404.astro +42 -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 +17 -0
- package/routes/cv.astro +9 -0
- package/routes/favicon.svg.ts +16 -0
- package/routes/index.astro +9 -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 +196 -0
- package/site/components/Agentic.astro +144 -0
- package/site/components/CareerReplay.astro +415 -0
- package/site/components/CastBadge.astro +132 -0
- package/site/components/CommandPalette.astro +122 -0
- package/site/components/Contact.astro +166 -0
- package/site/components/Cratis.astro +235 -0
- package/site/components/CratisMark.astro +18 -0
- package/site/components/EventModel.astro +265 -0
- package/site/components/Footer.astro +33 -0
- package/site/components/Hero.astro +211 -0
- package/site/components/Inline.astro +45 -0
- package/site/components/Monogram.astro +28 -0
- package/site/components/Nav.astro +127 -0
- package/site/components/Now.astro +116 -0
- package/site/components/Projects.astro +185 -0
- package/site/components/PullRequestFeed.astro +88 -0
- package/site/components/SectionHeader.astro +30 -0
- package/site/components/Skills.astro +44 -0
- package/site/components/Stats.astro +106 -0
- package/site/components/Talks.astro +76 -0
- package/site/components/Terminal.astro +164 -0
- package/site/components/Writing.astro +128 -0
- package/site/data.ts +11 -0
- package/site/layouts/Base.astro +156 -0
- package/site/lib/accent.ts +52 -0
- package/site/lib/blog.ts +58 -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 +72 -0
- package/site/lib/inline.ts +79 -0
- package/site/lib/lifecycle.ts +44 -0
- package/site/lib/motion.ts +43 -0
- package/site/lib/nav.ts +49 -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/pages/CvPage.astro +379 -0
- package/site/pages/ProfilePage.astro +78 -0
- package/site/profile/CastHost.ts +32 -0
- package/site/profile/CastView.ts +96 -0
- package/site/profile/assertRenderable.ts +151 -0
- package/site/profile/clientCast.ts +117 -0
- package/site/profile/index.ts +8 -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 +155 -0
- package/site/scripts/palette.ts +313 -0
- package/site/scripts/replay.ts +252 -0
- package/site/scripts/terminal.ts +559 -0
- package/site/styles/global.css +508 -0
- package/virtual.d.ts +15 -0
- package/writing/WritingSnapshot.ts +20 -0
- package/writing/fetchWriting.ts +69 -0
- package/writing/parse.ts +72 -0
- package/writing/writingSnapshotSchema.ts +27 -0
|
@@ -0,0 +1,252 @@
|
|
|
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
|
+
// Career replay: renders the projection produced by folding career events up to a position.
|
|
5
|
+
import { readClientCast } from '../profile/clientCast';
|
|
6
|
+
import { project, formatMonth, yearsBetween, alsoText, type CareerProjection } from '../lib/career';
|
|
7
|
+
import { motionReduced } from '../lib/motion';
|
|
8
|
+
|
|
9
|
+
const STEP_MS = 1500;
|
|
10
|
+
const pad = (n: number) => String(n).padStart(2, '0');
|
|
11
|
+
|
|
12
|
+
export function mountReplay(root: HTMLElement): () => void {
|
|
13
|
+
const cleanups: (() => void)[] = [];
|
|
14
|
+
const careerEvents = readClientCast()?.careerEvents ?? [];
|
|
15
|
+
const total = careerEvents.length;
|
|
16
|
+
const q = <T extends Element = HTMLElement>(sel: string) => root.querySelector<T>(sel);
|
|
17
|
+
const qa = <T extends Element = HTMLElement>(sel: string) => [...root.querySelectorAll<T>(sel)];
|
|
18
|
+
const out = (name: string) => qa(`[data-out="${name}"]`);
|
|
19
|
+
const setText = (name: string, text: string) => out(name).forEach((el) => (el.textContent = text));
|
|
20
|
+
|
|
21
|
+
const items = qa<HTMLLIElement>('[data-stream] > li');
|
|
22
|
+
const scrubber = q<HTMLInputElement>('[data-scrubber]');
|
|
23
|
+
const playButtons = qa<HTMLButtonElement>('[data-action="play"]');
|
|
24
|
+
const live = q('[data-live]');
|
|
25
|
+
const skillList = q<HTMLUListElement>('[data-out="skills"]');
|
|
26
|
+
const affList = q<HTMLUListElement>('[data-out="affiliations"]');
|
|
27
|
+
|
|
28
|
+
qa('[data-controls], [data-hud], [data-controls-hint]').forEach((el) => el.removeAttribute('hidden'));
|
|
29
|
+
|
|
30
|
+
let position = total;
|
|
31
|
+
let playing = false;
|
|
32
|
+
let timer = 0;
|
|
33
|
+
let liveTimer = 0;
|
|
34
|
+
let scrollLockUntil = 0;
|
|
35
|
+
|
|
36
|
+
/** Keyed list rendering so chips animate in only when they are genuinely new. */
|
|
37
|
+
function renderChips(list: HTMLUListElement | null, keys: string[], fresh: Set<string>, extraClass = '') {
|
|
38
|
+
if (!list) return;
|
|
39
|
+
const existing = new Map<string, HTMLLIElement>();
|
|
40
|
+
list.querySelectorAll<HTMLLIElement>(':scope > li').forEach((li) => existing.set(li.dataset['key'] ?? '', li));
|
|
41
|
+
const next: HTMLLIElement[] = keys.map((key) => {
|
|
42
|
+
let li = existing.get(key);
|
|
43
|
+
if (!li) {
|
|
44
|
+
li = document.createElement('li');
|
|
45
|
+
li.className = `chip ${extraClass}`.trim();
|
|
46
|
+
li.dataset['key'] = key;
|
|
47
|
+
li.textContent = key;
|
|
48
|
+
}
|
|
49
|
+
li.classList.toggle('is-new', fresh.has(key));
|
|
50
|
+
existing.delete(key);
|
|
51
|
+
return li;
|
|
52
|
+
});
|
|
53
|
+
existing.forEach((li) => li.remove());
|
|
54
|
+
next.forEach((li, i) => {
|
|
55
|
+
if (list.children[i] !== li) list.insertBefore(li, list.children[i] ?? null);
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function announce(state: CareerProjection) {
|
|
60
|
+
window.clearTimeout(liveTimer);
|
|
61
|
+
liveTimer = window.setTimeout(() => {
|
|
62
|
+
if (!live) return;
|
|
63
|
+
const e = state.lastEvent;
|
|
64
|
+
live.textContent = e
|
|
65
|
+
? `Stream position ${state.position} of ${total}. ${e.type}: ${e.title}. ` +
|
|
66
|
+
(state.currentRoles.length ? `Current roles: ${state.currentRoles.map((r) => `${r.title} at ${r.org}`).join(' and ')}. ` : '') +
|
|
67
|
+
`${state.skills.length} skills, ${state.affiliations.length} organizations.`
|
|
68
|
+
: `Stream position 0 of ${total}. Nothing replayed yet.`;
|
|
69
|
+
}, 700);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function render(prev: number) {
|
|
73
|
+
// The projection is always recomputed by replaying the stream from the start.
|
|
74
|
+
const state = project(careerEvents, position);
|
|
75
|
+
const forward = position > prev;
|
|
76
|
+
const e = state.lastEvent;
|
|
77
|
+
|
|
78
|
+
setText('position', pad(position));
|
|
79
|
+
setText('positionShort', `${position}/${total}`);
|
|
80
|
+
out('progress').forEach((el) => (el.style.transform = `scaleX(${position / total})`));
|
|
81
|
+
out('hudProgress').forEach((el) => (el.style.transform = `scaleX(${position / total})`));
|
|
82
|
+
setText('lastSeq', e ? `#${pad(e.seq)}` : '#00');
|
|
83
|
+
setText('lastType', e ? e.type : 'StreamOpened');
|
|
84
|
+
setText('lastDate', e ? formatMonth(e.date) : '');
|
|
85
|
+
setText('role', state.currentRole?.title ?? '—');
|
|
86
|
+
setText(
|
|
87
|
+
'roleOrg',
|
|
88
|
+
state.currentRole ? `${state.currentRole.org} · since ${formatMonth(state.currentRole.from)}` : 'No role yet',
|
|
89
|
+
);
|
|
90
|
+
setText('roleAlso', alsoText(state));
|
|
91
|
+
setText('years', yearsBetween(state.shippingSince, state.clock).toFixed(1));
|
|
92
|
+
setText('affCount', String(state.affiliations.length));
|
|
93
|
+
setText('skillCount', String(state.skills.length));
|
|
94
|
+
setText('hudPos', `#${pad(position)}/${total}`);
|
|
95
|
+
setText('hudType', e ? e.type : 'StreamOpened');
|
|
96
|
+
setText(
|
|
97
|
+
'hudRole',
|
|
98
|
+
`${state.currentRole ? `${state.currentRole.title} · ${state.currentRole.org}` : 'No role yet'} · ${state.skills.length} skills`,
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
const animate = forward && position - prev === 1 && !motionReduced();
|
|
102
|
+
renderChips(skillList, state.skills, new Set(animate ? state.newSkills : []));
|
|
103
|
+
if (skillList && forward && state.newSkills.length) {
|
|
104
|
+
skillList.scrollTo({ top: skillList.scrollHeight, behavior: motionReduced() ? 'auto' : 'smooth' });
|
|
105
|
+
}
|
|
106
|
+
const newAff =
|
|
107
|
+
animate && e?.org && state.affiliations.at(-1) === e.org && !project(careerEvents, prev).affiliations.includes(e.org)
|
|
108
|
+
? new Set([e.org])
|
|
109
|
+
: new Set<string>();
|
|
110
|
+
renderChips(affList, state.affiliations, newAff, 'chip-org');
|
|
111
|
+
|
|
112
|
+
items.forEach((li) => {
|
|
113
|
+
const seq = Number(li.dataset['seq']);
|
|
114
|
+
li.dataset['state'] = seq < position ? 'applied' : seq === position ? 'current' : 'future';
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
if (scrubber) {
|
|
118
|
+
scrubber.value = String(position);
|
|
119
|
+
scrubber.style.setProperty('--fill', `${(position / total) * 100}%`);
|
|
120
|
+
scrubber.setAttribute('aria-valuetext', e ? `Position ${position} of ${total}: ${e.type}` : `Position 0 of ${total}`);
|
|
121
|
+
}
|
|
122
|
+
announce(state);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function setPosition(next: number) {
|
|
126
|
+
const clamped = Math.max(0, Math.min(total, next));
|
|
127
|
+
if (clamped === position) return;
|
|
128
|
+
const prev = position;
|
|
129
|
+
position = clamped;
|
|
130
|
+
render(prev);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const sectionVisible = () => {
|
|
134
|
+
const r = root.getBoundingClientRect();
|
|
135
|
+
return r.bottom > 0 && r.top < window.innerHeight;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
function follow() {
|
|
139
|
+
const li = items[position - 1];
|
|
140
|
+
if (!li || !sectionVisible()) return;
|
|
141
|
+
scrollLockUntil = performance.now() + 900;
|
|
142
|
+
li.scrollIntoView({ block: 'center', behavior: motionReduced() ? 'auto' : 'smooth' });
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function setPlaying(next: boolean) {
|
|
146
|
+
playing = next;
|
|
147
|
+
window.clearInterval(timer);
|
|
148
|
+
playButtons.forEach((b) => {
|
|
149
|
+
b.setAttribute('aria-pressed', String(next));
|
|
150
|
+
b.setAttribute('aria-label', next ? 'Pause replay' : 'Play replay');
|
|
151
|
+
});
|
|
152
|
+
if (!next) return;
|
|
153
|
+
if (position >= total) setPosition(0);
|
|
154
|
+
timer = window.setInterval(() => {
|
|
155
|
+
if (position >= total) return setPlaying(false);
|
|
156
|
+
setPosition(position + 1);
|
|
157
|
+
follow();
|
|
158
|
+
}, STEP_MS);
|
|
159
|
+
setPosition(position + 1);
|
|
160
|
+
follow();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// --- Controls ------------------------------------------------------------
|
|
164
|
+
root.addEventListener('click', (ev) => {
|
|
165
|
+
const target = ev.target as Element;
|
|
166
|
+
const action = target.closest<HTMLElement>('[data-action]')?.dataset['action'];
|
|
167
|
+
if (action === 'play') return setPlaying(!playing);
|
|
168
|
+
if (action === 'prev' || action === 'next') {
|
|
169
|
+
setPlaying(false);
|
|
170
|
+
return setPosition(position + (action === 'next' ? 1 : -1));
|
|
171
|
+
}
|
|
172
|
+
const jump = target.closest<HTMLElement>('[data-jump]');
|
|
173
|
+
if (jump) {
|
|
174
|
+
setPlaying(false);
|
|
175
|
+
setPosition(Number(jump.dataset['jump']));
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
scrubber?.addEventListener('input', () => {
|
|
180
|
+
setPlaying(false);
|
|
181
|
+
setPosition(Number(scrubber.value));
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
root.addEventListener('keydown', (ev) => {
|
|
185
|
+
if ((ev.target as Element).matches('input[type="range"]')) return;
|
|
186
|
+
const keys: Record<string, number> = { ArrowLeft: position - 1, ArrowRight: position + 1, Home: 0, End: total };
|
|
187
|
+
const next = keys[ev.key];
|
|
188
|
+
if (next === undefined || ev.altKey || ev.ctrlKey || ev.metaKey) return;
|
|
189
|
+
ev.preventDefault();
|
|
190
|
+
setPlaying(false);
|
|
191
|
+
setPosition(next);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// Any manual scrolling intent hands control back to the reader.
|
|
195
|
+
const userScroll = () => playing && performance.now() > scrollLockUntil && setPlaying(false);
|
|
196
|
+
window.addEventListener('wheel', userScroll, { passive: true });
|
|
197
|
+
window.addEventListener('touchstart', userScroll, { passive: true });
|
|
198
|
+
cleanups.push(() => {
|
|
199
|
+
window.removeEventListener('wheel', userScroll);
|
|
200
|
+
window.removeEventListener('touchstart', userScroll);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// --- Scroll-driven projection ------------------------------------------
|
|
204
|
+
if ('IntersectionObserver' in window) {
|
|
205
|
+
const io = new IntersectionObserver(
|
|
206
|
+
(entries) => {
|
|
207
|
+
if (playing || performance.now() < scrollLockUntil) return;
|
|
208
|
+
for (const entry of entries) {
|
|
209
|
+
if (entry.isIntersecting) setPosition(Number((entry.target as HTMLElement).dataset['seq']));
|
|
210
|
+
}
|
|
211
|
+
},
|
|
212
|
+
{ rootMargin: '-45% 0px -50% 0px' },
|
|
213
|
+
);
|
|
214
|
+
items.forEach((li) => io.observe(li));
|
|
215
|
+
cleanups.push(() => io.disconnect());
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Palette / external trigger: scroll here and replay from the start.
|
|
219
|
+
const onReplay = () => {
|
|
220
|
+
setPlaying(false);
|
|
221
|
+
setPosition(0);
|
|
222
|
+
scrollLockUntil = performance.now() + 1200;
|
|
223
|
+
root.scrollIntoView({ behavior: motionReduced() ? 'auto' : 'smooth', block: 'start' });
|
|
224
|
+
window.setTimeout(() => setPlaying(true), motionReduced() ? 50 : 900);
|
|
225
|
+
};
|
|
226
|
+
document.addEventListener('replay:start', onReplay);
|
|
227
|
+
cleanups.push(() => document.removeEventListener('replay:start', onReplay));
|
|
228
|
+
|
|
229
|
+
// Start from an empty stream unless the reader is already past it.
|
|
230
|
+
const r = root.getBoundingClientRect();
|
|
231
|
+
if (r.top > window.innerHeight * 0.3) {
|
|
232
|
+
const prev = position;
|
|
233
|
+
position = 0;
|
|
234
|
+
render(prev);
|
|
235
|
+
} else {
|
|
236
|
+
render(position);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Arriving from another page via the palette: /?replay#career
|
|
240
|
+
const url = new URL(location.href);
|
|
241
|
+
if (url.searchParams.has('replay')) {
|
|
242
|
+
url.searchParams.delete('replay');
|
|
243
|
+
history.replaceState(null, '', url.pathname + url.search + url.hash);
|
|
244
|
+
window.setTimeout(() => document.dispatchEvent(new CustomEvent('replay:start')), 300);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return () => {
|
|
248
|
+
setPlaying(false);
|
|
249
|
+
window.clearTimeout(liveTimer);
|
|
250
|
+
cleanups.forEach((fn) => fn());
|
|
251
|
+
};
|
|
252
|
+
}
|