wren/glyph Public Public: anyone can read this; nobody can change it.

forked from apps/glyph

workbenchthis machine

Detail (#6)

Requested-by: benjivers <[email protected]>
Co-authored-by: benjivers <[email protected]>
Wren committed 5c2d82d Browse files
7 files changed +74 −33
README.md +11 −6
@@ -15,13 +15,18 @@ any theme, then **Copy** the SVG or **Save** it as a file in that palette.
1515 in Settings → App → Environment (make one with `claude setup-token`).
1616 Without it a drawing fails at once with a sentence saying exactly that.
1717 - **Every SVG is rebuilt from an allowlist** (`src/glyph.ts` `sanitize`):
18 shapes, `title` and plain attributes only. No script, style, link, image,
19 `url(…)` or event handler survives, and previews are `<img>` data URIs, so
18 shapes, local gradients and clip paths, `title` and plain attributes
19 only. No script, style, link, image, `url(…)` other than one pointing at
20 a gradient or clip inside the same SVG (`url(#id)`), or event handler
21 survives, and previews are `<img>` data URIs, so
2022 nothing in a drawing can run on the page. A saved file is served with a
2123 sandboxing CSP.
22 - **Colours**: Claude draws in two placeholder colours (ink and glow,
23 `design/palettes.json`); a palette swaps those two. Downloads carry the
24 palette's real colours.
24 - **Colours**: Claude draws in three placeholder colours (ink, glow and
25 paper, `design/palettes.json`) and builds depth from gradients and
26 opacity between them; a palette swaps ink and glow for its own and paper
27 for its ground. Downloads carry the palette's real colours. Drawings are
28 asked for on a 256-unit canvas with layered, detailed shapes, so a
29 drawing takes a little longer than a flat one.
2530 - **Drawings are private to whoever asked** (`contract.json`); an admin sees
2631 everyone's. One drawing at a time per person, three at once for everyone.
2732 A drawing left half done by a restart is marked failed with a reason.
@@ -62,7 +67,7 @@ decide nothing themselves. `AGENTS.md` says how to add to it.
6267 | `src/actions.ts` | start, list, read, delete a drawing; the audit trail, read |
6368 | `src/glyph.ts` | the ask, the reply read back, the SVG allowlist, palettes |
6469 | `src/draw.ts`, `src/claude.ts` | the work after the response: Claude Code, run with no tools |
65 | `design/palettes.json` | the drawing palettes and the two placeholder colours |
70 | `design/palettes.json` | the drawing palettes and the three placeholder colours |
6671 | `contract.json` | who may do what: roles, and the least role each verb needs |
6772 | `src/contract.ts`, `src/can.ts` | the table, read at boot; the forge's evaluator, verbatim |
6873 | `src/security.ts` | the door: rationing, same origin, the table before the body |
contract/openapi.json +1 −1
@@ -442,7 +442,7 @@
442442 "items": {
443443 "type": "string"
444444 },
445 "description": "The drawings, as SVG, in the placeholder ink and glow colours."
445 "description": "The drawings, as SVG, in the placeholder ink, glow and paper colours."
446446 },
447447 "error": {
448448 "type": [
design/palettes.json +2 −2
@@ -1,6 +1,6 @@
11 {
2 "$description": "The colours a drawing is drawn and shown in. Claude draws every SVG in the placeholder ink and glow; a palette swaps those two for its own and sits the drawing on its ground. Drawing content, not the app's look: the app's own colours are design/tokens.json.",
3 "placeholder": { "ink": "#1e2a21", "glow": "#9c4830" },
2 "$description": "The colours a drawing is drawn and shown in. Claude draws every SVG in the placeholder ink, glow and paper; a palette swaps ink and glow for its own, paper for its ground, and sits the drawing on that ground. Drawing content, not the app's look: the app's own colours are design/tokens.json.",
3 "placeholder": { "ink": "#1e2a21", "glow": "#9c4830", "paper": "#dfe6d7" },
44 "palettes": {
55 "sage": { "label": "Sage", "ink": "#1e2a21", "glow": "#9c4830", "ground": "#dfe6d7" },
66 "moss": { "label": "Moss", "ink": "#e3e9df", "glow": "#ec9478", "ground": "#171e19" },
src/glyph.test.ts +23 −4
@@ -6,7 +6,7 @@
66 import { expect, test } from "bun:test";
77 import { claudeEnv, drawArgs, resultText } from "./claude.ts";
88 import { claudeDrawer } from "./draw.ts";
9 import { ask, GLOW, INK, inPalette, nameOf, PALETTES, parseReply, sanitize } from "./glyph.ts";
9 import { ask, GLOW, INK, inPalette, nameOf, PALETTES, PAPER, parseReply, sanitize } from "./glyph.ts";
1010
1111 const ok = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><title>Hex Chip</title><path d="M12 2 L52 2" stroke="${INK}" stroke-width="4" stroke-linecap="round"/><circle cx="32" cy="32" r="6" fill="${GLOW}"/></svg>`;
1212
@@ -38,9 +38,21 @@ test("nothing that runs, loads or links gets through", () => {
3838 expect(clean).not.toContain("<circle");
3939 });
4040
41 test("a value is a value: no url(#…), no colour functions, only a transform's own", () => {
42 const svg = sanitize(`<svg viewBox="0 0 8 8"><circle r="1" fill="url(#g)" stroke="rgb(1,2,3)" transform="rotate(45 4 4)"/></svg>`)!;
41 test("a value is a value: no colour functions, only a transform's own and a local url", () => {
42 const svg = sanitize(`<svg viewBox="0 0 8 8"><circle r="1" fill="rgb(1,2,3)" stroke="hsl(1,2%,3%)" transform="rotate(45 4 4)"/></svg>`)!;
4343 expect(svg).toContain(`<circle r="1" transform="rotate(45 4 4)"/>`);
44 // A url points inside the SVG, from a paint or a clip, and nowhere else.
45 const refs = sanitize(`<svg viewBox="0 0 8 8"><circle r="1" fill="url(#a1)" stroke="url(data:x)" clip-path="url(#c)" opacity="url(#a1)"/><rect width="1" height="1" fill="url( #a1)" stroke="url(#a1) url(#b)"/></svg>`)!;
46 expect(refs).toContain(`<circle r="1" fill="url(#a1)" clip-path="url(#c)"/>`);
47 expect(refs).toContain(`<rect width="1" height="1"/>`);
48 expect(sanitize(`<svg viewBox="0 0 8 8"><circle r="1" id="1x" fill="url(#1x)"/><circle r="1" id="ok-1"/></svg>`)).toContain(`<circle r="1"/><circle r="1" id="ok-1"/>`);
49 });
50
51 test("depth gets through: gradients, stops and clips, in the placeholder colours", () => {
52 const deep = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><title>Lit Orb</title><defs><radialGradient id="a1" cx="0.35" cy="0.3" r="0.8" fx="0.3" fy="0.25" gradientTransform="rotate(10 0.5 0.5)"><stop offset="0" stop-color="${GLOW}"/><stop offset="1" stop-color="${INK}" stop-opacity="0.9"/></radialGradient><linearGradient id="a2" x1="0" y1="0" x2="0" y2="1" gradientUnits="objectBoundingBox" spreadMethod="pad"><stop offset="0%" stop-color="${PAPER}" stop-opacity="0.8"/><stop offset="100%" stop-color="${PAPER}" stop-opacity="0"/></linearGradient><clipPath id="a3" clipPathUnits="userSpaceOnUse"><circle cx="128" cy="128" r="100"/></clipPath></defs><g clip-path="url(#a3)"><circle cx="128" cy="128" r="100" fill="url(#a1)"/><ellipse cx="110" cy="80" rx="50" ry="26" fill="url(#a2)"/></g></svg>`;
53 expect(sanitize(deep)).toBe(deep);
54 // A gradient's transform is held to a transform's own grammar.
55 expect(sanitize(`<svg viewBox="0 0 8 8"><linearGradient id="g" gradientTransform="url(x)"/><circle r="1"/></svg>`)).toContain(`<linearGradient id="g"/>`);
4456 });
4557
4658 test("a title's text is text, and markup in values is refused", () => {
@@ -77,11 +89,18 @@ test("a palette swaps ink and glow, even when one palette's ink is another's pla
7789 expect(neon).not.toContain(INK);
7890 expect(inPalette(ok, "sage")).toBe(ok);
7991 expect(inPalette(ok.toUpperCase().replace("<SVG", "<svg"), "mono")).toContain(PALETTES.mono!.ink);
92 // Paper is the palette's ground, swapped once even where a ground is some placeholder.
93 const papered = `<svg viewBox="0 0 8 8"><stop stop-color="${PAPER}"/><circle r="1" fill="${INK}" stroke="${GLOW}"/></svg>`;
94 for (const [key, p] of Object.entries(PALETTES))
95 expect([key, inPalette(papered, key as keyof typeof PALETTES)]).toEqual([
96 key,
97 `<svg viewBox="0 0 8 8"><stop stop-color="${p.ground}"/><circle r="1" fill="${p.ink}" stroke="${p.glow}"/></svg>`,
98 ]);
8099 });
81100
82101 test("the ask names the brief, the kind, the colours and the count", () => {
83102 const text = ask("a fox", "badge");
84 for (const part of ["a fox", "badge", INK, GLOW, "4 distinct", "viewBox=\"0 0 64 64\""])
103 for (const part of ["a fox", "badge", INK, GLOW, PAPER, "4 distinct", "viewBox=\"0 0 256 256\"", "radialGradient", "url(#"])
85104 expect([part, text.includes(part)]).toEqual([part, true]);
86105 });
87106
src/glyph.ts +34 −17
@@ -4,9 +4,10 @@
44 * functions, no I/O — `src/draw.ts` runs Claude, `src/actions.ts` keeps
55 * the rows.
66 *
7 * Every SVG is drawn in two placeholder colours, INK and GLOW. A palette is
8 * those two swapped for two others, here for a download and in
9 * `static/glyph.js` for the preview, so one drawing reads in any theme.
7 * Every SVG is drawn in three placeholder colours, INK, GLOW and PAPER. A
8 * palette swaps ink and glow for its own two and paper for its ground, so
9 * one drawing reads in any theme; tone between them comes from opacity and
10 * local gradients, never a fourth colour.
1011 * The colours themselves are data: `design/palettes.json`.
1112 */
1213
@@ -25,6 +26,8 @@ export const isKind = (value: unknown): value is Kind =>
2526 /** The placeholder colours every SVG is drawn in. */
2627 export const INK: string = colours.placeholder.ink;
2728 export const GLOW: string = colours.placeholder.glow;
29 /** The ground's colour, for highlights and cut-outs drawn on top. */
30 export const PAPER: string = colours.placeholder.paper;
2831
2932 /** Palettes a drawing can be shown and saved in. */
3033 export const PALETTES: Readonly<
@@ -37,20 +40,22 @@ export const isPalette = (value: unknown): value is Palette =>
3740 export const COUNT = 4;
3841 export const PROMPT_MAX = 400;
3942 /** The most a kept SVG may weigh, in characters. */
40 export const SVG_MAX = 16_000;
43 export const SVG_MAX = 48_000;
4144
4245 /** What Claude is asked, for one prompt. */
4346 export function ask(prompt: string, kind: Kind): string {
44 return `You are a sharp vector designer. Draw ${COUNT} distinct ${KINDS[kind]} concepts for this brief:
47 return `You are a senior identity designer who crafts polished, detailed vector marks — the finish of a studio logo, not clip art. Draw ${COUNT} distinct ${KINDS[kind]} concepts for this brief:
4548
4649 ${prompt}
4750
4851 Rules for every SVG:
49 - Root element exactly: <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
52 - Root element exactly: <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
5053 - First child: <title> with a two-to-four word name for the concept.
51 - Use ONLY these colours, written as hex in fill/stroke attributes: ${INK} (main ink) and ${GLOW} (accent glow), plus "none". No other colours, no gradients, no opacity tricks, no CSS, no <style>.
52 - Allowed elements: g, path, circle, ellipse, rect, line, polyline, polygon, title. No text, no images, no filters, no scripts, no links.
53 - Bold and simple: it must still read at 24 pixels. Round line caps and joins. Stay inside the viewBox with a little margin.
54 - Colours: ONLY ${INK} (main ink), ${GLOW} (accent glow) and ${PAPER} (paper — the background colour, for highlights, cut-outs and inner detail drawn on top), plus "none". Write them as hex in fill, stroke and stop-color. No other colours, no colour functions, no CSS, no <style>.
55 - Depth and tone: build shading with linearGradient/radialGradient inside <defs> whose stops use only those colours (fade with stop-opacity), and with fill-opacity/stroke-opacity. Reference them as fill="url(#id)" or stroke="url(#id)"; clipPath works the same way with clip-path="url(#id)". Give every id a short prefix unique to the concept (a1, b1, …).
56 - Allowed elements: svg, title, defs, g, path, circle, ellipse, rect, line, polyline, polygon, linearGradient, radialGradient, stop, clipPath. No text, no images, no filters, no masks, no patterns, no use, no scripts, no links.
57 - Craft: layer several shapes — a confident silhouette, then secondary forms, highlights in paper, a shadow or inner bevel in a darker tone, and fine detail lines in varied stroke weights. Use precise curves (cubic béziers), consistent corner radii and optical balance. Aim for roughly 15–60 shapes, not 3.
58 - It must still read as one clear shape at 32 pixels: the detail rewards a close look, it never muddies the silhouette. Stay inside the viewBox with a margin of about 12.
5459 - Make the ${COUNT} concepts genuinely different ideas, not variations of one.
5560
5661 Reply with exactly ${COUNT} fenced code blocks marked svg, one per concept, and nothing else.`;
@@ -74,14 +79,21 @@ export function parseReply(reply: string): string[] {
7479 // trick nobody thought of has nothing to ride in on.
7580 const ELEMENTS = new Set([
7681 "svg", "g", "path", "circle", "ellipse", "rect", "line", "polyline", "polygon", "title",
82 "defs", "linearGradient", "radialGradient", "stop", "clipPath",
7783 ]);
7884 const ATTRIBUTES = new Set([
7985 "viewBox", "d", "cx", "cy", "r", "rx", "ry", "x", "y", "x1", "y1", "x2", "y2",
8086 "width", "height", "points", "fill", "stroke", "stroke-width", "stroke-linecap",
8187 "stroke-linejoin", "stroke-miterlimit", "stroke-dasharray", "stroke-dashoffset",
8288 "fill-rule", "clip-rule", "opacity", "fill-opacity", "stroke-opacity", "transform",
89 "id", "offset", "stop-color", "stop-opacity", "gradientUnits", "gradientTransform",
90 "spreadMethod", "fx", "fy", "fr", "clip-path", "clipPathUnits",
8391 ]);
84 /** A value: numbers, path data, a colour keyword or hex — never a url, a function but a transform's, or markup. */
92 /** Attributes that may point at a paint or clip in the same SVG — `url(#id)` and nothing else. */
93 const REFERENCES = new Set(["fill", "stroke", "clip-path"]);
94 const LOCAL = /^url\(#[A-Za-z][\w-]*\)$/;
95 const TRANSFORM = /^(\s*(matrix|translate|scale|rotate|skewX|skewY)\([-\d.,\s]*\)\s*)+$/;
96 /** A value: numbers, path data, a colour keyword or hex — never markup; a function only as a transform or a local url. */
8597 const VALUE = /^[-\w\s.,#%()]*$/;
8698
8799 /** Text as text: a bare & is escaped, an entity already written stays one (entities are inert). */
@@ -136,9 +148,10 @@ export function sanitize(input: string): string | undefined {
136148 for (const a of attrs.matchAll(/([^\s=>\/]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g)) {
137149 const key = a[1] ?? "";
138150 const value = a[2] ?? a[3] ?? a[4] ?? "";
139 if (!ATTRIBUTES.has(key) || !VALUE.test(value) || value.length > 4000) continue;
140 if (/\(/.test(value) && key !== "transform") continue;
141 if (key === "transform" && !/^(\s*(matrix|translate|scale|rotate|skewX|skewY)\([-\d.,\s]*\)\s*)+$/.test(value)) continue;
151 if (!ATTRIBUTES.has(key) || !VALUE.test(value) || value.length > 16_000) continue;
152 const transform = key === "transform" || key === "gradientTransform";
153 if (/\(/.test(value) && !(transform && TRANSFORM.test(value)) && !(REFERENCES.has(key) && LOCAL.test(value))) continue;
154 if (key === "id" && !/^[A-Za-z][\w-]*$/.test(value)) continue;
142155 kept.push(`${key}="${value}"`);
143156 }
144157 const head = `<${name}${kept.length ? ` ${kept.join(" ")}` : ""}`;
@@ -163,11 +176,15 @@ export function nameOf(svg: string): string {
163176 return title === undefined || title === "" ? "Untitled" : title.replace(/&amp;/g, "&");
164177 }
165178
166 /** The SVG in a palette: INK and GLOW swapped for the palette's two. */
179 /** The SVG in a palette: INK and GLOW swapped for the palette's two, PAPER for its ground. */
167180 export function inPalette(svg: string, palette: Palette): string {
168 const { ink, glow } = PALETTES[palette]!;
181 const { ink, glow, ground } = PALETTES[palette]!;
182 // Every placeholder is marked before any is swapped, so a palette whose colour is another placeholder is not swapped twice.
169183 return svg
170184 .replace(new RegExp(INK, "gi"), "\u0000ink")
171 .replace(new RegExp(GLOW, "gi"), glow)
172 .replace(/\u0000ink/g, ink);
185 .replace(new RegExp(GLOW, "gi"), "\u0000glow")
186 .replace(new RegExp(PAPER, "gi"), "\u0000paper")
187 .replace(/\u0000ink/g, ink)
188 .replace(/\u0000glow/g, glow)
189 .replace(/\u0000paper/g, ground);
173190 }
src/mcp.ts +2 −2
@@ -16,7 +16,7 @@ import {
1616 type Drawing,
1717 type Result,
1818 } from "./actions.ts";
19 import { GLOW, INK, KINDS, PROMPT_MAX } from "./glyph.ts";
19 import { GLOW, INK, KINDS, PAPER, PROMPT_MAX } from "./glyph.ts";
2020 import { title } from "./env.ts";
2121 import { fields } from "./http.ts";
2222 import type { Who } from "./who.ts";
@@ -46,7 +46,7 @@ const shaped = <T extends Drawing | Drawing[]>(r: Result<T>): Result<unknown> =>
4646 export const tools: readonly Tool[] = [
4747 {
4848 name: "draw",
49 description: `Ask Claude for SVG concepts from a prompt. Answers at once with status \`drawing\`; call get_drawing until it is \`done\` (about a minute). The SVGs use ${INK} as ink and ${GLOW} as glow: swap those two for your own colours.`,
49 description: `Ask Claude for SVG concepts from a prompt. Answers at once with status \`drawing\`; call get_drawing until it is \`done\` (about a minute). The SVGs use ${INK} as ink, ${GLOW} as glow and ${PAPER} as paper (the background): swap those three for your own colours.`,
5050 inputSchema: {
5151 type: "object",
5252 properties: {
src/routes.ts +1 −1
@@ -86,7 +86,7 @@ export const DRAWING: Schema = {
8686 svgs: {
8787 type: "array",
8888 items: { type: "string" },
89 description: "The drawings, as SVG, in the placeholder ink and glow colours.",
89 description: "The drawings, as SVG, in the placeholder ink, glow and paper colours.",
9090 },
9191 error: { type: ["string", "null"] },
9292 author_id: { type: "string" },
↑↓ navigate ↵ open esc close

Keyboard shortcuts