Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 57 additions & 5 deletions share-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ verdicts back directly — no artifact private-wall, no copy/paste shuttle.
Cloudflare Worker + KV (`worker.js`, deployed via `bunx wrangler deploy` from
this directory; uses the wrangler OAuth login, not `CLOUDFLARE_API_TOKEN`).
Modeled on the mc-launcher share store: content-derived FNV-1a ids, idempotent
republish. No auth by design — org-internal links, short TTL.
republish. Remote rounds store the round manifest as the source of truth and the
Worker renders the review board from it. No auth by design — org-internal links,
short TTL.

## Lifetime

Expand All @@ -19,7 +21,7 @@ an idle share dies after one day. Republish (same content → same id) to revive

| Method + path | Purpose |
| --- | --- |
| `POST /share[?series=&round=&title=&by=]` (body = self-contained HTML, ≤4MB) | Publish a board → `{id, url}`. Content-derived id, idempotent. `series/round/title` register it in a series (round switcher + `/s/<slug>` picker); `by` is the publisher's everyday name, shown in the injected topbar ("Board by jackson") and the series page. |
| `POST /share[?by=]` (body = round manifest JSON, ≤4MB) | Publish a board → `{id, url}`. Content-derived id, idempotent. `series/round/title` come from the manifest and register it in a series (round switcher + `/s/<slug>` picker); `by` is the publisher's everyday name, shown in the injected topbar ("Board by jackson") and the series page. Legacy self-contained HTML bodies still work for old callers. |
| `GET /share/<id>` | Serve the board (re-arms TTL). `410` when expired. |
| `POST /share/<id>/verdict` (`{name, decisions[], next?}`) | One reviewer's submission; keyed by name, resubmit overwrites. |
| `GET /share/<id>/verdicts` | Merged submissions, agent-readable: `{id, count, submissions[]}`. |
Expand All @@ -28,6 +30,56 @@ an idle share dies after one day. Republish (same content → same id) to revive

`skills/brand-studio/assets/share-review.html` — the round-review board wired
to this transport (submit → `POST <path>/verdict`, aggregate → `GET
<path>/verdicts`, drafts in `localStorage`). Fill `__ITEMS_JSON__` (items with
inline `svg` markup or data-URI `jpg`) and `__GOAL__`, then `POST /share` the
result. The board must stay self-contained: inline SVGs / data-URI images only.
<path>/verdicts`, drafts in `localStorage`). The Worker injects the posted
manifest into `__ROUND_JSON__` and the resolved review UI theme into
`__THEME_JSON__`; the template reads `R.series`, `R.round`, `R.title`, `R.goal`,
and `R.items`.

Remote manifests should use inline `svg` markup or data-URI `jpg`/`img` values
for visual items because the public board cannot read local files.

## Review theme

Remote round manifests may include a review UI theme. The share server resolves
tokens in this order, per token:

1. `theme.brand.tokens` — org / brand-standard review theme.
2. `theme.repo.tokens` — product repo review theme.
3. built-in default cream/clay/ink theme.

If a brand theme only supplies `bg`, for example, `card` can still come from the
repo theme and all other tokens fall back to defaults.

Supported token names: `bg`, `card`, `line`, `ink`, `dim`, `clay`, `clayDeep`,
`stage`, `keep`, `maybe`, and `drop`. A few common aliases such as
`background`, `paper`, `border`, `text`, `muted`, `accent`, and `clay-deep` are
accepted and normalized.

Example:

```json
{
"series": "org-logo",
"round": 2,
"title": "tail refinement",
"goal": "Refine the accepted direction.",
"theme": {
"brand": {
"tokens": {
"bg": "#F3EDE3",
"ink": "#191713",
"clay": "#D87857"
}
},
"repo": {
"tokens": {
"card": "#FCF8F1",
"stage": "#191713"
}
}
},
"items": [
{ "id": "r2-tail-01", "concept": "cream-tipped tail", "svg": "<svg>...</svg>" }
]
}
```
6 changes: 6 additions & 0 deletions share-server/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"type": "module",
"scripts": {
"test": "node --test test/worker.test.js"
}
}
230 changes: 230 additions & 0 deletions share-server/test/worker.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
import assert from "node:assert/strict";
import test from "node:test";

import worker from "../worker.js";

class FakeKV {
constructor() {
this.entries = new Map();
}

async put(key, value, options = {}) {
this.entries.set(key, { value: String(value), options });
}

async get(key) {
return this.entries.has(key) ? this.entries.get(key).value : null;
}

async list(options = {}) {
const prefix = options.prefix || "";
return {
keys: [...this.entries.keys()]
.filter((name) => name.startsWith(prefix))
.sort()
.map((name) => ({ name })),
};
}
}

function makeEnv() {
return { SHARES: new FakeKV() };
}

function roundManifest() {
return {
series: "org-logo",
round: 2,
title: "tail refinement",
goal: "Refine the accepted tail direction without changing the mark family.",
items: [
{
id: "r2-tail-01",
concept: "cream-tipped tail curl",
svg: '<svg viewBox="0 0 10 10"><path d="M1 5 C4 1 7 1 9 5"/></svg>',
},
],
};
}

async function publishManifest(env, manifest) {
const create = await worker.fetch(
new Request("https://brand-studio.example/share", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(manifest),
}),
env,
);
const { id } = await create.json();
const res = await worker.fetch(
new Request(`https://brand-studio.example/share/${id}?series=${manifest.series}`),
env,
);
return res.text();
}

function renderedTheme(html) {
const match = html.match(/const THEME = (.*?);\nconst R = /s);
assert.ok(match, "expected rendered page to include THEME JSON");
return JSON.parse(match[1]);
}

test("POST /share with a round manifest stores manifest as the remote source of truth", async () => {
const env = makeEnv();
const res = await worker.fetch(
new Request("https://brand-studio.example/share", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(roundManifest()),
}),
env,
);

assert.equal(res.status, 200);
const body = await res.json();
assert.equal(body.series, "org-logo");
assert.match(body.url, /^https:\/\/brand-studio\.example\/share\/[0-9a-f]{16}\?series=org-logo$/);

const manifest = JSON.parse(await env.SHARES.get(`r:${body.id}`));
assert.equal(manifest.series, "org-logo");
assert.equal(manifest.round, 2);
assert.equal(manifest.title, "tail refinement");
assert.equal(manifest.items[0].id, "r2-tail-01");
assert.equal(await env.SHARES.get(`s:${body.id}`), null);

const index = JSON.parse(await env.SHARES.get("x:org-logo"));
assert.deepEqual(index.map(({ round, title, id }) => ({ round, title, id })), [
{ round: 2, title: "tail refinement", id: body.id },
]);
});

test("GET /share renders the remote board from the stored round manifest", async () => {
const env = makeEnv();
const create = await worker.fetch(
new Request("https://brand-studio.example/share", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(roundManifest()),
}),
env,
);
const { id } = await create.json();

const res = await worker.fetch(
new Request(`https://brand-studio.example/share/${id}?series=org-logo`),
env,
);

assert.equal(res.status, 200);
const html = await res.text();
assert.match(html, /org-logo · round 2/);
assert.match(html, /tail refinement/);
assert.match(html, /Refine the accepted tail direction/);
assert.match(html, /cream-tipped tail curl/);
assert.match(html, /const R = /);
assert.doesNotMatch(html, /const ROUND = 1/);
});

test("manifest-backed boards accept reviewer verdicts", async () => {
const env = makeEnv();
const create = await worker.fetch(
new Request("https://brand-studio.example/share", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(roundManifest()),
}),
env,
);
const { id } = await create.json();

const submit = await worker.fetch(
new Request(`https://brand-studio.example/share/${id}/verdict`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
series: "org-logo",
round: 2,
title: "tail refinement",
name: "jc",
decisions: [{ id: "r2-tail-01", verdict: "keep", note: "strongest tail" }],
}),
}),
env,
);

assert.equal(submit.status, 200);
const verdicts = await worker.fetch(
new Request(`https://brand-studio.example/share/${id}/verdicts`),
env,
);

const body = await verdicts.json();
assert.match(body.submissions[0].ts, /\d{4}-\d{2}-\d{2}T/);
delete body.submissions[0].ts;
assert.deepEqual(body, {
id,
count: 1,
submissions: [
{
series: "org-logo",
round: 2,
title: "tail refinement",
name: "jc",
decisions: [{ id: "r2-tail-01", verdict: "keep", note: "strongest tail" }],
},
],
});
});

test("brand review theme overrides repo theme and falls back per token", async () => {
const html = await publishManifest(makeEnv(), {
...roundManifest(),
theme: {
brand: {
tokens: {
bg: "#010203",
},
},
repo: {
tokens: {
bg: "#abcdef",
card: "#f8f1e7",
},
},
},
});

const theme = renderedTheme(html);
assert.equal(theme.source, "brand");
assert.equal(theme.tokens.bg, "#010203");
assert.equal(theme.tokens.card, "#f8f1e7");
assert.equal(theme.tokens.ink, "#191713");
});

test("repo review theme is used when brand theme is absent", async () => {
const html = await publishManifest(makeEnv(), {
...roundManifest(),
theme: {
repo: {
tokens: {
bg: "#111827",
ink: "#f9fafb",
},
},
},
});

const theme = renderedTheme(html);
assert.equal(theme.source, "repo");
assert.equal(theme.tokens.bg, "#111827");
assert.equal(theme.tokens.ink, "#f9fafb");
});

test("default review theme is used when manifest has no theme", async () => {
const theme = renderedTheme(await publishManifest(makeEnv(), roundManifest()));

assert.equal(theme.source, "default");
assert.equal(theme.tokens.bg, "#F3EDE3");
assert.equal(theme.tokens.clayDeep, "#B4532A");
});
Loading
Loading