@augurworks/augur 0.15.2 → 0.15.3

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,400 @@
1
+ // galleries.mjs — the pages a workspace DERIVES, rendered at serve time from the live store.
2
+ //
3
+ // docs/drafts-that-land.md §6.4. The gallery, each opportunity's index, the playground, the
4
+ // library tiers and the search index used to be baked by a client's build and shipped with a
5
+ // publish, so a landing could not appear on them until somebody published a tree. Here they
6
+ // are a pure function of what the store holds: the live manifest (units, files, per-file
7
+ // provenance), the status overlay, the roster (ids → faces) and the design-system catalog.
8
+ // The worker calls this where drafts are served; the markup keeps the card contract the
9
+ // chrome bundle's scripts read (class names and data-* attributes), so pins, renames, status
10
+ // clicks, currency, faces and the finder keep working on a derived page exactly as they did
11
+ // on a baked one.
12
+ //
13
+ // ⚠️ NO NODE IMPORTS, NO I/O. The worker runs this per request.
14
+ import { renderAppChrome, renderSpaceContextScript, CHROME_MARK_START, CHROME_MARK_END, UI_VERSION, escAttr, titleCase, fmtDate, relTime } from "./chrome/appchrome.mjs";
15
+ import { authoredUnits } from "./publish-units.mjs";
16
+ import { unitProvenance, unitKey, STATUS_LABELS } from "./currency.mjs";
17
+
18
+ export const TIERS = Object.freeze(["base", "components", "patterns", "pages"]);
19
+ const TIER_TITLE = Object.freeze({ base: "Base", components: "Components", patterns: "Patterns", pages: "Pages" });
20
+
21
+ const STATUS_META = Object.freeze({
22
+ "in-progress": { label: STATUS_LABELS["in-progress"], cls: "is-wip" },
23
+ "dev-ready": { label: STATUS_LABELS["dev-ready"], cls: "is-ready" },
24
+ ignore: { label: STATUS_LABELS.ignore, cls: "is-ignore" },
25
+ });
26
+ const STATUS_ICONS = Object.freeze({
27
+ "dev-ready": '<svg viewBox="0 0 20 20" aria-hidden="true"><circle cx="10" cy="10" r="9" fill="#17935a"/><path d="M5.8 10.4l2.7 2.7 5.7-6" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>',
28
+ "in-progress": '<svg viewBox="0 0 20 20" aria-hidden="true"><circle cx="10" cy="10" r="8" fill="none" stroke="#1c1c22" stroke-width="2.2"/><path d="M10 2.8a7.2 7.2 0 0 1 0 14.4z" fill="#1c1c22"/></svg>',
29
+ ignore: '<svg viewBox="0 0 20 20" aria-hidden="true"><circle cx="10" cy="10" r="8" fill="none" stroke="#aeb3bd" stroke-width="2.2"/><line x1="6.4" y1="10" x2="13.6" y2="10" stroke="#aeb3bd" stroke-width="2.2" stroke-linecap="round"/></svg>',
30
+ });
31
+ const STATUS_RANK = Object.freeze({ "dev-ready": 0, "in-progress": 1, ignore: 2 });
32
+ const IC_STAR = `<svg class="pin-star" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z"/></svg>`;
33
+ // Spelled out rather than built with `ic()`: the module-scope lint reads a call initializer
34
+ // as state it cannot prove pure, and a literal costs nothing.
35
+ const IC_PLUS = `<svg class="gvic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5v14"/><path d="M5 12h14"/></svg>`;
36
+ const EMOJI_POOL = Object.freeze(["🗳️", "🏛️", "📊", "🧭", "🛰️", "🧩", "🪧", "🌳", "🚲", "📣", "🗺️", "🧪", "💡", "🔭", "🪟", "🧱", "🎛️", "🛣️", "🧰", "📐", "🧮", "🗂️", "🔔", "🏘️", "🌍", "💬", "📝", "🚏", "🏙️", "🌿", "🎚️", "🧷"]);
37
+
38
+ const dec = (s) => { try { return decodeURIComponent(String(s)); } catch (e) { return String(s); } };
39
+ const enc = (s) => encodeURIComponent(s);
40
+ const plural = (n, word) => `${n} ${n === 1 ? word : word.endsWith("y") ? word.slice(0, -1) + "ies" : word + "s"}`;
41
+ function protoEmoji(slug) {
42
+ let h = 0;
43
+ for (let i = 0; i < slug.length; i++) h = (h * 31 + slug.charCodeAt(i)) >>> 0;
44
+ return EMOJI_POOL[h % EMOJI_POOL.length];
45
+ }
46
+ const protoName = (slug) => `${protoEmoji(slug)} ${titleCase(slug)}`;
47
+
48
+ // ── the site model ───────────────────────────────────────────────────────────
49
+ /** Which list a unit belongs to, from its path alone. */
50
+ export function unitHome(unit) {
51
+ const segs = dec(unit).split("/").filter(Boolean);
52
+ if (segs.length !== 2) return null;
53
+ if (segs[0] === "playground") return { kind: "playground", name: segs[1] };
54
+ if (TIERS.includes(segs[0])) return { kind: "tier", tier: segs[0], name: segs[1] };
55
+ if (segs[0].startsWith("_") || segs[0] === "skills") return null;
56
+ return { kind: "opportunity", opp: segs[0], name: segs[1] };
57
+ }
58
+
59
+ function unitEntry(manifest, unit, home, { statuses, baseline, people, now }) {
60
+ const files = manifest.files || {};
61
+ const prefix = dec(unit);
62
+ const inUnit = Object.keys(files).filter((p) => dec(p).startsWith(prefix));
63
+ const rel = (p) => dec(p).slice(prefix.length);
64
+ const hasIndex = inUnit.some((p) => rel(p) === "index.html");
65
+ const firstHtml = inUnit.map(rel).filter((r) => !r.includes("/") && r.endsWith(".html")).sort()[0] || null;
66
+ const file = hasIndex ? `${unit}index.html` : firstHtml ? `${unit}${enc(firstHtml)}` : null;
67
+ const { editedAt, by } = unitProvenance(manifest, unit);
68
+ const key = unitKey(unit);
69
+ const status = (statuses && Object.prototype.hasOwnProperty.call(statuses, key) && statuses[key])
70
+ || (baseline && baseline[key]) || null;
71
+ const ids = [...new Set(inUnit.map((p) => files[p] && files[p].by).filter(Boolean))];
72
+ const editors = ids.map((id) => people(id)).filter(Boolean);
73
+ return {
74
+ name: home.name, unit, href: unit, file,
75
+ poster: inUnit.some((p) => rel(p) === "preview.webp"),
76
+ editedAt: editedAt || null,
77
+ mtimeMs: editedAt ? Date.parse(editedAt) : 0,
78
+ status, editors, by,
79
+ };
80
+ }
81
+
82
+ const byRecency = (a, b) => b.mtimeMs - a.mtimeMs || a.name.localeCompare(b.name);
83
+ const byStatusThenRecency = (a, b) => {
84
+ const ra = a.status in STATUS_RANK ? STATUS_RANK[a.status] : 2;
85
+ const rb = b.status in STATUS_RANK ? STATUS_RANK[b.status] : 2;
86
+ return ra - rb || byRecency(a, b);
87
+ };
88
+
89
+ /**
90
+ * Everything the derived pages need, from the live manifest and the overlays.
91
+ * `people(id) → {id, name, initials, color} | null` resolves a recorded author id.
92
+ */
93
+ export function siteModel({ manifest, statuses = {}, baseline = {}, people = () => null, now = Date.now() }) {
94
+ const opps = new Map(), playground = [], tiers = { base: [], components: [], patterns: [], pages: [] };
95
+ const units = [];
96
+ for (const unit of authoredUnits(manifest || {})) {
97
+ const home = unitHome(unit);
98
+ if (!home) continue;
99
+ const e = unitEntry(manifest, unit, home, { statuses, baseline, people, now });
100
+ units.push({ ...e, home });
101
+ if (home.kind === "playground") { playground.push({ ...e, status: e.status || "in-progress" }); continue; }
102
+ if (home.kind === "tier") { tiers[home.tier].push(e); continue; }
103
+ if (!opps.has(home.opp)) opps.set(home.opp, { name: home.opp, prototypes: [] });
104
+ opps.get(home.opp).prototypes.push(e);
105
+ }
106
+ const opportunities = [...opps.values()].map((o) => {
107
+ o.prototypes.sort(byStatusThenRecency);
108
+ const seen = new Map();
109
+ for (const p of o.prototypes) for (const u of p.editors) if (!seen.has(u.id)) seen.set(u.id, u);
110
+ return { ...o, people: [...seen.values()], mtimeMs: Math.max(0, ...o.prototypes.map((p) => p.mtimeMs)) };
111
+ }).sort(byRecency);
112
+ playground.sort(byStatusThenRecency);
113
+ for (const t of TIERS) tiers[t].sort(byRecency);
114
+ return { opportunities, playground, tiers, hasPlayground: playground.length > 0, units };
115
+ }
116
+
117
+ /** What a request path names on the derived surface, or null for anything else. */
118
+ export function derivedPathKind(pathname, model) {
119
+ const p = dec(pathname);
120
+ if (p === "/" || p === "/index.html") return { kind: "root" };
121
+ if (p === "/__search.json") return { kind: "search" };
122
+ const m = /^\/([^/]+)\/?(index\.html)?$/.exec(p);
123
+ if (!m) return null;
124
+ const seg = m[1];
125
+ if (seg === "playground") return model.hasPlayground || p.endsWith("/") ? { kind: "playground" } : null;
126
+ if (TIERS.includes(seg)) return { kind: seg === "components" ? "components" : "tier", tier: seg };
127
+ if (model.opportunities.some((o) => o.name === seg)) return { kind: "opportunity", name: seg, slash: p.endsWith("/") || p.endsWith("index.html") };
128
+ return null;
129
+ }
130
+
131
+ // ── markup ───────────────────────────────────────────────────────────────────
132
+ const media = (href, hasPoster) => hasPoster
133
+ ? `<img class="preview-img" src="${href}preview.webp" alt="" aria-hidden="true" loading="lazy" decoding="async" width="768" height="480" />`
134
+ : `<div class="preview-ph" aria-hidden="true"></div>`;
135
+ const preview = (href, hasPoster) => `<div class="preview${hasPoster ? "" : " preview--ph"}">${media(href, hasPoster)}</div>`;
136
+ const filterEmpty = () => `<p class="filter-empty" data-filter-empty hidden>No matches.</p>`;
137
+ const pinStar = (key, href) => `<button type="button" class="pin-btn" data-pin-key="${key}" data-pin-href="${href}" aria-pressed="false" aria-label="Pin to sidebar" title="Pin to sidebar">${IC_STAR}</button>`;
138
+ const newCanvasBtn = (dir) => `<button type="button" class="folderbar__new" data-new-canvas="${dir}" hidden>${IC_PLUS}New canvas</button>`;
139
+ const emptyState = (...paras) => paras.map((p) => `<p class="empty">${p}</p>`).join("");
140
+ const emptyHead = (title, keepOnMobile = false) =>
141
+ `<header class="folderbar${keepOnMobile ? " folderbar--keep" : ""}"><h1 class="folderbar__title">${title}</h1><span class="folderbar__count">0</span><span class="folderbar__rule"></span></header>`;
142
+ const ghostLine = (w) => `<span class="ghost-line" style="width:${w}"></span>`;
143
+ const ghosts = (inner) => `<div class="ghosts" aria-hidden="true">${inner}</div>`;
144
+ const ghostFolderGrid = (n = 6) => ghosts(`<div class="opp-grid">${Array.from({ length: n }, () => `<div class="card-opp"><div class="preview preview--pending"></div><div class="proto-meta"><div class="proto-text">${ghostLine("58%")}${ghostLine("30%")}</div></div></div>`).join("")}</div>`);
145
+ const ghostCardGrid = (n = 8) => ghosts(`<div class="page-grid">${Array.from({ length: n }, () => `<div class="card-proto is-pending"><div class="preview preview--pending"></div><div class="proto-meta">${ghostLine("56%")}</div></div>`).join("")}</div>`);
146
+ const ghostCompTable = (n = 4) => ghosts(`<table class="comp-table"><thead><tr><th>Preview</th><th>Component</th><th>What it is</th><th class="comp-status">Status</th></tr></thead><tbody>${Array.from({ length: n }, () => `<tr><td><span class="ghost-thumb"></span></td><td><div class="comp-name">${ghostLine("110px")}</div><div class="comp-badges"><span class="ghost-chip" style="width:54px"></span><span class="ghost-chip" style="width:40px"></span></div></td><td><div class="comp-desc">${ghostLine("100%")}${ghostLine("62%")}</div></td><td class="comp-status"><span class="ghost-dot"></span></td></tr>`).join("")}</tbody></table>`);
147
+
148
+ function faceChip(u, cls) {
149
+ const ini = escAttr((u.initials || (u.name || "?").slice(0, 2)).toUpperCase());
150
+ const label = escAttr(u.name || "Someone");
151
+ return `<span class="${cls}" style="background-color:${u.color || "#4f46e5"}" data-person="${escAttr(u.id)}" title="${label}" aria-label="${label}">${ini}</span>`;
152
+ }
153
+ function facePile(people, cap = 5) {
154
+ if (!people || !people.length) return "";
155
+ const shown = people.slice(0, cap), extra = people.length - shown.length;
156
+ const chips = shown.map((u) => faceChip(u, "proto-editor opp-face")).join("");
157
+ const more = extra > 0 ? `<span class="proto-editor opp-face opp-face--more" title="+${extra} more">+${extra}</span>` : "";
158
+ return `<span class="opp-people" role="group" aria-label="Contributors">${chips}${more}</span>`;
159
+ }
160
+ function currencyLine(key, p, now) {
161
+ const cur = STATUS_META[p.status] ? p.status : "ignore";
162
+ const word = cur === "ignore" ? "" : `<span class="proto-state ${STATUS_META[cur].cls}">${STATUS_META[cur].label}</span> &middot; `;
163
+ const when = p.mtimeMs ? `<span class="proto-when" title="${fmtDate(p.mtimeMs)}">${relTime(p.mtimeMs, now)}</span>` : `<span class="proto-when"></span>`;
164
+ return `<div class="proto-date" data-currency="${escAttr(key)}">${word}${when}</div>`;
165
+ }
166
+ function statusChip(status, key) {
167
+ const cur = STATUS_META[status] ? status : "ignore";
168
+ const aria = `Status: ${STATUS_META[cur].label}. Click to change.`;
169
+ return `<button type="button" class="status-chip ${STATUS_META[cur].cls}" data-status-key="${key}" data-status="${cur}" aria-label="${aria}" title="${aria}">${STATUS_ICONS[cur]}</button>`;
170
+ }
171
+ const compStatusChip = (name) => {
172
+ const aria = "Validation: In progress. Click to mark reviewed.";
173
+ return `<button type="button" class="status-chip is-wip" data-comp-status-key="components/${name}" data-status="in-progress" aria-label="${aria}" title="${aria}">${STATUS_ICONS["in-progress"]}</button>`;
174
+ };
175
+ function metaBadges(meta) {
176
+ const layer = meta.layer ? `<span class="cbadge cbadge--layer-${escAttr(meta.layer)}">${escAttr(meta.layer)}</span>` : "";
177
+ const surf = meta.surface ? `<span class="cbadge cbadge--surf">${escAttr(meta.surface)}</span>` : "";
178
+ const cat = meta.category ? `<span class="cbadge cbadge--cat">${escAttr(meta.category)}</span>` : "";
179
+ const stat = meta.status ? `<span class="cbadge cbadge--st-${escAttr(meta.status)}">${escAttr(meta.status)}</span>` : "";
180
+ return `<div class="comp-badges">${layer}${surf}${cat}${stat}</div>`;
181
+ }
182
+
183
+ /** The page skeleton the build used to emit, with the CURRENT chrome. */
184
+ export function shell({ title, body, activeTab = "prototypes", wrapClass = "", ctx }) {
185
+ const state = { spaces: ctx.spaces || [], activeSpace: ctx.activeSpace || "", opportunities: [], hasPlayground: !!ctx.hasPlayground };
186
+ const css = ctx.chrome && ctx.chrome.css ? `/${ctx.chrome.css}` : "";
187
+ const js = ctx.chrome && ctx.chrome.js ? `/${ctx.chrome.js}` : "";
188
+ return `<!doctype html>
189
+ <html lang="en">
190
+ <head>
191
+ <meta charset="utf-8" />
192
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
193
+ <meta name="robots" content="noindex, nofollow" />
194
+ <title>${escAttr(title)}</title>
195
+ <link rel="icon" type="image/png" href="/augur-mark.png?v=${UI_VERSION}" />
196
+ <link rel="apple-touch-icon" href="/augur-mark.png?v=${UI_VERSION}" />
197
+ <link rel="manifest" href="/manifest.webmanifest" />
198
+ <meta name="theme-color" content="#2C2150" />
199
+ <link rel="preload" href="/fonts/inter-latin-wght-normal.woff2" as="font" type="font/woff2" crossorigin />
200
+ ${js ? ` <link rel="preload" href="${js}" as="script" />\n` : ""}${css ? ` <link rel="stylesheet" href="${css}" />\n` : ""}</head>
201
+ <body>
202
+ ${CHROME_MARK_START(state.activeSpace, activeTab, state.hasPlayground)}${renderAppChrome(activeTab, state, {})}${CHROME_MARK_END}
203
+ <div class="wrap${wrapClass ? " " + wrapClass : ""}">
204
+ ${body}
205
+ </div>
206
+ <script>${renderSpaceContextScript(state)}
207
+ </script>
208
+ ${js ? ` <script defer src="${js}"></script>\n` : ""} <script type="speculationrules">${JSON.stringify({ prefetch: [{ where: { and: [{ href_matches: "/*" }, { not: { href_matches: "/__*" } }] }, eagerness: "moderate" }] })}</script>
209
+ </body>
210
+ </html>
211
+ `;
212
+ }
213
+
214
+ const folderbar = (title, count, extra = "") => `<header class="folderbar"><h1 class="folderbar__title">${title}</h1><span class="folderbar__count">${count}</span><span class="folderbar__rule"></span>${extra}</header>`;
215
+
216
+ export function renderRootIndex(model, ctx) {
217
+ const label = ctx.projectsLabel || "Projects";
218
+ if (!model.opportunities.length) {
219
+ return shell({ title: "Augur", wrapClass: "wrap--wide", ctx: { ...ctx, hasPlayground: model.hasPlayground },
220
+ body: emptyHead(label) + ghostFolderGrid() + emptyState("Ask your agent for a clickable prototype and it shows up here, ready to send.") });
221
+ }
222
+ const cards = model.opportunities.map((opp) => {
223
+ const oppPath = `${enc(opp.name)}/`;
224
+ const cover = opp.prototypes[0];
225
+ const coverSrc = cover ? cover.href : "";
226
+ return `
227
+ <div class="card-opp" data-fitem data-fkey="${titleCase(opp.name)}">
228
+ <a class="card-cover-link" href="${oppPath}" aria-label="Open ${titleCase(opp.name)}"></a>
229
+ ${preview(coverSrc, cover && cover.poster)}
230
+ <div class="opp-meta">
231
+ <div class="opp-name-row"><div class="proto-name">${titleCase(opp.name)}</div>${facePile(opp.people)}</div>
232
+ <div class="proto-date">${plural(opp.prototypes.length, "prototype")} &middot; <span class="proto-when" data-currency-folder="${escAttr(opp.name)}" title="${opp.mtimeMs ? fmtDate(opp.mtimeMs) : ""}">${opp.mtimeMs ? relTime(opp.mtimeMs, ctx.now) : ""}</span></div>
233
+ </div>
234
+ </div>`;
235
+ }).join("");
236
+ return shell({ title: "Augur", wrapClass: "wrap--wide", ctx: { ...ctx, hasPlayground: model.hasPlayground },
237
+ body: `${folderbar(label, model.opportunities.length)}<div data-fgroup><div class="opp-grid">${cards}</div></div>${filterEmpty()}` });
238
+ }
239
+
240
+ export function renderOpportunityIndex(model, oppName, ctx) {
241
+ const opp = model.opportunities.find((o) => o.name === oppName);
242
+ if (!opp) return null;
243
+ const label = (ctx.projectsLabel || "Projects").toLowerCase();
244
+ const cards = opp.prototypes.map((p) => {
245
+ const download = p.file ? `<button type="button" data-dl="${p.file}" data-dlname="${enc(p.name)}.html" aria-label="Download HTML" hidden></button>` : "";
246
+ const pinKey = `/${enc(opp.name)}/${enc(p.name)}/`;
247
+ const dname = protoName(p.name);
248
+ const key = `${opp.name}/${p.name}`;
249
+ return `
250
+ <div class="card-proto" data-fitem data-fkey="${titleCase(p.name)}" data-rename-key="${key}" data-default-name="${dname}" data-del-space="${escAttr(ctx.activeSpace || "")}" data-del-path="${opp.name}/prototypes/${p.name}">
251
+ <div class="preview">
252
+ ${media(p.href, p.poster)}
253
+ <a class="preview-link" href="${p.href}" aria-label="Open ${titleCase(p.name)}"></a>
254
+ <div class="preview-actions">
255
+ ${download}
256
+ ${pinStar(pinKey, pinKey)}
257
+ </div>
258
+ ${statusChip(p.status, key)}
259
+ </div>
260
+ <div class="proto-meta">
261
+ <div class="proto-text">
262
+ <div class="proto-name">${dname}</div>
263
+ ${currencyLine(key, p, ctx.now)}
264
+ </div>
265
+ ${facePile(p.editors)}
266
+ </div>
267
+ </div>`;
268
+ }).join("");
269
+ const up = `<a class="folderbar__up" href="/" aria-label="All ${label}" title="All ${label}"><svg viewBox="0 0 16 16" fill="none" aria-hidden="true"><path d="M10 3L5 8l5 5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg></a>`;
270
+ return shell({ title: titleCase(opp.name), activeTab: opp.name, wrapClass: "wrap--wide", ctx: { ...ctx, hasPlayground: model.hasPlayground },
271
+ body: `<header class="folderbar">${up}<h1 class="folderbar__title">${titleCase(opp.name)}</h1><span class="folderbar__count">${opp.prototypes.length}</span><span class="folderbar__rule"></span>${newCanvasBtn(`/${opp.name}/`)}</header><div data-fgroup><div class="page-grid is-3up">${cards}</div></div>${filterEmpty()}` });
272
+ }
273
+
274
+ export function renderPlaygroundIndex(model, ctx) {
275
+ const c = { ...ctx, hasPlayground: model.hasPlayground };
276
+ if (!model.playground.length) {
277
+ return shell({ title: "Playground", activeTab: "playground", wrapClass: "wrap--wide", ctx: c,
278
+ body: emptyHead("Playground") + ghostFolderGrid() + emptyState("Ask your agent for something quick that doesn't need a project.") });
279
+ }
280
+ const cards = model.playground.map((p) => {
281
+ const folder = `${enc(p.name)}/`;
282
+ const pinKey = `/playground/${enc(p.name)}/`;
283
+ const dname = protoName(p.name);
284
+ const key = `playground/${p.name}`;
285
+ return `
286
+ <div class="card-opp" data-fitem data-fkey="${titleCase(p.name)}" data-rename-key="${key}" data-default-name="${dname}" data-del-space="${escAttr(ctx.activeSpace || "")}" data-del-path="playground/${p.name}">
287
+ <a class="card-cover-link" href="${folder}" aria-label="Open ${titleCase(p.name)}"></a>
288
+ <div class="preview">
289
+ ${media(p.href, p.poster)}
290
+ ${statusChip(p.status, key)}
291
+ </div>
292
+ <div class="preview-actions">${pinStar(pinKey, pinKey)}</div>
293
+ <div class="proto-meta">
294
+ <div class="proto-text">
295
+ <div class="proto-name">${dname}</div>
296
+ ${currencyLine(key, p, ctx.now)}
297
+ </div>
298
+ ${facePile(p.editors)}
299
+ </div>
300
+ </div>`;
301
+ }).join("");
302
+ return shell({ title: "Playground", activeTab: "playground", wrapClass: "wrap--wide", ctx: c,
303
+ body: `${folderbar("Playground", model.playground.length, newCanvasBtn("/playground/"))}<div data-fgroup><div class="opp-grid">${cards}</div></div>${filterEmpty()}` });
304
+ }
305
+
306
+ const TIER_COPY = Object.freeze({
307
+ base: { addHint: 'The atoms every screen here borrows: buttons, inputs, cards, badges, modal, icons. Components and Patterns are built out of these, and all of them wear <a href="/tokens/">Tokens</a>.', empty: "Ask your agent for your buttons, inputs and cards, one page each." },
308
+ patterns: { addHint: "Layouts that keep coming back: several Components arranged the way real screens arrange them again and again.", empty: 'Build a few <a href="/pages/">Pages</a> first, then ask your agent to pull out what repeats.' },
309
+ pages: { addHint: "", empty: "Ask your agent to build a whole screen out of your design system." },
310
+ });
311
+
312
+ export function renderTierIndex(model, tier, ctx) {
313
+ if (!TIER_COPY[tier]) return null;
314
+ const items = model.tiers[tier] || [];
315
+ const title = TIER_TITLE[tier];
316
+ const c = { ...ctx, hasPlayground: model.hasPlayground };
317
+ if (!items.length) {
318
+ return shell({ title, activeTab: tier, wrapClass: "wrap--wide", ctx: c, body: emptyHead(title, true) + ghostCardGrid() + emptyState(TIER_COPY[tier].empty) });
319
+ }
320
+ const card = (p) => `
321
+ <div class="card-proto" data-fitem data-fkey="${titleCase(p.name)}" data-rename-key="${tier}/${p.name}" data-default-name="${titleCase(p.name)}">
322
+ <div class="preview">
323
+ ${media(p.href, p.poster)}
324
+ <a class="preview-link" href="${p.href}" aria-label="Open ${titleCase(p.name)}"></a>
325
+ </div>
326
+ <div class="proto-meta">
327
+ <div class="proto-name">${titleCase(p.name)}</div>
328
+ </div>
329
+ </div>`;
330
+ const hint = TIER_COPY[tier].addHint ? `<p class="tier-hint">${TIER_COPY[tier].addHint}</p>` : "";
331
+ return shell({ title, activeTab: tier, wrapClass: "wrap--wide", ctx: c,
332
+ body: `${folderbar(title, items.length)}${hint}<div class="page-grid">${items.map(card).join("")}</div>${filterEmpty()}` });
333
+ }
334
+
335
+ /** The catalog a components table reads: registry.json → {slug → {name, classes, desc, meta}}. */
336
+ export function catalogFrom(registry) {
337
+ const out = {};
338
+ for (const it of (registry && registry.items) || []) {
339
+ if (!it || !it.name || it.type === "page") continue;
340
+ const fams = it.classes || (it.class ? [it.class] : []);
341
+ out[it.name] = {
342
+ name: it.label || titleCase(it.name),
343
+ classes: (it.cssClasses || fams).map((f) => "." + f).join(" / "),
344
+ desc: it.description || "",
345
+ meta: it.meta || null,
346
+ };
347
+ }
348
+ return out;
349
+ }
350
+
351
+ export function renderComponentsIndex(model, catalog, ctx) {
352
+ const items = model.tiers.components || [];
353
+ const c = { ...ctx, hasPlayground: model.hasPlayground };
354
+ if (!items.length) {
355
+ return shell({ title: "Components", activeTab: "components", wrapClass: "wrap--wide", ctx: c,
356
+ body: emptyHead("Components", true) + ghostCompTable() + emptyState("Ask your agent for a component you keep rebuilding, like a search field.") });
357
+ }
358
+ const rows = items.map((it) => {
359
+ const blurb = (catalog && catalog[it.name]) || { name: "", classes: "", desc: "", meta: null };
360
+ const dname = blurb.name || titleCase(it.name);
361
+ const classes = blurb.classes ? `<code>${escAttr(blurb.classes)}</code>` : "";
362
+ const badges = blurb.meta ? metaBadges(blurb.meta) : "";
363
+ const tags = blurb.meta && blurb.meta.tags && blurb.meta.tags.length ? `<div class="comp-tags">${blurb.meta.tags.map((t) => `<span>#${escAttr(t)}</span>`).join("")}</div>` : "";
364
+ const metaKey = blurb.meta ? [blurb.meta.surface, blurb.meta.category, blurb.meta.status, ...(blurb.meta.tags || [])].filter(Boolean).join(" ") : "";
365
+ const fkey = `${dname} ${blurb.classes} ${blurb.desc} ${metaKey}`.replace(/<[^>]+>/g, " ").replace(/"/g, "");
366
+ return `
367
+ <tr data-fitem data-fkey="${escAttr(fkey)}" data-rename-key="components/${it.name}" data-default-name="${escAttr(dname)}">
368
+ <td>
369
+ <a class="comp-thumb" href="${it.href}" aria-label="Open ${escAttr(dname)}">
370
+ ${media(it.href, it.poster)}
371
+ </a>
372
+ </td>
373
+ <td><div class="comp-name"><span class="proto-name">${escAttr(dname)}</span>${classes}</div>${badges}${tags}</td>
374
+ <td><div class="comp-desc" data-desc-key="components/${it.name}#desc">${escAttr(blurb.desc)}</div></td>
375
+ <td class="comp-status">${compStatusChip(it.name)}</td>
376
+ </tr>`;
377
+ }).join("");
378
+ return shell({ title: "Components", activeTab: "components", wrapClass: "wrap--wide", ctx: c,
379
+ body: `${folderbar("Components", items.length)}<table class="comp-table">
380
+ <thead><tr><th>Preview</th><th>Component</th><th>What it is</th><th class="comp-status">Status</th></tr></thead>
381
+ <tbody>${rows}</tbody>
382
+ </table>${filterEmpty()}` });
383
+ }
384
+
385
+ /** The rail's finder index — every navigable thing, the shape the chrome bundle reads. */
386
+ export function searchIndex(model, ctx) {
387
+ const idx = [{ t: ctx.projectsLabel || "Projects", y: "Index", u: "/" }];
388
+ if (model.hasPlayground) idx.push({ t: "Playground", y: "Index", u: "/playground/" });
389
+ for (const t of ["pages", "components", "base", "patterns"]) if (model.tiers[t].length) idx.push({ t: TIER_TITLE[t], y: "Index", u: `/${t}/` });
390
+ const thumb = (p) => (p.poster ? { th: `${p.href}preview.webp` } : {});
391
+ for (const opp of model.opportunities) {
392
+ const cover = opp.prototypes[0];
393
+ idx.push({ t: titleCase(opp.name), y: "Folder", u: `/${enc(opp.name)}/`, ...(cover ? thumb(cover) : {}) });
394
+ for (const p of opp.prototypes) idx.push({ t: titleCase(p.name), y: "Prototype", g: titleCase(opp.name), u: p.href, k: `${opp.name}/${p.name}`, ...thumb(p) });
395
+ }
396
+ for (const p of model.playground) idx.push({ t: titleCase(p.name), y: "Playground", u: p.href, k: `playground/${p.name}`, ...thumb(p) });
397
+ for (const p of model.tiers.pages) idx.push({ t: titleCase(p.name), y: "Page", u: p.href, k: `pages/${p.name}`, ...thumb(p) });
398
+ for (const p of model.tiers.components) idx.push({ t: titleCase(p.name), y: "Component", u: p.href, k: `components/${p.name}`, ...thumb(p) });
399
+ return idx;
400
+ }
@@ -123,10 +123,6 @@ export const STATE_INVENTORY = Object.freeze([
123
123
  id: "board:", store: "kv", kind: "prefix", to: "workspace",
124
124
  why: "A canvas board document, spelled `board:<path>` or `board:<workspace>:<path>` where the deployment serves its own rooms (src/board-key.mjs). Same table. NOTE this is a MIRROR: while the realtime worker is separate, the authoritative copy is that worker's own BoardRoom storage, and a DO's storage belongs to the script that created it — so a board carried by an export is carried as of the last mirror write, which lags the room and can lag it by a great deal more than the write cadence alone. What travels the whole board is `scripts/board-snapshot.mjs`, which reads the room over a WebSocket join and seeds the new one; run it per board AFTER a move, and take its lag report rather than a clock as the sign that a board is quiet enough to have been copied correctly.",
125
125
  },
126
- {
127
- id: "marks", store: "kv", kind: "key", to: "drop",
128
- why: "Working marks: one row per path saying something is editing there right now, holding the path, the one-way author id and the time, and good only until its own TTL runs out (ten minutes by default, an hour at the most). Transient by construction — a mark is not a promise to anybody, it grants nothing and refuses nothing, and the next work-start recreates it. DROPPED rather than carried, for the same reason `health:report` is: a mark restored onto a new home would say somebody is working somewhere at a moment that has passed, and a false claim is worse than a missing one. Nothing is lost: the marks a copy would have carried are all expired by the time anybody reads the copy.",
129
- },
130
126
  {
131
127
  id: "drafts", store: "kv", kind: "key", to: "drop",
132
128
  why: "The open-drafts hint: one row per unit saying how many drafts its object reports open, written after open, land and discard so the gallery knows which unit objects to ask (`draftsIndexApi` in src/_worker.js) and asks no others. Transient by construction and recreated by the next open, land or discard on that unit; never the truth — every read re-checks the object and drops a row it contradicts. DROPPED on a copy because the unit objects it points at do not travel (see `/__unit/`), so a carried row would name drafts the destination cannot serve; the first open on the new home writes a true one.",
@@ -1,86 +0,0 @@
1
- # Working marks — say what you are about to touch, read what everyone else is
2
-
3
- Nothing in a workspace used to say what was already being worked on. Two people's
4
- agents, on two machines, both told to improve "the checkout flow", would each open
5
- the same folder, each edit it, and find out at publish time — where the answer is a
6
- fork, a conflict file and an afternoon nobody planned.
7
-
8
- A **mark** is a note on a path saying *something is editing here right now*. It
9
- carries the path, a one-way id for who left it, when it started, and how long it is
10
- good for. That is all it is.
11
-
12
- Where the workspace serves drafts, a draft IS the mark: `augur open` tells you who else
13
- has the prototype open and shows you on their chips, with nothing to leave and nothing to
14
- expire — see [drafts.md](./drafts.md). Marks stay for workspaces without drafts.
15
-
16
- ## ⚠️ It is not a lock, and you must not treat it as one
17
-
18
- A mark **grants nothing and refuses nothing**. A marked path can still be opened,
19
- edited, published and shipped by anybody, including you. Nothing in the engine asks
20
- a mark for permission — not the gate, not the publish handler, not the commit.
21
- `augur mark` exits 0 whatever it finds.
22
-
23
- So do not write a script that blocks on a mark, retries until one clears, or treats
24
- one as a failure. When coordination genuinely fails, the composed publish settles it
25
- on evidence after the fact (see [publishing.md](./publishing.md)) — that is the part
26
- that is allowed to refuse, and it is the only part.
27
-
28
- ## The protocol, in one line
29
-
30
- **Read the marks before you start. Leave one when you do.**
31
-
32
- ```
33
- augur mark what is being worked on right now
34
- augur mark <path> [--ttl <seconds>] leave a mark on it, then start
35
- augur mark <path> --clear take yours down early
36
- augur mark … --json the same answer, for a tool to read
37
- ```
38
-
39
- A path is a URL path (`/checkout/flow/`). The repo folder is accepted and
40
- translated, so `checkout/prototypes/flow` — the folder you were just editing —
41
- marks the URL it publishes to. The line printed back is always the instance's own
42
- spelling; do not assume yours won.
43
-
44
- Marking a path that somebody else already marked **still works**, and prints their
45
- mark next to yours. That is the whole design: you now know, and you choose. Pick a
46
- different path, wait it out, or carry on knowing you will be merging.
47
-
48
- ## It expires by itself
49
-
50
- A mark is good for ten minutes by default and an hour at the most. The instance
51
- stops reporting it the moment that passes, whether or not anything ever clears it.
52
-
53
- That is the point rather than a detail. The thing leaving marks is a process that
54
- can be killed — an interrupt, an out-of-memory, a closed laptop — and a claim that
55
- outlives the claimant is worse than no claim at all, because the next reader
56
- believes it. **`--clear` is a courtesy, never the guarantee.** There is no cleanup
57
- job to run and none to forget.
58
-
59
- If the work is genuinely still going when the mark lapses, mark it again. An agent
60
- that wants a four-hour mark is describing a lock, and the answer to a lock is a
61
- short mark re-written as the work continues.
62
-
63
- ## Where marks show up without being asked for
64
-
65
- - `augur status` prints what is being worked on, under the live-vs-clone table.
66
- - `augur clone` and `augur pull` print the marks on the paths they are about to
67
- write, before the first byte lands — and write anyway, with the same exit code
68
- as always.
69
- - A gallery card carries a small badge (*"… is working on this"*) while a mark on
70
- that exact URL is live, and drops it when the mark lapses.
71
-
72
- The badge is the **byproduct**, not the point. As an agent's edit shrinks toward
73
- seconds, a mark is felt almost never and read always — which is why the write side
74
- is the CLI and the browser side only ever reads.
75
-
76
- ## What is stored
77
-
78
- `{path, personId, startedAt, ttl}` — and `personId` is the same one-way fingerprint
79
- a comment carries, so a mark holds **no address**. The display name beside it is
80
- resolved from the roster at read time, never stored, so a rename shows through and
81
- somebody who has left resolves to "Someone".
82
-
83
- Marks are workspace state, not published content: they are covered by
84
- `augur export --full`, and a restore deliberately drops them (see
85
- `src/state-inventory.mjs`) rather than reinstating a claim about a moment that has
86
- passed.
@@ -1,107 +0,0 @@
1
- // Working marks, client side — one definition of the path spelling and one of the phrasing.
2
- //
3
- // `F-presence-marks`. Three commands surface marks (`mark`, `status`, `pull`) and a fourth
4
- // will. If each spelled a path its own way, two agents naming the same folder would write
5
- // two rows and read past each other — which is the exact failure the feature exists to
6
- // prevent, arriving through the tool that was supposed to prevent it. So the normalization
7
- // here MIRRORS `normalizeMarkPath` in src/_worker.js on purpose, and the server's answer is
8
- // always the one printed back: the client never assumes its own spelling won.
9
- //
10
- // ⚠️ A MARK REFUSES NOTHING. Nothing in this file returns a verdict, sets an exit code, or
11
- // gives a caller something to branch on that would let it block. It reads, and it prints.
12
-
13
- /** Leading and trailing slash. Same rule as the worker, for the same containment reason. */
14
- export function normalizeMarkPath(p) {
15
- const s = String(p == null ? "" : p).trim().slice(0, 300);
16
- if (!s) return "";
17
- const t = s.replace(/^\.\//, "").replace(/\/{2,}/g, "/");
18
- if (!t || t === "/") return "/";
19
- return `/${t.replace(/^\/+/, "").replace(/\/+$/, "")}/`;
20
- }
21
-
22
- /**
23
- * A REPO folder, as the URL it publishes to.
24
- *
25
- * `<project>/prototypes/<name>` is the nesting `discoverSpaces()` looks in, and it is
26
- * served at `/<project>/<name>/`. An agent has just been editing the folder, so it is the
27
- * folder it will type; taking it without translation would mark a path no card and no
28
- * published unit will ever match.
29
- */
30
- export function markPathFor(input) {
31
- return normalizeMarkPath(String(input == null ? "" : input).replace(/\/prototypes\//g, "/"));
32
- }
33
-
34
- /** Does either path contain the other? The whole overlap test. */
35
- export function marksOverlap(a, b) {
36
- const x = normalizeMarkPath(a), y = normalizeMarkPath(b);
37
- if (!x || !y) return false;
38
- return x === y || x.startsWith(y) || y.startsWith(x);
39
- }
40
-
41
- /**
42
- * Of the marks that were there before you wrote yours, whose are worth telling you about.
43
- *
44
- * ⚠️ "SOMEBODY ELSE" IS DECIDED BY WHO, NEVER BY WHERE, and this function exists so that
45
- * decision has somewhere to be tested. The obvious way to stop your own renewal warning at
46
- * you is to drop the exact path from the list — and that silently drops the ONE case the
47
- * whole feature exists to surface: two agents on the same prototype. It shipped that way
48
- * once and printed nothing at all for an exact collision, which is worse than not having
49
- * the warning, because it reads as an all-clear.
50
- *
51
- * `mine` is the id the INSTANCE resolved from the credential and handed back, never one the
52
- * client worked out for itself — the same rule the row's authorship follows.
53
- */
54
- export function othersOverlapping(before, path, mine) {
55
- return (before || []).filter((m) => m && m.personId !== mine && marksOverlap(m.path, path));
56
- }
57
-
58
- /**
59
- * Every live mark at an instance. NEVER THROWS: a `status` or a `pull` that died because
60
- * the coordination note could not be fetched would make the note the most fragile thing in
61
- * the toolchain. An older instance answers 404 and gets an empty list, which reads exactly
62
- * like "nobody is working on anything" — and is the right answer there, because on that
63
- * instance nobody can be.
64
- */
65
- export async function fetchMarks(req) {
66
- try {
67
- const r = await req("_marks/list");
68
- const body = await r.json();
69
- return Array.isArray(body.marks) ? body.marks : [];
70
- } catch (e) { return []; }
71
- }
72
-
73
- const plural = (n, w) => `${n} ${w}${n === 1 ? "" : "s"}`;
74
-
75
- /** "4 minutes ago" / "just now", from a millisecond age. */
76
- export function since(ms) {
77
- const s = Math.max(0, Math.round(ms / 1000));
78
- if (s < 45) return "just now";
79
- if (s < 5400) return `${plural(Math.round(s / 60), "minute")} ago`;
80
- return `${plural(Math.round(s / 3600), "hour")} ago`;
81
- }
82
-
83
- /**
84
- * "for another 6 minutes", from a millisecond remainder.
85
- *
86
- * Switches to hours at exactly 3600s rather than at the 90 minutes `since` uses, because
87
- * a mark's ceiling IS an hour: at the other threshold the longest mark anybody can ask
88
- * for would read "for another 60 minutes", and the hours branch could never fire at all.
89
- */
90
- export function forAnother(ms) {
91
- const s = Math.max(0, Math.round(ms / 1000));
92
- if (s < 60) return `for another ${plural(s, "second")}`;
93
- if (s < 3600) return `for another ${plural(Math.round(s / 60), "minute")}`;
94
- return `for another ${plural(Math.round(s / 3600), "hour")}`;
95
- }
96
-
97
- /**
98
- * One line per mark. `by` is null when the id behind the mark resolves to nobody on the
99
- * roster — a token an admin labelled by hand, or somebody who has since left — and
100
- * "Someone" is the honest rendering of that, never a guess.
101
- */
102
- export function markLine(m) {
103
- const who = m.by || "Someone";
104
- const started = Date.parse(m.startedAt);
105
- const age = Number.isFinite(started) ? since(Date.now() - started) : "";
106
- return `${m.path} ${who}${age ? ` · started ${age}` : ""} · ${forAnother(m.expiresIn)}`;
107
- }