atris 3.25.2 → 3.27.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.
@@ -0,0 +1,95 @@
1
+ // Memory view — another way to look at memory updates. Reads the workspace's
2
+ // compounding memory (lessons.md, learnings.jsonl, task projection) and builds a
3
+ // content spec rendered as a beautiful HTML page by lib/html-render.
4
+ //
5
+ // Pure-ish: file reads in, a { theme, brand, slides } spec out. Anti-slop by
6
+ // construction (the renderer sanitizes, so em dashes in the raw lessons file
7
+ // never reach the page).
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+
12
+ // "- **[2026-06-18] some-id** — pass — text..." -> { date, id, status, text }
13
+ function parseLessons(text) {
14
+ const out = [];
15
+ for (const line of String(text || '').split('\n')) {
16
+ const m = line.match(/^-\s*\*\*\[([^\]]+)\]\s*([^*]+?)\*\*\s*[—:-]*\s*(pass|fail)?\s*[—:-]*\s*(.*)$/i);
17
+ if (!m) continue;
18
+ out.push({ date: m[1].trim(), id: m[2].trim(), status: (m[3] || '').toLowerCase(), text: m[4].trim() });
19
+ }
20
+ return out; // append-only file: oldest first
21
+ }
22
+
23
+ function parseLearnings(text) {
24
+ const out = [];
25
+ for (const line of String(text || '').split('\n')) {
26
+ if (!line.trim()) continue;
27
+ try { const j = JSON.parse(line); out.push({ ts: j.ts, type: j.type, key: j.key, insight: j.insight }); } catch {}
28
+ }
29
+ return out;
30
+ }
31
+
32
+ function readFileSafe(p) { try { return fs.readFileSync(p, 'utf8'); } catch { return ''; } }
33
+
34
+ function readTaskCounts(root) {
35
+ try {
36
+ const j = JSON.parse(readFileSafe(path.join(root, '.atris', 'state', 'tasks.projection.json')));
37
+ const tasks = Array.isArray(j.tasks) ? j.tasks : [];
38
+ const by = (s) => tasks.filter((t) => (t.status || '').toLowerCase() === s).length;
39
+ return { total: tasks.length, done: by('done') + by('accepted'), active: by('active') + by('in_progress'), review: by('review') + by('ready') };
40
+ } catch { return null; }
41
+ }
42
+
43
+ function gatherMemory(root = process.cwd()) {
44
+ const lessons = parseLessons(readFileSafe(path.join(root, 'atris', 'lessons.md')));
45
+ const learnings = parseLearnings(readFileSafe(path.join(root, 'atris', 'learnings.jsonl')));
46
+ const tasks = readTaskCounts(root);
47
+ return { lessons, learnings, tasks };
48
+ }
49
+
50
+ function clip(s, n) { s = String(s || ''); return s.length > n ? s.slice(0, n - 1).trimEnd() + '…' : s; }
51
+ function titleize(id) { return String(id || '').replace(/[-_]+/g, ' ').trim(); }
52
+ function plural(n, word) { return `${n} ${word}${n === 1 ? '' : 's'}`; }
53
+
54
+ function buildMemorySpec(root = process.cwd(), opts = {}) {
55
+ const theme = opts.theme || 'atris';
56
+ const brand = { name: opts.brand || 'Atris', accent: '.' };
57
+ const { lessons, learnings, tasks } = gatherMemory(root);
58
+ const recent = lessons.slice(-8).reverse(); // newest first
59
+
60
+ const slides = [];
61
+ slides.push({
62
+ type: 'title',
63
+ headline: 'What the workspace **learned**',
64
+ sub: `${plural(lessons.length, 'lesson')} and ${plural(learnings.length, 'note')} compounded into memory${tasks ? `, ${plural(tasks.done, 'task')} done` : ''}.`,
65
+ });
66
+ slides.push({ type: 'bignumber', number: String(lessons.length), label: 'lessons compounded into the workspace' });
67
+
68
+ if (recent.length) {
69
+ slides.push({
70
+ type: 'columns', heading: 'Most recent lessons',
71
+ columns: recent.slice(0, 3).map((l) => ({ h: titleize(l.id), b: clip(l.text, 150) })),
72
+ });
73
+ slides.push({
74
+ type: 'panel', heading: 'Memory updates', sub: 'Lessons and notes, newest first.',
75
+ panel: {
76
+ header: { title: 'Recent updates', meta: `${recent.length} shown` },
77
+ rows: recent.map((l, i) => ({
78
+ title: titleize(l.id), sub: l.date,
79
+ value: l.status || 'note', sev: l.status === 'fail' ? 0 : (l.status === 'pass' ? 2 : 1),
80
+ active: i === 0,
81
+ })),
82
+ },
83
+ });
84
+ }
85
+ if (learnings.length) {
86
+ slides.push({
87
+ type: 'columns', heading: 'Notes from the field',
88
+ columns: learnings.slice(-3).reverse().map((n) => ({ h: titleize(n.key || n.type || 'note'), b: clip(n.insight, 150) })),
89
+ });
90
+ }
91
+ slides.push({ type: 'close', tagline: 'Memory lives in the filesystem, not the model.', footer: `${brand.name} workspace memory` });
92
+ return { theme, brand, slides };
93
+ }
94
+
95
+ module.exports = { buildMemorySpec, gatherMemory, parseLessons, parseLearnings, readTaskCounts };
@@ -0,0 +1,362 @@
1
+ 'use strict';
2
+
3
+ // Alive onboarding: the engine behind `atris moves`.
4
+ //
5
+ // It reads the workspace for the highest-leverage next moves (the goal in
6
+ // ROADMAP.md, work already in flight, fresh inbox ideas), ranks them, and lets
7
+ // the human approve, kill, or skip. Approved moves are seeded into today's
8
+ // inbox, which the loop's hasWork() already reads, so onboarding feeds the
9
+ // loop without touching the autonomous core.
10
+
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+
14
+ function safeRead(p) {
15
+ try { return fs.readFileSync(p, 'utf8'); } catch { return ''; }
16
+ }
17
+
18
+ // Normalize a title for dedup and suppression, the same way moveId does, so a
19
+ // move matches itself across case and whitespace.
20
+ function norm(title) {
21
+ return String(title == null ? '' : title).trim().toLowerCase();
22
+ }
23
+
24
+ // Short, stable id so a kill on Tuesday still suppresses the same move on
25
+ // Wednesday. Pure function of (source, title).
26
+ function moveId(source, title) {
27
+ const s = `${source}:${String(title).trim().toLowerCase()}`;
28
+ let h = 2166136261;
29
+ for (let i = 0; i < s.length; i++) {
30
+ h ^= s.charCodeAt(i);
31
+ h = Math.imul(h, 16777619);
32
+ }
33
+ return `m_${(h >>> 0).toString(36)}`;
34
+ }
35
+
36
+ const WEIGHT = { roadmap: 100, task: 60, inbox: 40 };
37
+
38
+ // One section-boundary shape for every reader and writer: tolerate a header
39
+ // suffix (e.g. "(priority)"), stop at a sibling/parent heading (## or #) or a
40
+ // malformed "##Next", but NOT at a child ### inside the section. Keeping a single
41
+ // definition is the whole point: divergent boundaries are how the gate and the
42
+ // picker drift apart.
43
+ const SECTION_TAIL = '\\b[^\\n]*\\r?\\n([\\s\\S]*?)(?=\\r?\\n##[^#]|\\r?\\n# |$)';
44
+ const OPEN_ITEMS_RE = new RegExp(`##\\s+Open loop items${SECTION_TAIL}`, 'i');
45
+ const INBOX_RE = new RegExp(`##\\s+Inbox${SECTION_TAIL}`, 'i');
46
+
47
+ // Locate a section's body and its character span, so a writer can splice an edit
48
+ // back into exactly the region a reader parsed.
49
+ function findSection(text, re) {
50
+ const m = text.match(re);
51
+ if (!m) return null;
52
+ const bodyStart = m.index + m[0].length - m[1].length;
53
+ return { body: m[1], start: bodyStart, end: bodyStart + m[1].length };
54
+ }
55
+
56
+ // Clean inbox titles out of a journal's ## Inbox section. The one inbox parser,
57
+ // shared by latestInboxItems, todayInboxItems, and the run.js work gate.
58
+ function parseInboxTitles(text) {
59
+ const m = (text || '').match(INBOX_RE);
60
+ if (!m) return [];
61
+ return m[1]
62
+ .split(/\r?\n/)
63
+ .map((l) => l.trim())
64
+ .filter((l) => l.startsWith('- ') && l.length > 2)
65
+ .map((l) => l.replace(/^-\s*\*\*[IC]?\d*:?\*\*\s*/, '').replace(/^-\s*\*\*/, '').replace(/\*\*$/, '').replace(/^-\s*/, '').trim())
66
+ .filter(Boolean);
67
+ }
68
+
69
+ function readRoadmapOpenItems(root) {
70
+ const text = safeRead(path.join(root, 'ROADMAP.md'));
71
+ if (!text) return [];
72
+ const section = findSection(text, OPEN_ITEMS_RE);
73
+ if (!section) return [];
74
+ return section.body
75
+ .split(/\r?\n/)
76
+ .map((l) => l.trim())
77
+ .filter((l) => /^- \[ \]/.test(l))
78
+ .map((l) => l.replace(/^- \[ \]\s*/, '').trim())
79
+ .filter(Boolean)
80
+ .map((title) => ({
81
+ title,
82
+ why: 'open item in ROADMAP.md, the goal the loop pursues',
83
+ source: 'roadmap',
84
+ weight: WEIGHT.roadmap,
85
+ }));
86
+ }
87
+
88
+ function readActiveTasks(root) {
89
+ const text = safeRead(path.join(root, '.atris', 'state', 'tasks.projection.json'));
90
+ if (!text) return [];
91
+ let proj;
92
+ try { proj = JSON.parse(text); } catch { return []; }
93
+ const tasks = Array.isArray(proj && proj.tasks) ? proj.tasks : [];
94
+ return tasks
95
+ .filter((t) => t && t.title && ['open', 'claimed'].includes(String(t.status || '').toLowerCase()))
96
+ .map((t) => ({
97
+ title: String(t.title).trim(),
98
+ why: `task in flight (${t.status}${t.claimed_by ? `, ${t.claimed_by}` : ''})`,
99
+ source: 'task',
100
+ ref: t.display_id || t.id || null,
101
+ weight: WEIGHT.task,
102
+ }));
103
+ }
104
+
105
+ function inboxItemsFrom(text) {
106
+ return parseInboxTitles(text).map((title) => ({ title, why: 'fresh idea in today\'s inbox', source: 'inbox', weight: WEIGHT.inbox }));
107
+ }
108
+
109
+ // Most recent journal under atris/logs/YYYY/, parsed for ## Inbox items. Used to
110
+ // SURFACE ideas in `atris moves`.
111
+ function latestInboxItems(root) {
112
+ const logsDir = path.join(root, 'atris', 'logs');
113
+ let years;
114
+ try { years = fs.readdirSync(logsDir).filter((d) => /^\d{4}$/.test(d)).sort(); } catch { return []; }
115
+ if (!years.length) return [];
116
+ const yearDir = path.join(logsDir, years[years.length - 1]);
117
+ let files;
118
+ try { files = fs.readdirSync(yearDir).filter((f) => /^\d{4}-\d{2}-\d{2}\.md$/.test(f)).sort(); } catch { return []; }
119
+ if (!files.length) return [];
120
+ return inboxItemsFrom(safeRead(path.join(yearDir, files[files.length - 1])));
121
+ }
122
+
123
+ // TODAY's journal inbox only. The loop's work gate and the seed-suppression must
124
+ // read the SAME file the seeder writes (today's), or a prior-day duplicate makes
125
+ // them disagree. Returns [] if today's file is absent.
126
+ function todayInboxItems(root) {
127
+ const { file } = todayLogFile(root);
128
+ if (!fs.existsSync(file)) return [];
129
+ return inboxItemsFrom(safeRead(file));
130
+ }
131
+
132
+ function gatherCandidates(root = process.cwd()) {
133
+ return [
134
+ ...readRoadmapOpenItems(root),
135
+ ...readActiveTasks(root),
136
+ ...latestInboxItems(root),
137
+ ];
138
+ }
139
+
140
+ // Pure ranking: drop killed/approved, sort by weight, then dedupe by title
141
+ // (keeping the highest-weight copy), take `limit`.
142
+ //
143
+ // Suppression: the exact move the operator acted on is dropped by id. Titles are
144
+ // only suppressed for IDEAS (roadmap/inbox), never for a real `task`, so killing
145
+ // or approving an idea can't hide a genuine in-flight task that happens to share
146
+ // the title.
147
+ function pickNextMoves(candidates, { limit = 3, killedIds = [], killedTitles = [], approvedIds = [], approvedTitles = [] } = {}) {
148
+ const blockedIds = new Set([...killedIds, ...approvedIds]);
149
+ const blockedTitles = new Set([...killedTitles, ...approvedTitles].map(norm));
150
+ const seen = new Set();
151
+ return candidates
152
+ .map((c) => ({ ...c, id: moveId(c.source, c.title), _key: norm(c.title) }))
153
+ .filter((c) => {
154
+ if (!c._key) return false;
155
+ if (blockedIds.has(c.id)) return false;
156
+ if (c.source !== 'task' && blockedTitles.has(c._key)) return false;
157
+ return true;
158
+ })
159
+ .sort((a, b) => (b.weight || 0) - (a.weight || 0))
160
+ .filter((c) => {
161
+ if (seen.has(c._key)) return false;
162
+ seen.add(c._key);
163
+ return true;
164
+ })
165
+ .map(({ _key, ...c }) => c)
166
+ .slice(0, limit);
167
+ }
168
+
169
+ const DECISIONS_FILE = ['.atris', 'state', 'moves.decisions.jsonl'];
170
+
171
+ function decisionsPath(root) {
172
+ return path.join(root, ...DECISIONS_FILE);
173
+ }
174
+
175
+ function readDecisions(root = process.cwd()) {
176
+ const text = safeRead(decisionsPath(root));
177
+ const rows = text.split(/\r?\n/).map((l) => l.trim()).filter(Boolean)
178
+ .map((l) => { try { return JSON.parse(l); } catch { return null; } })
179
+ .filter(Boolean);
180
+ const idsFor = (decision) => rows.filter((r) => r.decision === decision).map((r) => r.id);
181
+ const titlesFor = (decision) => rows.filter((r) => r.decision === decision).map((r) => norm(r.title));
182
+ return {
183
+ killedIds: idsFor('kill'),
184
+ killedTitles: titlesFor('kill'),
185
+ approvedIds: idsFor('approve'),
186
+ approvedTitles: titlesFor('approve'),
187
+ };
188
+ }
189
+
190
+ function recordDecision(root, move, decision, stamp) {
191
+ const p = decisionsPath(root);
192
+ fs.mkdirSync(path.dirname(p), { recursive: true });
193
+ const row = { id: move.id, title: move.title, source: move.source, decision, at: stamp || null };
194
+ fs.appendFileSync(p, `${JSON.stringify(row)}\n`, 'utf8');
195
+ return row;
196
+ }
197
+
198
+ function todayLogFile(root, date = new Date()) {
199
+ const y = date.getFullYear();
200
+ const m = String(date.getMonth() + 1).padStart(2, '0');
201
+ const d = String(date.getDate()).padStart(2, '0');
202
+ const dateFormatted = `${y}-${m}-${d}`;
203
+ return { file: path.join(root, 'atris', 'logs', String(y), `${dateFormatted}.md`), dateFormatted };
204
+ }
205
+
206
+ // The canonical day-journal sections every reader expects (status, analytics,
207
+ // section parsers). Mirrors lib/journal.js createLogFile, minus the em dash in
208
+ // its title, so a journal first created by the idle-seed path is not malformed.
209
+ function canonicalJournal(dateFormatted) {
210
+ return `# Log ${dateFormatted}\n\n## Handoff\n\n---\n\n## Completed ✅\n\n---\n\n## In Progress 🔄\n\n---\n\n## Backlog\n\n---\n\n## Notes\n\n---\n\n## Inbox\n\n`;
211
+ }
212
+
213
+ // Seed an approved move into today's inbox so the loop's hasWork() finds it.
214
+ // Idempotent by title: if the same title is already in today's inbox, it is not
215
+ // appended again (so a retry or a concurrent cycle cannot duplicate it).
216
+ function seedInboxFromMove(root, move, date) {
217
+ const { file, dateFormatted } = todayLogFile(root, date);
218
+ fs.mkdirSync(path.dirname(file), { recursive: true });
219
+ let content = safeRead(file);
220
+ if (!content) {
221
+ content = canonicalJournal(dateFormatted);
222
+ }
223
+ if (!/##\s+Inbox/i.test(content)) {
224
+ content = content.replace(/^(#.*\n)/, `$1\n## Inbox\n`);
225
+ if (!/##\s+Inbox/i.test(content)) content += `\n## Inbox\n`;
226
+ }
227
+ const target = norm(move.title);
228
+ if (parseInboxTitles(content).some((t) => norm(t) === target)) {
229
+ return { file, line: null, nextId: null, alreadyPresent: true };
230
+ }
231
+ const existingIds = (content.match(/-\s*\*\*I(\d+):/g) || []).map((m2) => parseInt(m2.match(/I(\d+)/)[1], 10));
232
+ const nextId = existingIds.length ? Math.max(...existingIds) + 1 : 1;
233
+ const line = `- **I${nextId}:** ${move.title}`;
234
+ // Insert right after the Inbox header line only (do not consume blank lines or
235
+ // following sections). Use a function replacer so a title containing $ tokens
236
+ // ($1, $&, $$) is inserted literally, not interpreted as a replacement pattern.
237
+ content = content.replace(/(##\s+Inbox[^\n]*\r?\n)/i, (m) => `${m}${line}\n`);
238
+ fs.writeFileSync(file, content, 'utf8');
239
+ return { file, line, nextId };
240
+ }
241
+
242
+ // Claim an open ROADMAP item for the loop by flipping its `- [ ]` to `- [~]`
243
+ // (claimed, in flight) WITHIN the "## Open loop items" section only. This is the
244
+ // single source of truth for "the loop took this on": readRoadmapOpenItems only
245
+ // returns `- [ ]`, so a claimed item is excluded, and the work gate and the seed
246
+ // picker stay in agreement. Scoped to the section so a same-titled `- [ ]` in
247
+ // another section (Big jobs, an example block) is never flipped by mistake.
248
+ // `- [~]` is distinct from human `- [x]` (done) so a reader is not misled.
249
+ function claimRoadmapItem(root, title) {
250
+ const p = path.join(root, 'ROADMAP.md');
251
+ const text = safeRead(p);
252
+ if (!text) return false;
253
+ const section = findSection(text, OPEN_ITEMS_RE);
254
+ if (!section) return false;
255
+ const target = norm(title);
256
+ let changed = false;
257
+ const newBody = section.body.split(/\r?\n/).map((line) => {
258
+ if (changed) return line;
259
+ const m = line.match(/^(\s*)- \[ \]\s*(.+?)\s*$/);
260
+ if (m && norm(m[2]) === target) {
261
+ changed = true;
262
+ return `${m[1]}- [~] ${m[2].trim()}`;
263
+ }
264
+ return line;
265
+ }).join('\n');
266
+ if (!changed) return false;
267
+ const out = text.slice(0, section.start) + newBody + text.slice(section.end);
268
+ fs.writeFileSync(p, out, 'utf8');
269
+ return true;
270
+ }
271
+
272
+ // Add one bounded item to the "## Open loop items" section so the loop (and
273
+ // `atris moves`) will pursue it: messy input straight into the working queue,
274
+ // no markdown editing. Creates ROADMAP.md and the section if missing. Dedups
275
+ // against existing OPEN items only (a done/claimed one can be re-added).
276
+ function addRoadmapItem(root, text) {
277
+ // Collapse any whitespace/newlines to single spaces (a multi-line title would
278
+ // break the markdown line) and strip a leading bullet/checkbox the user may
279
+ // have pasted, so the item is exactly one well-formed `- [ ]` line.
280
+ const title = String(text || '').replace(/\s+/g, ' ').trim().replace(/^- \[[ x~]\]\s*/, '').replace(/^- /, '').trim();
281
+ if (!title) return { added: false, reason: 'empty', title: null };
282
+ const p = path.join(root, 'ROADMAP.md');
283
+ let content = safeRead(p);
284
+ if (!content) content = '# Roadmap\n\n## Open loop items\n\n';
285
+ let section = findSection(content, OPEN_ITEMS_RE);
286
+ if (!section) {
287
+ if (!content.endsWith('\n')) content += '\n';
288
+ content += '\n## Open loop items\n\n';
289
+ section = findSection(content, OPEN_ITEMS_RE);
290
+ }
291
+ const openTitles = section.body.split(/\r?\n/)
292
+ .map((l) => l.trim())
293
+ .filter((l) => /^- \[ \]/.test(l))
294
+ .map((l) => norm(l.replace(/^- \[ \]\s*/, '')));
295
+ if (openTitles.includes(norm(title))) return { added: false, reason: 'already an open item', title };
296
+ // Insert at the top of the section body so the loop pulls it next.
297
+ const out = `${content.slice(0, section.start)}\n- [ ] ${title}\n${content.slice(section.start)}`;
298
+ fs.writeFileSync(p, out, 'utf8');
299
+ return { added: true, title };
300
+ }
301
+
302
+ // Group the Open loop items by state: queued `- [ ]`, in-flight `- [~]` (claimed
303
+ // by the loop), done `- [x]`. The evidence for a loop report.
304
+ function roadmapItemsByState(root) {
305
+ const out = { open: [], claimed: [], done: [] };
306
+ const text = safeRead(path.join(root, 'ROADMAP.md'));
307
+ if (!text) return out;
308
+ const section = findSection(text, OPEN_ITEMS_RE);
309
+ if (!section) return out;
310
+ for (const raw of section.body.split(/\r?\n/)) {
311
+ const l = raw.trim();
312
+ let m;
313
+ if ((m = l.match(/^- \[ \]\s*(.+)$/))) out.open.push(m[1].trim());
314
+ else if ((m = l.match(/^- \[~\]\s*(.+)$/))) out.claimed.push(m[1].trim());
315
+ else if ((m = l.match(/^- \[x\]\s*(.+)$/))) out.done.push(m[1].trim());
316
+ }
317
+ return out;
318
+ }
319
+
320
+ // The top open ROADMAP item the loop has not already handled. Claimed items are
321
+ // marked `- [~]` (so readRoadmapOpenItems drops them); this also skips anything
322
+ // already in TODAY's inbox or killed/approved, so an idle loop advances to the
323
+ // next item each cycle. todayInboxItems (not latestInboxItems) so the picker, the
324
+ // work gate, and the seeder all read the same file. Root-explicit and pure of
325
+ // cwd, so it is testable without a live runner.
326
+ function pickRoadmapSeed(root = process.cwd()) {
327
+ const items = readRoadmapOpenItems(root);
328
+ if (!items.length) return null;
329
+ const inbox = todayInboxItems(root).map((i) => norm(i.title));
330
+ const { killedTitles, approvedTitles } = readDecisions(root);
331
+ const blocked = new Set([...inbox, ...killedTitles, ...approvedTitles]);
332
+ return items.find((it) => !blocked.has(norm(it.title))) || null;
333
+ }
334
+
335
+ // Shared "3 next moves" recipe used by both `atris moves` and `atris activate`,
336
+ // so the ranking inputs live in one place.
337
+ function nextMoves(root = process.cwd(), limit = 3) {
338
+ const { killedIds, killedTitles, approvedIds, approvedTitles } = readDecisions(root);
339
+ return pickNextMoves(gatherCandidates(root), { limit, killedIds, killedTitles, approvedIds, approvedTitles });
340
+ }
341
+
342
+ module.exports = {
343
+ moveId,
344
+ norm,
345
+ WEIGHT,
346
+ parseInboxTitles,
347
+ readRoadmapOpenItems,
348
+ readActiveTasks,
349
+ latestInboxItems,
350
+ todayInboxItems,
351
+ gatherCandidates,
352
+ pickNextMoves,
353
+ nextMoves,
354
+ readDecisions,
355
+ recordDecision,
356
+ seedInboxFromMove,
357
+ pickRoadmapSeed,
358
+ claimRoadmapItem,
359
+ addRoadmapItem,
360
+ roadmapItemsByState,
361
+ todayLogFile,
362
+ };
package/lib/reel.js ADDED
@@ -0,0 +1,52 @@
1
+ // atris reel — a card, animated. A reel is the card from lib/card.js rendered at
2
+ // progress t in [0,1]: each element fades and rises in on a staggered schedule.
3
+ // Pure: (spec, t) -> HTML for that single frame. commands/reel.js screenshots the
4
+ // frames (same Chrome as card) and ffmpeg-encodes them. No new dependency.
5
+
6
+ const { buildCard } = require('./card');
7
+
8
+ const clamp01 = (x) => Math.max(0, Math.min(1, x));
9
+ const easeOutCubic = (p) => 1 - Math.pow(1 - clamp01(p), 3);
10
+ // reveal progress of an element whose window is [a,b], at global time t
11
+ const win = (t, a, b) => easeOutCubic((t - a) / (b - a));
12
+
13
+ // staggered reveal windows by element class. A kind only has some of these;
14
+ // overriding a class that isn't present is harmless.
15
+ const STAGGER = [
16
+ ['.rule', 0.00, 0.16],
17
+ ['.kicker', 0.10, 0.30],
18
+ ['.qmark', 0.10, 0.30],
19
+ ['.headline', 0.18, 0.46],
20
+ ['.qtext', 0.20, 0.50],
21
+ ['.big', 0.16, 0.46],
22
+ ['.statlabel', 0.34, 0.58],
23
+ ['.sub', 0.40, 0.62],
24
+ ['.by', 0.46, 0.66],
25
+ ['.foot', 0.58, 0.80],
26
+ ];
27
+
28
+ // CSS that places every element at its reveal state for time t (one-shot, no loops)
29
+ function revealCss(t, dist = 16) {
30
+ const tt = clamp01(t);
31
+ let css = '';
32
+ for (const [sel, a, b] of STAGGER) {
33
+ const p = win(tt, a, b);
34
+ css += `${sel}{opacity:${p.toFixed(3)};transform:translateY(${((1 - p) * dist).toFixed(2)}px)}`;
35
+ }
36
+ return css;
37
+ }
38
+
39
+ // HTML for a single reel frame at time t. Reuses the card, appends the reveal styles.
40
+ function buildReelFrame(spec, t, opts = {}) {
41
+ const card = buildCard(spec, opts);
42
+ const overrides = `<style id="reel">${revealCss(t)}</style>`;
43
+ return { html: card.html.replace('</head>', `${overrides}</head>`), width: card.width, height: card.height };
44
+ }
45
+
46
+ // the list of t values for a reel of `seconds` at `fps` (>=2 frames)
47
+ function reelFrames(seconds = 2.6, fps = 20) {
48
+ const n = Math.max(2, Math.round(seconds * fps));
49
+ return Array.from({ length: n }, (_, i) => i / (n - 1));
50
+ }
51
+
52
+ module.exports = { buildReelFrame, revealCss, reelFrames, win, STAGGER };
package/lib/site.js ADDED
@@ -0,0 +1,114 @@
1
+ // Static site generator — point it at a folder of markdown (docs, your wiki,
2
+ // memory) and get a beautiful, navigable HTML site in the design system.
3
+ // Builds on lib/deck-from-md (parse) + lib/html-render (render).
4
+ //
5
+ // buildSite(input, opts) -> { outDir, indexPath, pages }. Pure file I/O.
6
+
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+ const http = require('http');
10
+ const { parseMarkdownToSpec } = require('./deck-from-md');
11
+ const { renderHtml, THEMES: BUILTIN } = require('./html-render');
12
+ const { mergedThemes } = require('./theme');
13
+
14
+ const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.next', 'out', 'coverage', '.cache']);
15
+ const MD_EXTS = new Set(['.md', '.mdx']);
16
+
17
+ function collectMd(input) {
18
+ const stat = fs.statSync(input);
19
+ if (stat.isFile()) return MD_EXTS.has(path.extname(input)) ? [input] : [];
20
+ const out = [];
21
+ (function walk(dir) {
22
+ for (const name of fs.readdirSync(dir).sort()) {
23
+ if (name.startsWith('.')) continue;
24
+ const full = path.join(dir, name);
25
+ let st; try { st = fs.statSync(full); } catch { continue; }
26
+ if (st.isDirectory()) { if (!SKIP_DIRS.has(name)) walk(full); }
27
+ else if (MD_EXTS.has(path.extname(name))) out.push(full);
28
+ }
29
+ })(input);
30
+ return out;
31
+ }
32
+
33
+ function slugFor(file, root) {
34
+ // base = the dir the site was built from (root may be a dir or a single .md file)
35
+ const base = MD_EXTS.has(path.extname(root)) ? path.dirname(root) : root;
36
+ const rel = path.relative(base, file).replace(/\.[^.]+$/, '');
37
+ return rel.replace(/[\\/]+/g, '-').replace(/[^A-Za-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '').toLowerCase() || 'page';
38
+ }
39
+
40
+ function firstHeading(md) {
41
+ const m = md.match(/^#{1,2}\s+(.+)$/m);
42
+ return m ? m[1].replace(/\*\*/g, '').trim() : null;
43
+ }
44
+ function firstParagraph(md) {
45
+ const body = md.replace(/^---\n[\s\S]*?\n---\n/, '');
46
+ for (const line of body.split('\n')) {
47
+ const t = line.trim();
48
+ if (!t || /^[#<>]/.test(t) || /^[-*+]/.test(t) || /^\|/.test(t) || /^```/.test(t)) continue;
49
+ return t.replace(/\*\*/g, '');
50
+ }
51
+ return '';
52
+ }
53
+ function clip(s, n) { s = String(s || ''); return s.length > n ? s.slice(0, n - 1).trimEnd() + '…' : s; }
54
+ function titleFromSlug(slug) { return slug.replace(/[-_]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); }
55
+
56
+ function buildSite(input, opts = {}) {
57
+ const THEMES = mergedThemes(BUILTIN, opts.root || process.cwd());
58
+ const theme = THEMES[opts.theme] ? opts.theme : 'atris';
59
+ const siteTitle = opts.title || 'Atris';
60
+ const accent = opts.accent || '.';
61
+ const outDir = opts.out || 'dist';
62
+ const files = collectMd(input);
63
+ fs.mkdirSync(outDir, { recursive: true });
64
+ const nav = { home: 'index.html', label: siteTitle, accent };
65
+
66
+ const pages = [];
67
+ const seen = new Set();
68
+ for (const file of files) {
69
+ const md = fs.readFileSync(file, 'utf8');
70
+ const spec = parseMarkdownToSpec(md, { theme });
71
+ if (!THEMES[spec.theme]) spec.theme = theme;
72
+ let slug = slugFor(file, input);
73
+ while (seen.has(slug)) slug += '-1';
74
+ seen.add(slug);
75
+ const title = firstHeading(md) || titleFromSlug(slug);
76
+ const summary = firstParagraph(md);
77
+ const outFile = path.join(outDir, slug + '.html');
78
+ fs.writeFileSync(outFile, renderHtml(spec, { title, nav, themes: THEMES }));
79
+ pages.push({ src: file, out: outFile, href: slug + '.html', title, summary });
80
+ }
81
+
82
+ const indexSpec = {
83
+ theme, brand: { name: siteTitle, accent },
84
+ slides: [
85
+ { type: 'title', headline: opts.headline || `${siteTitle} **docs**`, sub: opts.sub || `${pages.length} page${pages.length === 1 ? '' : 's'}, one design system.` },
86
+ { type: 'toc', heading: 'Pages', items: pages.map((p) => ({ href: p.href, title: p.title, summary: clip(p.summary, 120) })) },
87
+ { type: 'close', tagline: opts.tagline || 'One workspace, one design system.', footer: siteTitle },
88
+ ],
89
+ };
90
+ const indexPath = path.join(outDir, 'index.html');
91
+ fs.writeFileSync(indexPath, renderHtml(indexSpec, { title: siteTitle, themes: THEMES }));
92
+ return { outDir, indexPath, pages };
93
+ }
94
+
95
+ const MIME = { '.html': 'text/html; charset=utf-8', '.css': 'text/css', '.js': 'text/javascript', '.json': 'application/json', '.png': 'image/png', '.jpg': 'image/jpeg', '.svg': 'image/svg+xml' };
96
+
97
+ // minimal static preview server for a built site dir (no deps)
98
+ function serveSite(dir, port = 4321) {
99
+ const root = path.resolve(dir);
100
+ const server = http.createServer((req, res) => {
101
+ let rel = decodeURIComponent((req.url || '/').split('?')[0]);
102
+ if (rel.endsWith('/')) rel += 'index.html';
103
+ const file = path.normalize(path.join(root, rel));
104
+ if (!file.startsWith(root)) { res.writeHead(403); res.end('forbidden'); return; }
105
+ fs.readFile(file, (err, data) => {
106
+ if (err) { res.writeHead(404, { 'Content-Type': 'text/plain' }); res.end('not found'); return; }
107
+ res.writeHead(200, { 'Content-Type': MIME[path.extname(file)] || 'application/octet-stream' });
108
+ res.end(data);
109
+ });
110
+ });
111
+ return new Promise((resolve) => server.listen(port, () => resolve({ server, url: `http://localhost:${port}` })));
112
+ }
113
+
114
+ module.exports = { buildSite, serveSite, collectMd, slugFor, firstHeading, firstParagraph };