@grainulation/barn 1.0.0 → 1.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/lib/server.js CHANGED
@@ -10,29 +10,35 @@
10
10
  * barn serve [--port 9093] [--root /path/to/repo]
11
11
  */
12
12
 
13
- import { createServer } from 'node:http';
14
- import { readFileSync, existsSync, statSync } from 'node:fs';
15
- import { readFile, stat, readdir } from 'node:fs/promises';
16
- import { join, resolve, extname, dirname, basename } from 'node:path';
17
- import { fileURLToPath } from 'node:url';
18
- import { execFile } from 'node:child_process';
19
- import { loadTemplates as _loadTemplates } from './index.js';
13
+ import { createServer } from "node:http";
14
+ import { readFileSync, existsSync, statSync } from "node:fs";
15
+ import { readFile, stat, readdir } from "node:fs/promises";
16
+ import { join, resolve, extname, dirname, basename } from "node:path";
17
+ import { fileURLToPath } from "node:url";
18
+ import { execFile } from "node:child_process";
19
+ import { loadTemplates as _loadTemplates } from "./index.js";
20
20
 
21
21
  const __dirname = dirname(fileURLToPath(import.meta.url));
22
22
 
23
23
  // ── Crash handlers ──
24
- process.on('uncaughtException', (err) => {
25
- process.stderr.write(`[${new Date().toISOString()}] FATAL: ${err.stack || err}\n`);
24
+ process.on("uncaughtException", (err) => {
25
+ process.stderr.write(
26
+ `[${new Date().toISOString()}] FATAL: ${err.stack || err}\n`,
27
+ );
26
28
  process.exit(1);
27
29
  });
28
- process.on('unhandledRejection', (reason) => {
29
- process.stderr.write(`[${new Date().toISOString()}] WARN unhandledRejection: ${reason}\n`);
30
+ process.on("unhandledRejection", (reason) => {
31
+ process.stderr.write(
32
+ `[${new Date().toISOString()}] WARN unhandledRejection: ${reason}\n`,
33
+ );
30
34
  });
31
35
 
32
- const PKG = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'));
33
- const PUBLIC_DIR = join(__dirname, '..', 'public');
34
- const TEMPLATES_DIR = join(__dirname, '..', 'templates');
35
- const TOOLS_DIR = join(__dirname, '..', 'tools');
36
+ const PKG = JSON.parse(
37
+ readFileSync(join(__dirname, "..", "package.json"), "utf8"),
38
+ );
39
+ const PUBLIC_DIR = join(__dirname, "..", "public");
40
+ const TEMPLATES_DIR = join(__dirname, "..", "templates");
41
+ const TOOLS_DIR = join(__dirname, "..", "tools");
36
42
 
37
43
  // ── CLI args ──────────────────────────────────────────────────────────────────
38
44
 
@@ -42,29 +48,59 @@ function arg(name, fallback) {
42
48
  return i !== -1 && args[i + 1] ? args[i + 1] : fallback;
43
49
  }
44
50
 
45
- const PORT = parseInt(arg('port', '9093'), 10);
46
- const ROOT = resolve(arg('root', process.cwd()));
47
- const CORS_ORIGIN = arg('cors', null);
51
+ const PORT = parseInt(arg("port", "9093"), 10);
52
+ const ROOT = resolve(arg("root", process.cwd()));
53
+ const CORS_ORIGIN = arg("cors", null);
48
54
 
49
55
  // ── Verbose logging ──────────────────────────────────────────────────────────
50
56
 
51
- const verbose = process.argv.includes('--verbose') || process.argv.includes('-v');
57
+ const verbose =
58
+ process.argv.includes("--verbose") || process.argv.includes("-v");
52
59
  function vlog(...a) {
53
60
  if (!verbose) return;
54
61
  const ts = new Date().toISOString();
55
- process.stderr.write(`[${ts}] barn: ${a.join(' ')}\n`);
62
+ process.stderr.write(`[${ts}] barn: ${a.join(" ")}\n`);
56
63
  }
57
64
 
58
65
  // ── Routes manifest ──────────────────────────────────────────────────────────
59
66
 
60
67
  const ROUTES = [
61
- { method: 'GET', path: '/health', description: 'Health check (tool, version, port, uptime)' },
62
- { method: 'GET', path: '/events', description: 'SSE event stream for live updates' },
63
- { method: 'GET', path: '/api/state', description: 'Current state (templates, sprints, manifest)' },
64
- { method: 'GET', path: '/api/template', description: 'Template content by ?name parameter' },
65
- { method: 'GET', path: '/api/search', description: 'Search templates by ?q=<query> (name, description, placeholders, features)' },
66
- { method: 'POST', path: '/api/refresh', description: 'Refresh state from disk' },
67
- { method: 'GET', path: '/api/docs', description: 'This API documentation page' },
68
+ {
69
+ method: "GET",
70
+ path: "/health",
71
+ description: "Health check (tool, version, port, uptime)",
72
+ },
73
+ {
74
+ method: "GET",
75
+ path: "/events",
76
+ description: "SSE event stream for live updates",
77
+ },
78
+ {
79
+ method: "GET",
80
+ path: "/api/state",
81
+ description: "Current state (templates, sprints, manifest)",
82
+ },
83
+ {
84
+ method: "GET",
85
+ path: "/api/template",
86
+ description: "Template content by ?name parameter",
87
+ },
88
+ {
89
+ method: "GET",
90
+ path: "/api/search",
91
+ description:
92
+ "Search templates by ?q=<query> (name, description, placeholders, features)",
93
+ },
94
+ {
95
+ method: "POST",
96
+ path: "/api/refresh",
97
+ description: "Refresh state from disk",
98
+ },
99
+ {
100
+ method: "GET",
101
+ path: "/api/docs",
102
+ description: "This API documentation page",
103
+ },
68
104
  ];
69
105
 
70
106
  // ── State ─────────────────────────────────────────────────────────────────────
@@ -81,44 +117,58 @@ const sseClients = new Set();
81
117
  function broadcast(event) {
82
118
  const data = `data: ${JSON.stringify(event)}\n\n`;
83
119
  for (const res of sseClients) {
84
- try { res.write(data); } catch { sseClients.delete(res); }
120
+ try {
121
+ res.write(data);
122
+ } catch {
123
+ sseClients.delete(res);
124
+ }
85
125
  }
86
126
  }
87
127
 
88
128
  // ── Data loading ──────────────────────────────────────────────────────────────
89
129
 
90
130
  function loadTemplates() {
91
- vlog('read', TEMPLATES_DIR);
131
+ vlog("read", TEMPLATES_DIR);
92
132
  return _loadTemplates(TEMPLATES_DIR);
93
133
  }
94
134
 
95
135
  function loadSprints() {
96
- const mod = join(TOOLS_DIR, 'detect-sprints.js');
136
+ const mod = join(TOOLS_DIR, "detect-sprints.js");
97
137
  if (!existsSync(mod)) return Promise.resolve({ sprints: [], active: null });
98
138
 
99
139
  return new Promise((resolve) => {
100
- execFile('node', [mod, '--json', '--root', ROOT], {
101
- timeout: 10000, stdio: ['ignore', 'pipe', 'pipe'],
102
- }, (err, stdout) => {
103
- if (err) { resolve({ sprints: [], active: null }); return; }
104
- try {
105
- const data = JSON.parse(stdout);
106
- resolve({
107
- sprints: data.sprints || [],
108
- active: (data.sprints || []).find(s => s.status === 'active') || null,
109
- });
110
- } catch {
111
- resolve({ sprints: [], active: null });
112
- }
113
- });
140
+ execFile(
141
+ "node",
142
+ [mod, "--json", "--root", ROOT],
143
+ {
144
+ timeout: 10000,
145
+ stdio: ["ignore", "pipe", "pipe"],
146
+ },
147
+ (err, stdout) => {
148
+ if (err) {
149
+ resolve({ sprints: [], active: null });
150
+ return;
151
+ }
152
+ try {
153
+ const data = JSON.parse(stdout);
154
+ resolve({
155
+ sprints: data.sprints || [],
156
+ active:
157
+ (data.sprints || []).find((s) => s.status === "active") || null,
158
+ });
159
+ } catch {
160
+ resolve({ sprints: [], active: null });
161
+ }
162
+ },
163
+ );
114
164
  });
115
165
  }
116
166
 
117
167
  function loadManifest() {
118
- const manifestPath = join(ROOT, 'wheat-manifest.json');
168
+ const manifestPath = join(ROOT, "wheat-manifest.json");
119
169
  if (!existsSync(manifestPath)) return null;
120
170
  try {
121
- return JSON.parse(readFileSync(manifestPath, 'utf8'));
171
+ return JSON.parse(readFileSync(manifestPath, "utf8"));
122
172
  } catch {
123
173
  return null;
124
174
  }
@@ -133,20 +183,24 @@ async function refreshState() {
133
183
  state.sprints = sprintData.sprints;
134
184
  state.activeSprint = sprintData.active;
135
185
  state.manifest = loadManifest();
136
- broadcast({ type: 'state', data: state });
186
+ broadcast({ type: "state", data: state });
137
187
  })();
138
- try { return await refreshPending; } finally { refreshPending = null; }
188
+ try {
189
+ return await refreshPending;
190
+ } finally {
191
+ refreshPending = null;
192
+ }
139
193
  }
140
194
 
141
195
  // ── MIME types ────────────────────────────────────────────────────────────────
142
196
 
143
197
  const MIME = {
144
- '.html': 'text/html; charset=utf-8',
145
- '.css': 'text/css; charset=utf-8',
146
- '.js': 'application/javascript; charset=utf-8',
147
- '.json': 'application/json; charset=utf-8',
148
- '.svg': 'image/svg+xml',
149
- '.png': 'image/png',
198
+ ".html": "text/html; charset=utf-8",
199
+ ".css": "text/css; charset=utf-8",
200
+ ".js": "application/javascript; charset=utf-8",
201
+ ".json": "application/json; charset=utf-8",
202
+ ".svg": "image/svg+xml",
203
+ ".png": "image/png",
150
204
  };
151
205
 
152
206
  // ── HTTP server ───────────────────────────────────────────────────────────────
@@ -156,74 +210,89 @@ const server = createServer(async (req, res) => {
156
210
 
157
211
  // CORS headers (only when --cors is passed)
158
212
  if (CORS_ORIGIN) {
159
- res.setHeader('Access-Control-Allow-Origin', CORS_ORIGIN);
160
- res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
161
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
213
+ res.setHeader("Access-Control-Allow-Origin", CORS_ORIGIN);
214
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
215
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type");
162
216
  }
163
217
 
164
- if (req.method === 'OPTIONS' && CORS_ORIGIN) {
218
+ if (req.method === "OPTIONS" && CORS_ORIGIN) {
165
219
  res.writeHead(204);
166
220
  res.end();
167
221
  return;
168
222
  }
169
223
 
170
- vlog('request', req.method, url.pathname);
224
+ vlog("request", req.method, url.pathname);
171
225
 
172
226
  // ── Health check ──
173
- if (req.method === 'GET' && url.pathname === '/health') {
174
- res.writeHead(200, { 'Content-Type': 'application/json' });
175
- res.end(JSON.stringify({ tool: 'barn', version: PKG.version, port: PORT, uptime: process.uptime() }));
227
+ if (req.method === "GET" && url.pathname === "/health") {
228
+ res.writeHead(200, { "Content-Type": "application/json" });
229
+ res.end(
230
+ JSON.stringify({
231
+ tool: "barn",
232
+ version: PKG.version,
233
+ port: PORT,
234
+ uptime: process.uptime(),
235
+ }),
236
+ );
176
237
  return;
177
238
  }
178
239
 
179
240
  // ── API: docs ──
180
- if (req.method === 'GET' && url.pathname === '/api/docs') {
241
+ if (req.method === "GET" && url.pathname === "/api/docs") {
181
242
  const html = `<!DOCTYPE html><html><head><title>barn API</title>
182
243
  <style>body{font-family:system-ui;background:#0a0e1a;color:#e8ecf1;max-width:800px;margin:40px auto;padding:0 20px}
183
244
  table{width:100%;border-collapse:collapse}th,td{padding:8px 12px;border-bottom:1px solid #1e293b;text-align:left}
184
245
  th{color:#9ca3af}code{background:#1e293b;padding:2px 6px;border-radius:4px;font-size:13px}</style></head>
185
246
  <body><h1>barn API</h1><p>${ROUTES.length} endpoints</p>
186
247
  <table><tr><th>Method</th><th>Path</th><th>Description</th></tr>
187
- ${ROUTES.map(r => '<tr><td><code>'+r.method+'</code></td><td><code>'+r.path+'</code></td><td>'+r.description+'</td></tr>').join('')}
248
+ ${ROUTES.map((r) => "<tr><td><code>" + r.method + "</code></td><td><code>" + r.path + "</code></td><td>" + r.description + "</td></tr>").join("")}
188
249
  </table></body></html>`;
189
- res.writeHead(200, { 'Content-Type': 'text/html' });
250
+ res.writeHead(200, { "Content-Type": "text/html" });
190
251
  res.end(html);
191
252
  return;
192
253
  }
193
254
 
194
255
  // ── SSE endpoint ──
195
- if (req.method === 'GET' && url.pathname === '/events') {
256
+ if (req.method === "GET" && url.pathname === "/events") {
196
257
  res.writeHead(200, {
197
- 'Content-Type': 'text/event-stream',
198
- 'Cache-Control': 'no-cache',
199
- 'Connection': 'keep-alive',
258
+ "Content-Type": "text/event-stream",
259
+ "Cache-Control": "no-cache",
260
+ Connection: "keep-alive",
200
261
  });
201
- res.write(`data: ${JSON.stringify({ type: 'state', data: state })}\n\n`);
262
+ res.write(`data: ${JSON.stringify({ type: "state", data: state })}\n\n`);
202
263
  const heartbeat = setInterval(() => {
203
- try { res.write(': heartbeat\n\n'); } catch { clearInterval(heartbeat); }
264
+ try {
265
+ res.write(": heartbeat\n\n");
266
+ } catch {
267
+ clearInterval(heartbeat);
268
+ }
204
269
  }, 15000);
205
270
  sseClients.add(res);
206
- vlog('sse', `client connected (${sseClients.size} total)`);
207
- req.on('close', () => { clearInterval(heartbeat); sseClients.delete(res); vlog('sse', `client disconnected (${sseClients.size} total)`); });
271
+ vlog("sse", `client connected (${sseClients.size} total)`);
272
+ req.on("close", () => {
273
+ clearInterval(heartbeat);
274
+ sseClients.delete(res);
275
+ vlog("sse", `client disconnected (${sseClients.size} total)`);
276
+ });
208
277
  return;
209
278
  }
210
279
 
211
280
  // ── API: state ──
212
- if (req.method === 'GET' && url.pathname === '/api/state') {
213
- res.writeHead(200, { 'Content-Type': 'application/json' });
281
+ if (req.method === "GET" && url.pathname === "/api/state") {
282
+ res.writeHead(200, { "Content-Type": "application/json" });
214
283
  res.end(JSON.stringify(state));
215
284
  return;
216
285
  }
217
286
 
218
287
  // ── API: search templates ──
219
- if (req.method === 'GET' && url.pathname === '/api/search') {
220
- const q = (url.searchParams.get('q') || '').toLowerCase().trim();
288
+ if (req.method === "GET" && url.pathname === "/api/search") {
289
+ const q = (url.searchParams.get("q") || "").toLowerCase().trim();
221
290
  if (!q) {
222
- res.writeHead(200, { 'Content-Type': 'application/json' });
291
+ res.writeHead(200, { "Content-Type": "application/json" });
223
292
  res.end(JSON.stringify(state.templates));
224
293
  return;
225
294
  }
226
- const filtered = state.templates.filter(tpl => {
295
+ const filtered = state.templates.filter((tpl) => {
227
296
  const haystack = [
228
297
  tpl.name,
229
298
  tpl.title,
@@ -231,83 +300,103 @@ ${ROUTES.map(r => '<tr><td><code>'+r.method+'</code></td><td><code>'+r.path+'</c
231
300
  ...tpl.placeholders,
232
301
  ...tpl.features,
233
302
  ...tpl.tags,
234
- ].join(' ').toLowerCase();
303
+ ]
304
+ .join(" ")
305
+ .toLowerCase();
235
306
  return haystack.includes(q);
236
307
  });
237
- res.writeHead(200, { 'Content-Type': 'application/json' });
308
+ res.writeHead(200, { "Content-Type": "application/json" });
238
309
  res.end(JSON.stringify(filtered));
239
310
  return;
240
311
  }
241
312
 
242
313
  // ── API: template content ──
243
- if (req.method === 'GET' && url.pathname === '/api/template') {
244
- const name = url.searchParams.get('name');
245
- if (!name) { res.writeHead(400); res.end('missing name'); return; }
246
- const filePath = resolve(TEMPLATES_DIR, name + '.html');
247
- if (!filePath.startsWith(TEMPLATES_DIR)) { res.writeHead(403); res.end('forbidden'); return; }
248
- if (!existsSync(filePath)) { res.writeHead(404); res.end('not found'); return; }
249
- res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
250
- res.end(readFileSync(filePath, 'utf8'));
314
+ if (req.method === "GET" && url.pathname === "/api/template") {
315
+ const name = url.searchParams.get("name");
316
+ if (!name) {
317
+ res.writeHead(400);
318
+ res.end("missing name");
319
+ return;
320
+ }
321
+ const filePath = resolve(TEMPLATES_DIR, name + ".html");
322
+ if (!filePath.startsWith(TEMPLATES_DIR)) {
323
+ res.writeHead(403);
324
+ res.end("forbidden");
325
+ return;
326
+ }
327
+ if (!existsSync(filePath)) {
328
+ res.writeHead(404);
329
+ res.end("not found");
330
+ return;
331
+ }
332
+ res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
333
+ res.end(readFileSync(filePath, "utf8"));
251
334
  return;
252
335
  }
253
336
 
254
337
  // ── API: refresh ──
255
- if (req.method === 'POST' && url.pathname === '/api/refresh') {
338
+ if (req.method === "POST" && url.pathname === "/api/refresh") {
256
339
  await refreshState();
257
- res.writeHead(200, { 'Content-Type': 'application/json' });
340
+ res.writeHead(200, { "Content-Type": "application/json" });
258
341
  res.end(JSON.stringify(state));
259
342
  return;
260
343
  }
261
344
 
262
345
  // ── Static files ──
263
- let filePath = url.pathname === '/' ? '/index.html' : url.pathname;
346
+ let filePath = url.pathname === "/" ? "/index.html" : url.pathname;
264
347
 
265
348
  // Prevent directory traversal
266
- const resolved = resolve(PUBLIC_DIR, '.' + filePath);
349
+ const resolved = resolve(PUBLIC_DIR, "." + filePath);
267
350
  if (!resolved.startsWith(PUBLIC_DIR)) {
268
351
  res.writeHead(403);
269
- res.end('forbidden');
352
+ res.end("forbidden");
270
353
  return;
271
354
  }
272
355
 
273
356
  if (existsSync(resolved) && statSync(resolved).isFile()) {
274
357
  const ext = extname(resolved);
275
- res.writeHead(200, { 'Content-Type': MIME[ext] || 'application/octet-stream' });
358
+ res.writeHead(200, {
359
+ "Content-Type": MIME[ext] || "application/octet-stream",
360
+ });
276
361
  res.end(readFileSync(resolved));
277
362
  return;
278
363
  }
279
364
 
280
365
  res.writeHead(404);
281
- res.end('not found');
366
+ res.end("not found");
282
367
  });
283
368
 
284
369
  // ── File watching ─────────────────────────────────────────────────────────────
285
370
 
286
371
  // Build a fingerprint of filenames + mtimes for change detection
287
372
  async function dirFingerprint(dir) {
288
- if (!existsSync(dir)) return '';
373
+ if (!existsSync(dir)) return "";
289
374
  const files = await readdir(dir);
290
375
  const parts = [];
291
376
  for (const f of files) {
292
- if (!f.endsWith('.html') && !f.endsWith('.json')) continue;
377
+ if (!f.endsWith(".html") && !f.endsWith(".json")) continue;
293
378
  try {
294
379
  const s = await stat(join(dir, f));
295
380
  parts.push(`${f}:${s.mtimeMs}`);
296
- } catch { /* removed between readdir and stat */ }
381
+ } catch {
382
+ /* removed between readdir and stat */
383
+ }
297
384
  }
298
- return parts.sort().join('|');
385
+ return parts.sort().join("|");
299
386
  }
300
387
 
301
388
  async function claimsFingerprint() {
302
- const claimsPath = join(ROOT, 'claims.json');
389
+ const claimsPath = join(ROOT, "claims.json");
303
390
  try {
304
391
  const s = await stat(claimsPath);
305
392
  return `claims:${s.mtimeMs}`;
306
- } catch { return ''; }
393
+ } catch {
394
+ return "";
395
+ }
307
396
  }
308
397
 
309
- let lastTemplatesFP = '';
310
- let lastClaimsFP = '';
398
+ let lastTemplatesFP = "";
399
+ let lastClaimsFP = "";
311
400
 
312
401
  const watchInterval = setInterval(async () => {
313
402
  try {
@@ -320,20 +409,26 @@ const watchInterval = setInterval(async () => {
320
409
  lastClaimsFP = cFP;
321
410
  await refreshState();
322
411
  }
323
- } catch { /* ignore polling errors */ }
412
+ } catch {
413
+ /* ignore polling errors */
414
+ }
324
415
  }, 2000);
325
416
 
326
417
  // ── Graceful shutdown ─────────────────────────────────────────────────────────
327
418
  const shutdown = (signal) => {
328
419
  console.log(`\nbarn: ${signal} received, shutting down...`);
329
420
  clearInterval(watchInterval);
330
- for (const res of sseClients) { try { res.end(); } catch {} }
421
+ for (const res of sseClients) {
422
+ try {
423
+ res.end();
424
+ } catch {}
425
+ }
331
426
  sseClients.clear();
332
427
  server.close(() => process.exit(0));
333
428
  setTimeout(() => process.exit(1), 5000);
334
429
  };
335
- process.on('SIGTERM', () => shutdown('SIGTERM'));
336
- process.on('SIGINT', () => shutdown('SIGINT'));
430
+ process.on("SIGTERM", () => shutdown("SIGTERM"));
431
+ process.on("SIGINT", () => shutdown("SIGINT"));
337
432
 
338
433
  // ── Start ─────────────────────────────────────────────────────────────────────
339
434
 
@@ -344,25 +439,27 @@ await refreshState();
344
439
  claimsFingerprint(),
345
440
  ]);
346
441
 
347
- server.on('error', (err) => {
348
- if (err.code === 'EADDRINUSE') {
442
+ server.on("error", (err) => {
443
+ if (err.code === "EADDRINUSE") {
349
444
  console.error(`barn: port ${PORT} already in use — try --port <other>`);
350
445
  process.exit(1);
351
446
  }
352
- if (err.code === 'EACCES') {
447
+ if (err.code === "EACCES") {
353
448
  console.error(`barn: permission denied for port ${PORT}`);
354
449
  process.exit(1);
355
450
  }
356
451
  throw err;
357
452
  });
358
453
 
359
- server.listen(PORT, '127.0.0.1', () => {
360
- vlog('listen', `port=${PORT}`, `root=${ROOT}`);
454
+ server.listen(PORT, "127.0.0.1", () => {
455
+ vlog("listen", `port=${PORT}`, `root=${ROOT}`);
361
456
  console.log(`barn: serving on http://localhost:${PORT}`);
362
457
  console.log(` templates: ${state.templates.length} found`);
363
458
  console.log(` sprints: ${state.sprints.length} detected`);
364
459
  if (state.activeSprint) {
365
- console.log(` active: ${state.activeSprint.name} (${state.activeSprint.phase})`);
460
+ console.log(
461
+ ` active: ${state.activeSprint.name} (${state.activeSprint.phase})`,
462
+ );
366
463
  }
367
464
  console.log(` root: ${ROOT}`);
368
465
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@grainulation/barn",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Template browser and sprint toolkit for the grainulation ecosystem",
5
5
  "license": "MIT",
6
6
  "author": "grainulation contributors",
@@ -28,7 +28,8 @@
28
28
  "./detect-sprints": "./tools/detect-sprints.js",
29
29
  "./generate-manifest": "./tools/generate-manifest.js",
30
30
  "./build-pdf": "./tools/build-pdf.js",
31
- "./tokens": "./public/grainulation-tokens.css"
31
+ "./tokens": "./public/grainulation-tokens.css",
32
+ "./status-icons": "./public/status-icons.svg"
32
33
  },
33
34
  "bin": {
34
35
  "barn": "./bin/barn.js"
@@ -39,7 +40,10 @@
39
40
  "public/",
40
41
  "tools/",
41
42
  "templates/",
42
- "CHANGELOG.md"
43
+ "CHANGELOG.md",
44
+ "LICENSE",
45
+ "CODE_OF_CONDUCT.md",
46
+ "CONTRIBUTING.md"
43
47
  ],
44
48
  "scripts": {
45
49
  "start": "node bin/barn.js serve",
@@ -47,6 +51,6 @@
47
51
  "test": "node test/basic.test.js"
48
52
  },
49
53
  "engines": {
50
- "node": ">=18"
54
+ "node": ">=20"
51
55
  }
52
56
  }
@@ -0,0 +1,47 @@
1
+ <!--
2
+ grainulation-icons.svg — Triple-encoding icon sprite
3
+ WCAG 1.4.1 compliant: each status indicator has a distinctive shape
4
+ so meaning is never conveyed by color alone.
5
+
6
+ Usage:
7
+ <svg class="icon" aria-hidden="true">
8
+ <use href="grainulation-icons.svg#checkmark-circle" />
9
+ </svg>
10
+ <span>Success message</span>
11
+
12
+ Icons:
13
+ checkmark-circle — success (circle + checkmark)
14
+ x-circle — error (circle + X)
15
+ triangle-alert — warning (triangle + exclamation)
16
+ info-circle — info (circle + i)
17
+ -->
18
+ <svg xmlns="http://www.w3.org/2000/svg" style="display:none">
19
+
20
+ <!-- ✓ Success: checkmark inside circle -->
21
+ <symbol id="checkmark-circle" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
22
+ <circle cx="12" cy="12" r="10" />
23
+ <path d="M9 12l2 2 4-4" />
24
+ </symbol>
25
+
26
+ <!-- ✗ Error: X inside circle -->
27
+ <symbol id="x-circle" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
28
+ <circle cx="12" cy="12" r="10" />
29
+ <path d="M15 9l-6 6" />
30
+ <path d="M9 9l6 6" />
31
+ </symbol>
32
+
33
+ <!-- ⚠ Warning: exclamation inside triangle -->
34
+ <symbol id="triangle-alert" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
35
+ <path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
36
+ <line x1="12" y1="9" x2="12" y2="13" />
37
+ <line x1="12" y1="17" x2="12.01" y2="17" />
38
+ </symbol>
39
+
40
+ <!-- ℹ Info: i inside circle -->
41
+ <symbol id="info-circle" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
42
+ <circle cx="12" cy="12" r="10" />
43
+ <line x1="12" y1="16" x2="12" y2="12" />
44
+ <line x1="12" y1="8" x2="12.01" y2="8" />
45
+ </symbol>
46
+
47
+ </svg>