@augurworks/augur 0.15.1
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/CANVAS.md +858 -0
- package/INSTALL.md +603 -0
- package/LICENSE +27 -0
- package/README.md +150 -0
- package/TRADEMARKS.md +72 -0
- package/agents/README.md +65 -0
- package/agents/canon-extract.md +117 -0
- package/agents/canon.md +151 -0
- package/agents/canvas.md +88 -0
- package/agents/currency.md +74 -0
- package/agents/drafts.md +92 -0
- package/agents/identity.md +148 -0
- package/agents/prototype-contract.md +69 -0
- package/agents/publishing.md +324 -0
- package/agents/review-feedback.md +76 -0
- package/agents/space-json.md +132 -0
- package/agents/ui-skill.md +101 -0
- package/agents/working-marks.md +86 -0
- package/brand/augur-eye.svg +3 -0
- package/brand/augur-icon.svg +41 -0
- package/brand/augur-mark.svg +5 -0
- package/brand/lentianova-bulky.otf +0 -0
- package/brand/lentianova-median.otf +0 -0
- package/brand/lentianova.woff2 +0 -0
- package/build.js +8409 -0
- package/changelog.md +133 -0
- package/fonts/inter-latin-wght-normal.woff2 +0 -0
- package/package.json +57 -0
- package/pitis/PETS.md +121 -0
- package/pitis/README.md +117 -0
- package/pitis/index.html +266 -0
- package/pitis/piti.build.js +81 -0
- package/pitis/piti.js +929 -0
- package/pitis/roast-agent.md +185 -0
- package/realtime/src/index.js +119 -0
- package/realtime/wrangler.example.toml +44 -0
- package/scripts/adopt.mjs +106 -0
- package/scripts/board-snapshot.mjs +197 -0
- package/scripts/build-seed-pack.mjs +33 -0
- package/scripts/bundle-rekey.mjs +112 -0
- package/scripts/bundle-tenancy-rehearsal.mjs +781 -0
- package/scripts/canon-extract.mjs +578 -0
- package/scripts/canon.mjs +599 -0
- package/scripts/canvas-screen.mjs +333 -0
- package/scripts/check-local.mjs +136 -0
- package/scripts/clawd-art.mjs +75 -0
- package/scripts/clawd-canvas.mjs +741 -0
- package/scripts/cli.mjs +100 -0
- package/scripts/clone.mjs +422 -0
- package/scripts/close.mjs +30 -0
- package/scripts/connect.mjs +103 -0
- package/scripts/deploy.mjs +157 -0
- package/scripts/dev.mjs +54 -0
- package/scripts/doc-lint.mjs +143 -0
- package/scripts/export.mjs +300 -0
- package/scripts/fork.mjs +64 -0
- package/scripts/freeze.mjs +74 -0
- package/scripts/frontdoor-parity.mjs +180 -0
- package/scripts/hook.mjs +67 -0
- package/scripts/identity-rekey.mjs +112 -0
- package/scripts/init.mjs +122 -0
- package/scripts/land.mjs +40 -0
- package/scripts/lib/adapters.mjs +156 -0
- package/scripts/lib/board-snapshot.mjs +511 -0
- package/scripts/lib/draft.mjs +385 -0
- package/scripts/lib/graduate.mjs +276 -0
- package/scripts/lib/instance.mjs +74 -0
- package/scripts/lib/marks.mjs +107 -0
- package/scripts/lib/materialize.mjs +138 -0
- package/scripts/lib/merge3.mjs +124 -0
- package/scripts/lib/offline-posture.mjs +120 -0
- package/scripts/lib/offline-respawn.mjs +18 -0
- package/scripts/lib/offline-wrangler.mjs +78 -0
- package/scripts/lib/publish-compose.mjs +12 -0
- package/scripts/lib/publish-conflict.mjs +78 -0
- package/scripts/lib/publish-evidence.mjs +144 -0
- package/scripts/lib/refine-compare.mjs +140 -0
- package/scripts/lib/refine-ledger.mjs +150 -0
- package/scripts/lib/refine-png.mjs +149 -0
- package/scripts/lib/seed-pack-build.mjs +141 -0
- package/scripts/lib/state-compare.mjs +68 -0
- package/scripts/lib/store.mjs +122 -0
- package/scripts/login.mjs +84 -0
- package/scripts/mark.mjs +112 -0
- package/scripts/migrate-board-keys.mjs +164 -0
- package/scripts/migrate.mjs +265 -0
- package/scripts/no-foreign-vocabulary.mjs +216 -0
- package/scripts/no-product-names.mjs +157 -0
- package/scripts/no-tenant-globals.mjs +1134 -0
- package/scripts/offline.mjs +247 -0
- package/scripts/og.mjs +170 -0
- package/scripts/one-tenant-resolver.mjs +146 -0
- package/scripts/open.mjs +63 -0
- package/scripts/publish.mjs +1041 -0
- package/scripts/read.mjs +31 -0
- package/scripts/refine.mjs +455 -0
- package/scripts/release-drift.mjs +138 -0
- package/scripts/restore.mjs +322 -0
- package/scripts/review.mjs +125 -0
- package/scripts/save.mjs +21 -0
- package/scripts/shell-lint.mjs +189 -0
- package/scripts/ship.mjs +460 -0
- package/scripts/shoot.mjs +264 -0
- package/scripts/smoke-invite.mjs +113 -0
- package/scripts/state-inventory.mjs +223 -0
- package/scripts/status.mjs +177 -0
- package/scripts/sync.mjs +27 -0
- package/scripts/tenant-do-rehearsal.mjs +1314 -0
- package/scripts/ui-copy-lint.mjs +202 -0
- package/scripts/watch.mjs +32 -0
- package/scripts/wrangler-preflight.mjs +246 -0
- package/seed/CANON.md +43 -0
- package/seed/README.md +119 -0
- package/seed/prototype-status.json +9 -0
- package/seed/registry.json +88 -0
- package/seed/skills/starter-ui/SKILL.md +52 -0
- package/seed/skills/starter-ui/skill.json +11 -0
- package/seed/skills/starter-ui/starter-tokens.css +73 -0
- package/seed/skills/starter-ui/starter-ui.css +318 -0
- package/seed/skills/starter-ui/starter-ui.js +50 -0
- package/seed/space.json +5 -0
- package/seed/start-here/prototypes/connect-your-terminal/index.html +428 -0
- package/seed/start-here/prototypes/connect-your-terminal/preview.webp +0 -0
- package/seed/start-here/prototypes/sample-with-comments/index.html +172 -0
- package/seed/start-here/prototypes/sample-with-comments/preview.webp +0 -0
- package/seed/start-here/prototypes/set-up-your-design-system/index.html +353 -0
- package/seed/start-here/prototypes/set-up-your-design-system/preview.webp +0 -0
- package/seed/threads.json +52 -0
- package/seed/worked-examples/prototypes/field-readings/index.html +273 -0
- package/seed/worked-examples/prototypes/field-readings/preview.webp +0 -0
- package/seed/worked-examples/prototypes/slide-deck/index.html +214 -0
- package/seed/worked-examples/prototypes/slide-deck/preview.webp +0 -0
- package/seed/worked-examples/prototypes/specimen-viewer/index.html +203 -0
- package/seed/worked-examples/prototypes/specimen-viewer/preview.webp +0 -0
- package/src/_worker.js +12668 -0
- package/src/board-key.mjs +57 -0
- package/src/board-room.mjs +881 -0
- package/src/bundle-keys.mjs +138 -0
- package/src/canon/collect-in-browser.js +215 -0
- package/src/canon/emit.mjs +232 -0
- package/src/canon/observe.mjs +260 -0
- package/src/canon/schema.mjs +451 -0
- package/src/canvas/DSEG-LICENSE.txt +95 -0
- package/src/canvas/DSEG7Classic-Bold.woff2 +0 -0
- package/src/canvas/canvas.css +885 -0
- package/src/canvas/canvas.js +5780 -0
- package/src/canvas/capture.js +485 -0
- package/src/chrome/appchrome.mjs +568 -0
- package/src/currency.mjs +203 -0
- package/src/delete-confirmation.mjs +254 -0
- package/src/drafts/drafts.js +326 -0
- package/src/entry.js +58 -0
- package/src/galleries.mjs +400 -0
- package/src/health-cron.mjs +173 -0
- package/src/identity.json +1 -0
- package/src/kv-codec.mjs +135 -0
- package/src/kv-identity.mjs +259 -0
- package/src/mail.mjs +599 -0
- package/src/provenance.mjs +74 -0
- package/src/publish-compose.mjs +363 -0
- package/src/publish-fork.mjs +221 -0
- package/src/publish-units.mjs +44 -0
- package/src/purge.mjs +81 -0
- package/src/review/COMMENTING-UX.md +306 -0
- package/src/review/cat.png +0 -0
- package/src/review/comment-cursor.svg +7 -0
- package/src/review/comments.js +1738 -0
- package/src/room-ticket.mjs +96 -0
- package/src/roster-chip.mjs +32 -0
- package/src/seed-pack.mjs +261 -0
- package/src/state-inventory.mjs +249 -0
- package/src/sw-logic.mjs +48 -0
- package/src/tenant-cache.mjs +141 -0
- package/src/tenant-context.mjs +386 -0
- package/src/tenant-do.js +2940 -0
- package/src/tenant-host.mjs +144 -0
- package/src/tenant-quotas.mjs +140 -0
- package/src/unit-core.mjs +103 -0
- package/src/unit-object.mjs +338 -0
- package/templates/README.md +67 -0
- package/templates/shell/deploy.yml +145 -0
- package/templates/shell/engine-bump.yml +142 -0
- package/templates/shell/health.yml +233 -0
- package/templates/shell/kv-backup.yml +201 -0
- package/templates/shell/roster-update.yml +110 -0
- package/templates/shell/space-preflight.yml +35 -0
- package/templates/shell/space-rebake.yml +79 -0
- package/templates/shell/store-backup.yml +152 -0
- package/templates/shell/wrangler.example.toml +266 -0
- package/templates/space/publish.yml +91 -0
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// canvas-screen — scaffold a prototype that is OWNED BY a canvas.
|
|
3
|
+
//
|
|
4
|
+
// The model (see augur/CANVAS.md "Canvas-owned prototypes"): a canvas is a container.
|
|
5
|
+
// "Build a prototype on the canvas" = create a real prototype in a SUBFOLDER of the canvas
|
|
6
|
+
// (`<opp>/prototypes/<canvas>/<slug>/index.html`, ships at `/<opp>/<canvas>/<slug>/`) AND
|
|
7
|
+
// place a tile for it on that canvas's board. The screen is OWNED by the canvas: removing it
|
|
8
|
+
// from the canvas deletes the folder too — gone in general, not merely unlinked. (A tile that
|
|
9
|
+
// points at some pre-existing top-level prototype, added via the in-app picker, is a mere
|
|
10
|
+
// REFERENCE — this tool never touches those.)
|
|
11
|
+
//
|
|
12
|
+
// Because a canvas can't write git from the browser, that create/remove coupling is enforced
|
|
13
|
+
// HERE, in the terminal, by whoever authors the prototype (you + an agent). Use:
|
|
14
|
+
// node scripts/canvas-screen.mjs add <canvasUrl> <slug> [--title "Nice Title"]
|
|
15
|
+
// node scripts/canvas-screen.mjs rm <canvasUrl> <slug>
|
|
16
|
+
// node scripts/canvas-screen.mjs ls <canvasUrl>
|
|
17
|
+
// node scripts/canvas-screen.mjs gc <canvasUrl>
|
|
18
|
+
// e.g. node scripts/canvas-screen.mjs add /ux-ui-audit/canvas/ voting-screen --title "Voting"
|
|
19
|
+
//
|
|
20
|
+
// `gc` is the browser half of the remove coupling: deleting a tile IN THE CANVAS UI can't
|
|
21
|
+
// delete the folder (no git in the browser), so the folder lingers as an orphan. gc notices
|
|
22
|
+
// orphans and deletes their folders after a ONE-HOUR grace window (to cover a
|
|
23
|
+
// ⌘Z that brings the tile back) anchored at the first gc run that saw the orphan. Run it
|
|
24
|
+
// whenever you start working a canvas; state lives in <workspace>/.canvas-gc.json (local only).
|
|
25
|
+
//
|
|
26
|
+
// `add`/`rm` touch two things: the FILES (in the space repo, which you then commit + push) and
|
|
27
|
+
// the canvas BOARD — written THROUGH the multiplayer room as per-node ops (takes effect
|
|
28
|
+
// live, and can't be clobbered by the room's own KV persistence). After `add`, write the
|
|
29
|
+
// real prototype into the created index.html, then commit + push the space repo.
|
|
30
|
+
|
|
31
|
+
import { readdirSync, existsSync, readFileSync, mkdirSync, writeFileSync, rmSync, cpSync } from "node:fs";
|
|
32
|
+
import path from "node:path";
|
|
33
|
+
import { fileURLToPath } from "node:url";
|
|
34
|
+
|
|
35
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); // augur/
|
|
36
|
+
const PARENT = path.join(ROOT, ".."); // the multi-space workspace
|
|
37
|
+
const SITE = process.env.CANVAS_SITE_ORIGIN || process.env.REVIEW_SITE_URL || "";
|
|
38
|
+
if (!SITE) {
|
|
39
|
+
console.error("canvas-screen: set CANVAS_SITE_ORIGIN (or REVIEW_SITE_URL) to the deployed site origin, e.g. https://<project>.pages.dev");
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ---- space resolution: map a canvas URL to its space repo + on-disk prototype dir ----------
|
|
44
|
+
function spaces() {
|
|
45
|
+
// sibling dirs carrying a space.json (same rule build.js/offline use)
|
|
46
|
+
return readdirSync(PARENT, { withFileTypes: true })
|
|
47
|
+
.filter((e) => e.isDirectory() && !e.name.startsWith(".") && existsSync(path.join(PARENT, e.name, "space.json")))
|
|
48
|
+
.map((e) => {
|
|
49
|
+
let meta = {};
|
|
50
|
+
try { meta = JSON.parse(readFileSync(path.join(PARENT, e.name, "space.json"), "utf8")); } catch {}
|
|
51
|
+
return { dir: path.join(PARENT, e.name), id: meta.id || e.name, isDefault: !!meta.default };
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// canvasUrl: "/ux-ui-audit/canvas/" (default space) or "/<spaceid>/<opp>/<canvas>/" (non-default)
|
|
56
|
+
function resolve(canvasUrl) {
|
|
57
|
+
const segs = String(canvasUrl).replace(/^\/+|\/+$/g, "").split("/").filter(Boolean);
|
|
58
|
+
if (segs.length < 2) throw new Error(`canvas URL needs at least <opp>/<canvas>: got "${canvasUrl}"`);
|
|
59
|
+
const sp = spaces();
|
|
60
|
+
const named = sp.find((s) => !s.isDefault && s.id === segs[0]);
|
|
61
|
+
const space = named || sp.find((s) => s.isDefault) || sp[0];
|
|
62
|
+
if (!space) throw new Error("no space repo found (need a sibling dir with space.json)");
|
|
63
|
+
const rest = named ? segs.slice(1) : segs; // drop the space-id prefix if present
|
|
64
|
+
if (rest.length < 2) throw new Error(`expected <opp>/<canvas> after the space, got "${rest.join("/")}"`);
|
|
65
|
+
const [opp, canvas] = rest;
|
|
66
|
+
const canvasDir = path.join(space.dir, opp, "prototypes", canvas);
|
|
67
|
+
if (!existsSync(path.join(canvasDir, "index.html")))
|
|
68
|
+
throw new Error(`no canvas at ${path.relative(PARENT, canvasDir)} (its index.html must exist first)`);
|
|
69
|
+
const boardPath = "/" + rest.join("/") + "/"; // KV board key path is the CANVAS url (space-prefixed on live via the URL you pass)
|
|
70
|
+
return { space, opp, canvas, canvasDir, canvasUrl: (named ? "/" + segs.join("/") : "/" + rest.join("/")) + "/", boardPath: (named ? "/" + segs.join("/") : "/" + rest.join("/")) + "/" };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ---- board mutations — THROUGH THE ROOM, never a raw KV overwrite --------------------------
|
|
74
|
+
// Since 2026-07-27 the BoardRoom DO persists the doc itself while anyone is on the board, so
|
|
75
|
+
// a direct POST /__board would be silently clobbered by the room's next write. The correct
|
|
76
|
+
// write path for every out-of-band tool is the same one the engine uses: join the room, send
|
|
77
|
+
// per-node ops, let the room fold them into the live doc and persist. When the room is empty
|
|
78
|
+
// this still works — the one-shot client seeds it from KV, ops apply, and the room flushes to
|
|
79
|
+
// KV when the client (last one out) disconnects.
|
|
80
|
+
import { ClawdCanvas } from "./clawd-canvas.mjs";
|
|
81
|
+
async function getBoard(canvasUrl) {
|
|
82
|
+
const r = await fetch(`${SITE}/__board?path=${encodeURIComponent(canvasUrl)}`);
|
|
83
|
+
const j = await r.json().catch(() => ({}));
|
|
84
|
+
return j.doc || { v: 1, name: "Untitled canvas", view: { x: 0, y: 0, scale: 1 }, nodes: [] };
|
|
85
|
+
}
|
|
86
|
+
// apply(doc) mutates a COPY of the live doc into the desired state; we then diff copy vs live
|
|
87
|
+
// and send the difference as upsert/del ops. Returns the room's resulting doc.
|
|
88
|
+
async function mutateBoard(boardPath, apply, confirm, label) {
|
|
89
|
+
const c = new ClawdCanvas({ boardPath, name: "canvas-screen" });
|
|
90
|
+
try {
|
|
91
|
+
await c.connect();
|
|
92
|
+
const before = JSON.parse(JSON.stringify(c.doc));
|
|
93
|
+
const desired = JSON.parse(JSON.stringify(c.doc));
|
|
94
|
+
desired.nodes = desired.nodes || [];
|
|
95
|
+
apply(desired);
|
|
96
|
+
const beforeIds = new Set((before.nodes || []).map((n) => n.id));
|
|
97
|
+
const desiredIds = new Set(desired.nodes.map((n) => n.id));
|
|
98
|
+
for (const n of desired.nodes) {
|
|
99
|
+
const old = (before.nodes || []).find((o) => o.id === n.id);
|
|
100
|
+
if (!old || JSON.stringify(old) !== JSON.stringify(n)) c.upsert(n);
|
|
101
|
+
}
|
|
102
|
+
for (const id of beforeIds) if (!desiredIds.has(id)) c.del(id);
|
|
103
|
+
await new Promise((r) => setTimeout(r, 600)); // let the ops land in the room
|
|
104
|
+
const ok = confirm(c.doc);
|
|
105
|
+
if (!ok) console.warn(`⚠ board ${label}: ops sent but end state not confirmed in the room doc.`);
|
|
106
|
+
return ok;
|
|
107
|
+
} finally {
|
|
108
|
+
try { c.close(); } catch {}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const uid = () => "n" + Math.random().toString(36).slice(2, 9);
|
|
112
|
+
|
|
113
|
+
// place a new tile clear of existing nodes: to the right of the rightmost, aligned to the top
|
|
114
|
+
function placeRightOf(nodes, w, h) {
|
|
115
|
+
const boxes = nodes.filter((n) => n.type !== "arrow" && typeof n.x === "number");
|
|
116
|
+
if (!boxes.length) return { x: -w / 2, y: -h / 2 };
|
|
117
|
+
const right = Math.max(...boxes.map((n) => n.x + (n.w || 240)));
|
|
118
|
+
const top = Math.min(...boxes.map((n) => n.y));
|
|
119
|
+
return { x: right + 60, y: top };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// The meta description is the screen's agent-facing meaning — one line stating the CLAIM
|
|
123
|
+
// the prototype makes (not what it looks like). The artifact owns it: update it in the
|
|
124
|
+
// same commit that changes the prototype. `ls` flags screens where it's missing/empty.
|
|
125
|
+
const escAttr = (s) => String(s || "").replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
|
126
|
+
const STARTER = (title, desc) => `<!DOCTYPE html>
|
|
127
|
+
<html lang="en">
|
|
128
|
+
<head>
|
|
129
|
+
<meta charset="utf-8" />
|
|
130
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
131
|
+
<meta name="description" content="${escAttr(desc)}" />
|
|
132
|
+
<title>${title}</title>
|
|
133
|
+
<style>
|
|
134
|
+
body { margin: 0; font: 16px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; color: #1a1a1a; }
|
|
135
|
+
.wrap { min-height: 100vh; display: grid; place-items: center; padding: 40px; box-sizing: border-box; text-align: center; background: #f4f5f7; }
|
|
136
|
+
h1 { margin: 0 0 8px; font-size: 22px; }
|
|
137
|
+
p { margin: 0; color: #6b7280; }
|
|
138
|
+
</style>
|
|
139
|
+
</head>
|
|
140
|
+
<body>
|
|
141
|
+
<div class="wrap">
|
|
142
|
+
<div>
|
|
143
|
+
<h1>${title}</h1>
|
|
144
|
+
<p>Scaffolded canvas screen — replace this with the real prototype.</p>
|
|
145
|
+
</div>
|
|
146
|
+
</div>
|
|
147
|
+
</body>
|
|
148
|
+
</html>
|
|
149
|
+
`;
|
|
150
|
+
|
|
151
|
+
// ---- commands ------------------------------------------------------------------------------
|
|
152
|
+
async function cmdAdd(canvasUrl, slug, title, desc) {
|
|
153
|
+
const { space, canvasDir, boardPath } = resolve(canvasUrl);
|
|
154
|
+
if (!/^[a-z0-9-]+$/.test(slug)) throw new Error("slug must be lowercase [a-z0-9-]");
|
|
155
|
+
const screenDir = path.join(canvasDir, slug);
|
|
156
|
+
const url = boardPath + slug + "/";
|
|
157
|
+
const name = title || slug;
|
|
158
|
+
if (!existsSync(screenDir)) {
|
|
159
|
+
mkdirSync(screenDir, { recursive: true });
|
|
160
|
+
writeFileSync(path.join(screenDir, "index.html"), STARTER(name, desc));
|
|
161
|
+
console.log(`✓ created ${path.relative(PARENT, path.join(screenDir, "index.html"))}`);
|
|
162
|
+
if (!desc) console.log(` ⚠ no --desc — fill the <meta name="description"> when you write the real prototype (ls flags opaque screens)`);
|
|
163
|
+
} else {
|
|
164
|
+
console.log(`• folder exists, keeping its index.html`);
|
|
165
|
+
}
|
|
166
|
+
const hasTile = (d) => (d.nodes || []).some((n) => n.type === "tile" && n.url === url);
|
|
167
|
+
await mutateBoard(boardPath, (d) => {
|
|
168
|
+
if (hasTile(d)) return;
|
|
169
|
+
const w = 560, h = 360, { x, y } = placeRightOf(d.nodes, w, h);
|
|
170
|
+
d.nodes.push({ id: uid(), type: "tile", x, y, w, h, url, name });
|
|
171
|
+
}, hasTile, "add-tile");
|
|
172
|
+
console.log(`✓ tile "${name}" → ${url} is on the canvas board`);
|
|
173
|
+
console.log(`\nNext: write the real prototype into that index.html, then commit + push the ${path.basename(space.dir)} repo.`);
|
|
174
|
+
console.log(`Lives at ${SITE}${url} once deployed.`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function cmdRm(canvasUrl, slug) {
|
|
178
|
+
const { canvasDir, boardPath } = resolve(canvasUrl);
|
|
179
|
+
const screenDir = path.join(canvasDir, slug);
|
|
180
|
+
const url = boardPath + slug + "/";
|
|
181
|
+
// 1) remove the tile from the board (retry through KV consistency until it's confirmed gone)
|
|
182
|
+
const gone = (d) => !(d.nodes || []).some((n) => n.type === "tile" && n.url === url);
|
|
183
|
+
await mutateBoard(boardPath, (d) => { d.nodes = (d.nodes || []).filter((n) => !(n.type === "tile" && n.url === url)); }, gone, "remove-tile");
|
|
184
|
+
console.log(`✓ tile ${url} removed from the board`);
|
|
185
|
+
// 2) delete the folder — owned-by-the-canvas means removing it deletes it in general
|
|
186
|
+
if (existsSync(screenDir)) { rmSync(screenDir, { recursive: true, force: true }); console.log(`✓ deleted ${path.relative(PARENT, screenDir)}`); }
|
|
187
|
+
else console.log(`• folder ${path.relative(PARENT, screenDir)} did not exist`);
|
|
188
|
+
console.log(`\nNext: commit + push the space repo so the deletion ships.`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
// Fork a canvas-owned screen: copy its folder to a new slug and point the DUPLICATE tile at
|
|
193
|
+
// it. This is the terminal half of "duplicate the tile ⇒ duplicate the folder": Cmd+D on the
|
|
194
|
+
// canvas clones only the tile (the browser can't write git), so both tiles briefly share one
|
|
195
|
+
// folder; before editing "the copy", run dup — it repoints the duplicate (by --tile name, or
|
|
196
|
+
// the one named "… copy", or the last sharer) at the fresh fork. If no duplicate tile exists
|
|
197
|
+
// yet it places a new one beside the source.
|
|
198
|
+
async function cmdDup(canvasUrl, srcSlug, newSlug, title, tileName) {
|
|
199
|
+
const { space, canvasDir, boardPath } = resolve(canvasUrl);
|
|
200
|
+
if (!/^[a-z0-9-]+$/.test(newSlug || "")) throw new Error("new slug must be lowercase [a-z0-9-]");
|
|
201
|
+
const srcDir = path.join(canvasDir, srcSlug), newDir = path.join(canvasDir, newSlug);
|
|
202
|
+
if (!existsSync(path.join(srcDir, "index.html"))) throw new Error(`no owned screen at ${srcSlug}`);
|
|
203
|
+
if (existsSync(newDir)) throw new Error(`${newSlug} already exists`);
|
|
204
|
+
cpSync(srcDir, newDir, { recursive: true });
|
|
205
|
+
console.log(`✓ forked ${path.relative(PARENT, srcDir)} → ${path.relative(PARENT, newDir)}`);
|
|
206
|
+
const srcUrl = boardPath + srcSlug + "/", newUrl = boardPath + newSlug + "/";
|
|
207
|
+
const name = title || newSlug;
|
|
208
|
+
const done = (d) => (d.nodes || []).some((n) => n.type === "tile" && n.url === newUrl);
|
|
209
|
+
await mutateBoard(boardPath, (d) => {
|
|
210
|
+
if (done(d)) return;
|
|
211
|
+
const sharers = d.nodes.filter((n) => n.type === "tile" && n.url === srcUrl);
|
|
212
|
+
let dupe = tileName ? sharers.find((n) => n.name === tileName) : null;
|
|
213
|
+
if (!dupe && sharers.length > 1) dupe = sharers.find((n) => / copy$/.test(n.name || "")) || sharers[sharers.length - 1];
|
|
214
|
+
if (dupe) { dupe.url = newUrl; dupe.name = name; delete dupe.liveUrl; delete dupe.thumb; }
|
|
215
|
+
else {
|
|
216
|
+
const src = sharers[0] || { x: 0, y: 0, w: 560, h: 360 };
|
|
217
|
+
d.nodes.push({ id: uid(), type: "tile", x: (src.x || 0) + 48, y: (src.y || 0) + 48, w: src.w || 560, h: src.h || 360, url: newUrl, name });
|
|
218
|
+
}
|
|
219
|
+
}, done, "fork-tile");
|
|
220
|
+
console.log(`✓ tile "${name}" → ${newUrl} on the board`);
|
|
221
|
+
console.log(`\nNext: edit the fork (${path.relative(PARENT, newDir)}), then commit + push the ${path.basename(space.dir)} repo.`);
|
|
222
|
+
console.log(`The fork inherits the source's <meta name="description"> — update it when the fork diverges.`);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Pull a screen's <meta name="description"> — its agent-facing meaning. Null = opaque.
|
|
226
|
+
function screenDesc(dir) {
|
|
227
|
+
try {
|
|
228
|
+
const html = readFileSync(path.join(dir, "index.html"), "utf8");
|
|
229
|
+
// exclude only the active delimiter — [^"'] would truncate at an inner apostrophe
|
|
230
|
+
const m = html.match(/<meta\s+name=["']description["']\s+content="([^"]*)"/i)
|
|
231
|
+
|| html.match(/<meta\s+name=["']description["']\s+content='([^']*)'/i)
|
|
232
|
+
|| html.match(/<meta\s+content="([^"]*)"\s+name=["']description["']/i)
|
|
233
|
+
|| html.match(/<meta\s+content='([^']*)'\s+name=["']description["']/i);
|
|
234
|
+
return m && m[1].trim() ? m[1].trim() : null;
|
|
235
|
+
} catch { return null; }
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function cmdLs(canvasUrl) {
|
|
239
|
+
const { canvasDir, boardPath } = resolve(canvasUrl);
|
|
240
|
+
const folders = readdirSync(canvasDir, { withFileTypes: true })
|
|
241
|
+
.filter((e) => e.isDirectory() && existsSync(path.join(canvasDir, e.name, "index.html")))
|
|
242
|
+
.map((e) => e.name);
|
|
243
|
+
const doc = await getBoard(boardPath);
|
|
244
|
+
const tileUrls = new Set((doc.nodes || []).filter((n) => n.type === "tile").map((n) => n.url));
|
|
245
|
+
console.log(`Canvas ${boardPath} — owned screens (nested folders) vs board tiles:\n`);
|
|
246
|
+
for (const f of folders) {
|
|
247
|
+
const url = boardPath + f + "/";
|
|
248
|
+
const desc = screenDesc(path.join(canvasDir, f));
|
|
249
|
+
console.log(` ${tileUrls.has(url) ? "●" : "○ (folder, no tile — orphaned)"} ${f} → ${url}`);
|
|
250
|
+
console.log(` ${desc ? `"${desc}"` : "⚠ no meta description — opaque to agents"}`);
|
|
251
|
+
}
|
|
252
|
+
if (folders.some((f) => !tileUrls.has(boardPath + f + "/")))
|
|
253
|
+
console.log(`\n → orphaned folders are meant to disappear: run \`canvas-screen gc ${boardPath}\` (1h grace for undo)`);
|
|
254
|
+
// tiles pointing under this canvas but with no folder = a dangling tile
|
|
255
|
+
for (const n of (doc.nodes || []).filter((n) => n.type === "tile" && n.url && n.url.startsWith(boardPath) && n.url !== boardPath)) {
|
|
256
|
+
const slug = n.url.slice(boardPath.length).replace(/\/$/, "");
|
|
257
|
+
if (!folders.includes(slug)) console.log(` ⚠ tile with no folder: ${n.url} (dangling)`);
|
|
258
|
+
}
|
|
259
|
+
if (!folders.length) console.log(" (no owned screens yet)");
|
|
260
|
+
// the board's other opaque nodes: images without a desc field
|
|
261
|
+
const blind = (doc.nodes || []).filter((n) => n.type === "image" && !(n.desc && String(n.desc).trim()));
|
|
262
|
+
if (blind.length) {
|
|
263
|
+
console.log(`\n ⚠ ${blind.length} image node(s) without desc (opaque to agents):`);
|
|
264
|
+
for (const n of blind) console.log(` ${n.name || n.id}`);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Orphan garbage collection: a screen folder whose tile was deleted in the canvas UI is
|
|
269
|
+
// meant to DISAPPEAR (ownership coupling), but not instantly — a 1h grace covers undo.
|
|
270
|
+
// Grace is anchored at the first gc observation (the board doc carries no deletion times),
|
|
271
|
+
// so deletion happens at the first gc run at least an hour after an orphan was noticed.
|
|
272
|
+
const GC_GRACE_MS = 60 * 60 * 1000;
|
|
273
|
+
const GC_STATE = path.join(PARENT, ".canvas-gc.json");
|
|
274
|
+
async function cmdGc(canvasUrl) {
|
|
275
|
+
const { space, canvasDir, boardPath } = resolve(canvasUrl);
|
|
276
|
+
const folders = readdirSync(canvasDir, { withFileTypes: true })
|
|
277
|
+
.filter((e) => e.isDirectory() && existsSync(path.join(canvasDir, e.name, "index.html")))
|
|
278
|
+
.map((e) => e.name);
|
|
279
|
+
const doc = await getBoard(boardPath);
|
|
280
|
+
const tileUrls = new Set((doc.nodes || []).filter((n) => n.type === "tile").map((n) => n.url));
|
|
281
|
+
let state = {};
|
|
282
|
+
try { state = JSON.parse(readFileSync(GC_STATE, "utf8")); } catch {}
|
|
283
|
+
const now = Date.now();
|
|
284
|
+
const deleted = [];
|
|
285
|
+
for (const f of folders) {
|
|
286
|
+
const url = boardPath + f + "/";
|
|
287
|
+
if (tileUrls.has(url)) {
|
|
288
|
+
if (state[url]) { delete state[url]; console.log(`● ${f}: tile is back on the board, grace cleared`); }
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
if (!state[url]) {
|
|
292
|
+
state[url] = now;
|
|
293
|
+
console.log(`○ ${f}: orphaned (no tile) — noticed now, deleting after a 1h grace (rerun gc later)`);
|
|
294
|
+
} else if (now - state[url] >= GC_GRACE_MS) {
|
|
295
|
+
rmSync(path.join(canvasDir, f), { recursive: true, force: true });
|
|
296
|
+
delete state[url];
|
|
297
|
+
deleted.push(f);
|
|
298
|
+
console.log(`✗ ${f}: grace expired, folder deleted`);
|
|
299
|
+
} else {
|
|
300
|
+
console.log(`○ ${f}: orphaned, ${Math.ceil((GC_GRACE_MS - (now - state[url])) / 60000)}m of grace left`);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
// stamps whose folder is gone (deleted here or by hand) are spent
|
|
304
|
+
for (const url of Object.keys(state)) {
|
|
305
|
+
if (!url.startsWith(boardPath)) continue;
|
|
306
|
+
const slug = url.slice(boardPath.length).replace(/\/$/, "");
|
|
307
|
+
if (!folders.includes(slug) || deleted.includes(slug)) delete state[url];
|
|
308
|
+
}
|
|
309
|
+
mkdirSync(path.dirname(GC_STATE), { recursive: true });
|
|
310
|
+
writeFileSync(GC_STATE, JSON.stringify(state, null, 1));
|
|
311
|
+
if (!folders.length) console.log("(no owned screens)");
|
|
312
|
+
if (deleted.length) console.log(`\nNext: commit + push the ${path.basename(space.dir)} repo so the deletion ships.`);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ---- cli -----------------------------------------------------------------------------------
|
|
316
|
+
const [cmd, canvasUrl, slug] = process.argv.slice(2);
|
|
317
|
+
const titleIdx = process.argv.indexOf("--title");
|
|
318
|
+
const title = titleIdx > -1 ? process.argv[titleIdx + 1] : "";
|
|
319
|
+
try {
|
|
320
|
+
const tileIdx = process.argv.indexOf("--tile");
|
|
321
|
+
const tileName = tileIdx > -1 ? process.argv[tileIdx + 1] : "";
|
|
322
|
+
const descIdx = process.argv.indexOf("--desc");
|
|
323
|
+
const desc = descIdx > -1 ? process.argv[descIdx + 1] : "";
|
|
324
|
+
if (cmd === "add" && canvasUrl && slug) await cmdAdd(canvasUrl, slug, title, desc);
|
|
325
|
+
else if (cmd === "dup" && canvasUrl && slug && process.argv[5] && !process.argv[5].startsWith("--")) await cmdDup(canvasUrl, slug, process.argv[5], title, tileName);
|
|
326
|
+
else if (cmd === "rm" && canvasUrl && slug) await cmdRm(canvasUrl, slug);
|
|
327
|
+
else if (cmd === "ls" && canvasUrl) await cmdLs(canvasUrl);
|
|
328
|
+
else if (cmd === "gc" && canvasUrl) await cmdGc(canvasUrl);
|
|
329
|
+
else {
|
|
330
|
+
console.log("usage:\n canvas-screen add <canvasUrl> <slug> [--title \"T\"] [--desc \"one-line claim the screen makes\"]\n canvas-screen dup <canvasUrl> <srcSlug> <newSlug> [--title \"T\"] [--tile \"Dup tile name\"]\n canvas-screen rm <canvasUrl> <slug>\n canvas-screen ls <canvasUrl> (also flags opaque screens/images — no description)\n canvas-screen gc <canvasUrl> (delete folders orphaned by in-canvas tile deletion, 1h grace)");
|
|
331
|
+
process.exit(1);
|
|
332
|
+
}
|
|
333
|
+
} catch (e) { console.error("✗ " + e.message); process.exit(1); }
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* check-local — run every gate `.github/workflows/check.yml` runs, here, before pushing.
|
|
4
|
+
*
|
|
5
|
+
* WHY. `check` gates the deploy: `deploy-trigger.yml` only dispatches when it is green,
|
|
6
|
+
* so a red check does not break a live instance — it silently STOPS the engine reaching
|
|
7
|
+
* one. A push whose check fails looks exactly like a push that shipped, from this side,
|
|
8
|
+
* and the instance that auto-bumps within a minute simply never bumps.
|
|
9
|
+
*
|
|
10
|
+
* That already happened once. A commit landed on main naming a person in a comment, the
|
|
11
|
+
* repo-wide word scan caught it in CI, the deploy trigger skipped, and the engine sat one
|
|
12
|
+
* commit behind on every instance until someone looked at the run list. Every check runs
|
|
13
|
+
* locally in seconds; there was no reason not to have run them.
|
|
14
|
+
*
|
|
15
|
+
* THE WORD SCAN IS THE ONE THAT NEEDED THIS. The other five gates are node scripts anyone
|
|
16
|
+
* would think to run. The word scan is a shell grep that only exists inside the workflow
|
|
17
|
+
* YAML, so it was the one gate with no local form at all — and it is the one that fires
|
|
18
|
+
* on ordinary prose written into a comment without thinking, which is the easiest mistake
|
|
19
|
+
* in this repo to make.
|
|
20
|
+
*
|
|
21
|
+
* IF YOU EDIT check.yml, EDIT THIS. They are two copies of one list. The last check below
|
|
22
|
+
* compares them and fails when the workflow grows a step this file does not run, so the
|
|
23
|
+
* drift is caught rather than discovered.
|
|
24
|
+
*
|
|
25
|
+
* Run: npm run check (exit 1 on the first failure, like the workflow)
|
|
26
|
+
*/
|
|
27
|
+
import fs from "node:fs";
|
|
28
|
+
import path from "node:path";
|
|
29
|
+
import { fileURLToPath } from "node:url";
|
|
30
|
+
import { execFileSync, execSync } from "node:child_process";
|
|
31
|
+
|
|
32
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
33
|
+
const WORKFLOW = path.join(ROOT, ".github", "workflows", "check.yml");
|
|
34
|
+
|
|
35
|
+
// The repo-wide instance/product/personal word scan, kept BYTE-IDENTICAL to the pattern in
|
|
36
|
+
// check.yml. It is read out of the workflow rather than duplicated here, so the two can
|
|
37
|
+
// never disagree — and so this file does not itself become a list of the words (which is
|
|
38
|
+
// why check.yml excludes itself from the scan, and why this script must be excluded too).
|
|
39
|
+
function wordScanPattern() {
|
|
40
|
+
const yml = fs.readFileSync(WORKFLOW, "utf8");
|
|
41
|
+
const m = yml.match(/^\s*'([^']*go\[[^']*)'\s*\\?\s*$/m);
|
|
42
|
+
if (!m) throw new Error("could not find the word-scan pattern in check.yml — has the step changed shape?");
|
|
43
|
+
return m[1];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const steps = [
|
|
47
|
+
["Doc drift (doc-lint)", () => run("node", ["scripts/doc-lint.mjs"])],
|
|
48
|
+
["No new module-scope tenant state", () => run("node", ["scripts/no-tenant-globals.mjs"])],
|
|
49
|
+
["One tenant resolver, one call site", () => run("node", ["scripts/one-tenant-resolver.mjs"])],
|
|
50
|
+
["No foreign vocabulary", () => run("node", ["scripts/no-foreign-vocabulary.mjs"])],
|
|
51
|
+
["No other company's product named", () => run("node", ["scripts/no-product-names.mjs"])],
|
|
52
|
+
["Empty states are a ghost and one line", () => run("node", ["scripts/ui-copy-lint.mjs"])],
|
|
53
|
+
["The state inventory still names everything", () => run("node", ["scripts/state-inventory.mjs"])],
|
|
54
|
+
["No instance, product, or personal words", wordScan],
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
function run(cmd, args) {
|
|
58
|
+
const out = execFileSync(cmd, args, { cwd: ROOT, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
|
59
|
+
return out.trim().split("\n").pop();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function wordScan() {
|
|
63
|
+
// WHAT TO SCAN: everything the repository WILL contain if you commit — tracked files plus
|
|
64
|
+
// untracked-but-not-ignored ones. Neither half alone is right, and both halves were
|
|
65
|
+
// learned the same way.
|
|
66
|
+
//
|
|
67
|
+
// Not the whole working tree: a local tree also holds everything .gitignore covers —
|
|
68
|
+
// agent scratch, notes, old reports — which are full of these words on purpose and are
|
|
69
|
+
// going nowhere near a deploy. Grepping `.` reported hundreds of findings CI cannot see,
|
|
70
|
+
// which trains whoever runs this to scroll past its output.
|
|
71
|
+
//
|
|
72
|
+
// And not the tracked set alone: a brand-new file is invisible to `git ls-files` until
|
|
73
|
+
// it is added, so that reports OK on exactly the file you are about to commit, and then
|
|
74
|
+
// CI — which sees it — goes red. That happened: a new test used a person's name as a
|
|
75
|
+
// fixture, this check passed because the file was not staged yet, and the push landed
|
|
76
|
+
// with a red gate and a skipped deploy.
|
|
77
|
+
//
|
|
78
|
+
// A brand-new file is invisible to `git ls-files` until it is added, so scanning only the
|
|
79
|
+
// tracked set reports OK on exactly the file you are about to commit — and then CI, which
|
|
80
|
+
// sees it, goes red. That happened: a new test used a person's name as a fixture, this
|
|
81
|
+
// check passed because the file was not staged yet, and the push landed with a red gate
|
|
82
|
+
// and a skipped deploy. Untracked-and-not-ignored is precisely "what the repository will
|
|
83
|
+
// contain if you commit", which is what CI is going to scan.
|
|
84
|
+
//
|
|
85
|
+
// Ignored files stay out, which is what keeps agent scratch from swamping the output.
|
|
86
|
+
const ls = (args) => execSync(`git ${args}`, { cwd: ROOT, encoding: "buffer" })
|
|
87
|
+
.toString("utf8").split("\0").filter(Boolean);
|
|
88
|
+
const tracked = [...ls("ls-files -z"), ...ls("ls-files -o --exclude-standard -z")]
|
|
89
|
+
// check.yml necessarily writes the words down; so does this file's own excludes.
|
|
90
|
+
.filter((f) => f !== ".github/workflows/check.yml" && f !== "scripts/check-local.mjs")
|
|
91
|
+
.filter((f, i, a) => a.indexOf(f) === i);
|
|
92
|
+
|
|
93
|
+
// grep exit codes: 0 = found (FAIL), 1 = clean (pass), >=2 = the scan itself broke.
|
|
94
|
+
// Treating >=2 as a pass is how a renamed path turns a guard into a no-op.
|
|
95
|
+
let rc = 1, out = "";
|
|
96
|
+
try {
|
|
97
|
+
out = execFileSync("grep", ["-InEi", "--binary-files=without-match", wordScanPattern(), ...tracked],
|
|
98
|
+
{ cwd: ROOT, encoding: "utf8", maxBuffer: 32 * 1024 * 1024 });
|
|
99
|
+
rc = 0;
|
|
100
|
+
} catch (e) {
|
|
101
|
+
rc = e.status;
|
|
102
|
+
out = (e.stdout || "") + (e.stderr || "");
|
|
103
|
+
}
|
|
104
|
+
if (rc === 0) throw new Error(`instance/product/personal word found:\n${out.trim()}`);
|
|
105
|
+
if (rc >= 2) throw new Error(`the word scan failed to RUN (grep rc=${rc}) — that is a failure, never a pass:\n${out}`);
|
|
106
|
+
return `clean — ${tracked.length} files (tracked + untracked), no instance/product words`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
let failed = 0;
|
|
110
|
+
for (const [name, fn] of steps) {
|
|
111
|
+
try {
|
|
112
|
+
const last = fn();
|
|
113
|
+
console.log(` ok ${name}${last ? ` — ${last.slice(0, 90)}` : ""}`);
|
|
114
|
+
} catch (e) {
|
|
115
|
+
failed++;
|
|
116
|
+
console.log(` FAIL ${name}`);
|
|
117
|
+
const msg = (e.stdout || "") + (e.stderr || "") || e.message;
|
|
118
|
+
console.log(msg.split("\n").map((l) => ` ${l}`).join("\n"));
|
|
119
|
+
break; // the workflow stops at the first failing step; so does this
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Drift: a step added to the workflow that this file does not run would make a green local
|
|
124
|
+
// check mean less than it says.
|
|
125
|
+
if (!failed) {
|
|
126
|
+
const names = [...fs.readFileSync(WORKFLOW, "utf8").matchAll(/^\s*-\s*name:\s*(.+)$/gm)].map((m) => m[1].trim());
|
|
127
|
+
const missing = names.filter((n) => !steps.some(([s]) => n.toLowerCase().startsWith(s.toLowerCase().slice(0, 18))));
|
|
128
|
+
if (missing.length) {
|
|
129
|
+
console.log(`\n FAIL check.yml has step(s) this script does not run: ${missing.join(", ")}`);
|
|
130
|
+
console.log(" Add them here. A local check that is a subset of CI is a local check that lies.");
|
|
131
|
+
process.exit(1);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
console.log(failed ? "\ncheck-local: FAILED — this push would not deploy" : "\ncheck-local: OK — every gate check.yml runs is green here");
|
|
136
|
+
process.exit(failed ? 1 : 0);
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// clawd-art — the Clawd mascot as tintable pixel-SVG, with expression STATES.
|
|
2
|
+
// One shared 14x12 pixel grid; each expression overrides eyes/mouth/arms/accessories.
|
|
3
|
+
// Body pixels ('B') take the peer's color ({C}); features are fixed hues. A white outline
|
|
4
|
+
// (feMorphology) keeps Clawd legible on any canvas background. clawdSvg(expr, color) -> string.
|
|
5
|
+
//
|
|
6
|
+
// States (so the cursor can "act"): idle · coding · thinking · happy · sleeping · love ·
|
|
7
|
+
// sunglasses · handsUp. Add more from the official sheet by adding a pixel map below.
|
|
8
|
+
|
|
9
|
+
const U = 4; // px per grid cell
|
|
10
|
+
const COLS = 14, ROWS = 12;
|
|
11
|
+
const K = '#1a1a1a', WHITE = '#fff', RED = '#e03131', BLUE = '#4dabf7', YELLOW = '#ffd43b', GREY = '#adb5bd';
|
|
12
|
+
|
|
13
|
+
// base body silhouette (B). Head block + small side-arm nubs (rows 6-7 cols 0 & 13) + 4 feet.
|
|
14
|
+
const BODY = [
|
|
15
|
+
'..BBBBBBBBBB..',
|
|
16
|
+
'.BBBBBBBBBBBB.',
|
|
17
|
+
'.BBBBBBBBBBBB.',
|
|
18
|
+
'.BBBBBBBBBBBB.',
|
|
19
|
+
'.BBBBBBBBBBBB.',
|
|
20
|
+
'.BBBBBBBBBBBB.',
|
|
21
|
+
'BBBBBBBBBBBBBB',
|
|
22
|
+
'BBBBBBBBBBBBBB',
|
|
23
|
+
'.BBBBBBBBBBBB.',
|
|
24
|
+
'.BBBBBBBBBBBB.',
|
|
25
|
+
'.BB.BB..BB.BB.',
|
|
26
|
+
'.BB.BB..BB.BB.',
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
// feature layers per expression: [row, col, color] cells painted ON TOP of the body
|
|
30
|
+
function eyes(open = true, color = K) {
|
|
31
|
+
// two 2x2 eyes at rows 3-4, cols 4-5 and 8-9
|
|
32
|
+
const c = [];
|
|
33
|
+
const rows = open ? [3, 4] : [4];
|
|
34
|
+
for (const r of rows) for (const col of [4, 5, 8, 9]) c.push([r, col, color]);
|
|
35
|
+
return c;
|
|
36
|
+
}
|
|
37
|
+
const EXPR = {
|
|
38
|
+
idle: () => eyes(true),
|
|
39
|
+
// squint (single-row eyes) = focused on the work
|
|
40
|
+
coding: () => [...eyes(false), [3, 4, K], [3, 9, K]],
|
|
41
|
+
// normal eyes + a trailing "..." up to the right
|
|
42
|
+
thinking: () => [...eyes(true), [1, 12, K], [0, 13, K], [2, 11, K]],
|
|
43
|
+
// upward (top-row) eyes = happy squint + tiny rosy cheeks
|
|
44
|
+
happy: () => [[3, 4, K], [3, 5, K], [3, 8, K], [3, 9, K], [5, 3, '#ff8787'], [5, 10, '#ff8787']],
|
|
45
|
+
// closed dash eyes + "z z" drifting up-right
|
|
46
|
+
sleeping: () => [...eyes(false), [1, 12, GREY], [0, 13, GREY]],
|
|
47
|
+
// red hearts for eyes + a floating heart
|
|
48
|
+
love: () => [[3, 4, RED], [3, 5, RED], [4, 4, RED], [3, 8, RED], [3, 9, RED], [4, 8, RED], [0, 11, RED], [0, 12, RED]],
|
|
49
|
+
// solid black bar across the eyes
|
|
50
|
+
sunglasses: () => { const c = []; for (const col of [3, 4, 5, 6, 7, 8, 9, 10]) c.push([3, col, K]); for (const col of [4, 5, 8, 9]) c.push([4, col, K]); return c; },
|
|
51
|
+
// idle face but arms raised (extra body pixels up at the top corners)
|
|
52
|
+
handsUp: () => [...eyes(true), [0, 0, 'B'], [1, 0, 'B'], [0, 1, 'B'], [0, 13, 'B'], [1, 13, 'B'], [0, 12, 'B']],
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export const CLAWD_STATES = Object.keys(EXPR);
|
|
56
|
+
|
|
57
|
+
export function clawdSvg(expr = 'idle', color = '#d97757') {
|
|
58
|
+
const cells = new Map(); // "r,c" -> color
|
|
59
|
+
BODY.forEach((row, r) => [...row].forEach((ch, c) => { if (ch === 'B') cells.set(r + ',' + c, 'B'); }));
|
|
60
|
+
(EXPR[expr] || EXPR.idle)().forEach(([r, c, col]) => cells.set(r + ',' + c, col));
|
|
61
|
+
let rects = '';
|
|
62
|
+
for (const [k, col] of cells) {
|
|
63
|
+
const [r, c] = k.split(',').map(Number);
|
|
64
|
+
const fill = col === 'B' ? '{C}' : col;
|
|
65
|
+
rects += `<rect x="${c * U}" y="${r * U}" width="${U}" height="${U}" fill="${fill}"/>`;
|
|
66
|
+
}
|
|
67
|
+
const W = COLS * U, H = ROWS * U;
|
|
68
|
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">` +
|
|
69
|
+
`<defs><filter id="clawd-o" x="-20%" y="-20%" width="140%" height="140%">` +
|
|
70
|
+
`<feMorphology in="SourceAlpha" operator="dilate" radius="1.3" result="d"/>` +
|
|
71
|
+
`<feFlood flood-color="${WHITE}"/><feComposite in2="d" operator="in" result="o"/>` +
|
|
72
|
+
`<feMerge><feMergeNode in="o"/><feMergeNode in="SourceGraphic"/></feMerge></filter></defs>` +
|
|
73
|
+
`<g filter="url(#clawd-o)">${rects}</g></svg>`;
|
|
74
|
+
return svg.replace(/\{C\}/g, color);
|
|
75
|
+
}
|