@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.
- package/INSTALL.md +3 -2
- package/README.md +4 -2
- package/agents/README.md +4 -5
- package/agents/drafts.md +11 -6
- package/agents/prototype-contract.md +1 -1
- package/agents/publishing.md +23 -16
- package/build.js +1 -97
- package/package.json +1 -1
- package/scripts/cli.mjs +11 -5
- package/scripts/clone.mjs +0 -20
- package/scripts/init.mjs +2 -2
- package/scripts/lib/adapters.mjs +23 -3
- package/scripts/lib/draft.mjs +22 -2
- package/scripts/no-tenant-globals.mjs +16 -0
- package/scripts/open.mjs +8 -6
- package/scripts/publish.mjs +19 -0
- package/scripts/read.mjs +2 -3
- package/scripts/status.mjs +5 -23
- package/src/_worker.js +139 -309
- package/src/galleries.mjs +400 -0
- package/src/state-inventory.mjs +0 -4
- package/agents/working-marks.md +0 -86
- package/scripts/lib/marks.mjs +0 -107
- package/scripts/mark.mjs +0 -112
- package/scripts/ship.mjs +0 -460
|
@@ -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> · `;
|
|
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")} · <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
|
+
}
|
package/src/state-inventory.mjs
CHANGED
|
@@ -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.",
|
package/agents/working-marks.md
DELETED
|
@@ -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.
|
package/scripts/lib/marks.mjs
DELETED
|
@@ -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
|
-
}
|