apps/glyph Public The code is public; the app answers only to 1 person who can change it and the crew.
Ask for detailed, layered drawings and let depth through the wall
Drawings looked flat because the ask demanded two flat colours on a 64-unit grid and the sanitizer refused every url(), so gradients could not survive even when Claude drew them. - The ask now wants a crafted, layered mark on a 256 canvas: gradients, opacity, highlights and fine detail, while still reading at 32px. - The wall admits defs, linear/radial gradients, stops and clip paths, and url() only as a local #id reference from fill, stroke or clip-path. Nothing external can load; previews stay <img> data URIs. - A third placeholder, paper, is swapped for the palette's ground, so highlights and cut-outs follow the theme. All three swaps go through sentinels so no colour is swapped twice. - SVG_MAX 16k -> 48k for the extra detail. Co-Authored-By: Claude Opus 5.5 <[email protected]>
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. | ||
| 15 | 15 | in Settings → App → Environment (make one with `claude setup-token`). |
| 16 | 16 | Without it a drawing fails at once with a sentence saying exactly that. |
| 17 | 17 | - **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 | |
| 20 | 22 | nothing in a drawing can run on the page. A saved file is served with a |
| 21 | 23 | 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. | |
| 25 | 30 | - **Drawings are private to whoever asked** (`contract.json`); an admin sees |
| 26 | 31 | everyone's. One drawing at a time per person, three at once for everyone. |
| 27 | 32 | 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. | ||
| 62 | 67 | | `src/actions.ts` | start, list, read, delete a drawing; the audit trail, read | |
| 63 | 68 | | `src/glyph.ts` | the ask, the reply read back, the SVG allowlist, palettes | |
| 64 | 69 | | `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 | | |
| 66 | 71 | | `contract.json` | who may do what: roles, and the least role each verb needs | |
| 67 | 72 | | `src/contract.ts`, `src/can.ts` | the table, read at boot; the forge's evaluator, verbatim | |
| 68 | 73 | | `src/security.ts` | the door: rationing, same origin, the table before the body | |
contract/openapi.json +1 −1
| @@ -442,7 +442,7 @@ | ||
| 442 | 442 | "items": { |
| 443 | 443 | "type": "string" |
| 444 | 444 | }, |
| 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." | |
| 446 | 446 | }, |
| 447 | 447 | "error": { |
| 448 | 448 | "type": [ |
design/palettes.json +2 −2
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | { |
| 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" }, | |
| 4 | 4 | "palettes": { |
| 5 | 5 | "sage": { "label": "Sage", "ink": "#1e2a21", "glow": "#9c4830", "ground": "#dfe6d7" }, |
| 6 | 6 | "moss": { "label": "Moss", "ink": "#e3e9df", "glow": "#ec9478", "ground": "#171e19" }, |
src/glyph.test.ts +23 −4
| @@ -6,7 +6,7 @@ | ||
| 6 | 6 | import { expect, test } from "bun:test"; |
| 7 | 7 | import { claudeEnv, drawArgs, resultText } from "./claude.ts"; |
| 8 | 8 | 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"; | |
| 10 | 10 | |
| 11 | 11 | 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>`; |
| 12 | 12 | |
| @@ -38,9 +38,21 @@ test("nothing that runs, loads or links gets through", () => { | ||
| 38 | 38 | expect(clean).not.toContain("<circle"); |
| 39 | 39 | }); |
| 40 | 40 | |
| 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>`)!; | |
| 43 | 43 | 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"/>`); | |
| 44 | 56 | }); |
| 45 | 57 | |
| 46 | 58 | 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 | ||
| 77 | 89 | expect(neon).not.toContain(INK); |
| 78 | 90 | expect(inPalette(ok, "sage")).toBe(ok); |
| 79 | 91 | 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 | ]); | |
| 80 | 99 | }); |
| 81 | 100 | |
| 82 | 101 | test("the ask names the brief, the kind, the colours and the count", () => { |
| 83 | 102 | 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(#"]) | |
| 85 | 104 | expect([part, text.includes(part)]).toEqual([part, true]); |
| 86 | 105 | }); |
| 87 | 106 |
src/glyph.ts +34 −17
| @@ -4,9 +4,10 @@ | ||
| 4 | 4 | * functions, no I/O — `src/draw.ts` runs Claude, `src/actions.ts` keeps |
| 5 | 5 | * the rows. |
| 6 | 6 | * |
| 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. | |
| 10 | 11 | * The colours themselves are data: `design/palettes.json`. |
| 11 | 12 | */ |
| 12 | 13 | |
| @@ -25,6 +26,8 @@ export const isKind = (value: unknown): value is Kind => | ||
| 25 | 26 | /** The placeholder colours every SVG is drawn in. */ |
| 26 | 27 | export const INK: string = colours.placeholder.ink; |
| 27 | 28 | 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; | |
| 28 | 31 | |
| 29 | 32 | /** Palettes a drawing can be shown and saved in. */ |
| 30 | 33 | export const PALETTES: Readonly< |
| @@ -37,20 +40,22 @@ export const isPalette = (value: unknown): value is Palette => | ||
| 37 | 40 | export const COUNT = 4; |
| 38 | 41 | export const PROMPT_MAX = 400; |
| 39 | 42 | /** The most a kept SVG may weigh, in characters. */ |
| 40 | export const SVG_MAX = 16_000; | |
| 43 | export const SVG_MAX = 48_000; | |
| 41 | 44 | |
| 42 | 45 | /** What Claude is asked, for one prompt. */ |
| 43 | 46 | 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: | |
| 45 | 48 | |
| 46 | 49 | ${prompt} |
| 47 | 50 | |
| 48 | 51 | 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"> | |
| 50 | 53 | - 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. | |
| 54 | 59 | - Make the ${COUNT} concepts genuinely different ideas, not variations of one. |
| 55 | 60 | |
| 56 | 61 | 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[] { | ||
| 74 | 79 | // trick nobody thought of has nothing to ride in on. |
| 75 | 80 | const ELEMENTS = new Set([ |
| 76 | 81 | "svg", "g", "path", "circle", "ellipse", "rect", "line", "polyline", "polygon", "title", |
| 82 | "defs", "linearGradient", "radialGradient", "stop", "clipPath", | |
| 77 | 83 | ]); |
| 78 | 84 | const ATTRIBUTES = new Set([ |
| 79 | 85 | "viewBox", "d", "cx", "cy", "r", "rx", "ry", "x", "y", "x1", "y1", "x2", "y2", |
| 80 | 86 | "width", "height", "points", "fill", "stroke", "stroke-width", "stroke-linecap", |
| 81 | 87 | "stroke-linejoin", "stroke-miterlimit", "stroke-dasharray", "stroke-dashoffset", |
| 82 | 88 | "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", | |
| 83 | 91 | ]); |
| 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. */ | |
| 85 | 97 | const VALUE = /^[-\w\s.,#%()]*$/; |
| 86 | 98 | |
| 87 | 99 | /** 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 { | ||
| 136 | 148 | for (const a of attrs.matchAll(/([^\s=>\/]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g)) { |
| 137 | 149 | const key = a[1] ?? ""; |
| 138 | 150 | 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; | |
| 142 | 155 | kept.push(`${key}="${value}"`); |
| 143 | 156 | } |
| 144 | 157 | const head = `<${name}${kept.length ? ` ${kept.join(" ")}` : ""}`; |
| @@ -163,11 +176,15 @@ export function nameOf(svg: string): string { | ||
| 163 | 176 | return title === undefined || title === "" ? "Untitled" : title.replace(/&/g, "&"); |
| 164 | 177 | } |
| 165 | 178 | |
| 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. */ | |
| 167 | 180 | 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. | |
| 169 | 183 | return svg |
| 170 | 184 | .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); | |
| 173 | 190 | } |
src/mcp.ts +2 −2
| @@ -16,7 +16,7 @@ import { | ||
| 16 | 16 | type Drawing, |
| 17 | 17 | type Result, |
| 18 | 18 | } from "./actions.ts"; |
| 19 | import { GLOW, INK, KINDS, PROMPT_MAX } from "./glyph.ts"; | |
| 19 | import { GLOW, INK, KINDS, PAPER, PROMPT_MAX } from "./glyph.ts"; | |
| 20 | 20 | import { title } from "./env.ts"; |
| 21 | 21 | import { fields } from "./http.ts"; |
| 22 | 22 | import type { Who } from "./who.ts"; |
| @@ -46,7 +46,7 @@ const shaped = <T extends Drawing | Drawing[]>(r: Result<T>): Result<unknown> => | ||
| 46 | 46 | export const tools: readonly Tool[] = [ |
| 47 | 47 | { |
| 48 | 48 | 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.`, | |
| 50 | 50 | inputSchema: { |
| 51 | 51 | type: "object", |
| 52 | 52 | properties: { |
src/routes.ts +1 −1
| @@ -86,7 +86,7 @@ export const DRAWING: Schema = { | ||
| 86 | 86 | svgs: { |
| 87 | 87 | type: "array", |
| 88 | 88 | 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.", | |
| 90 | 90 | }, |
| 91 | 91 | error: { type: ["string", "null"] }, |
| 92 | 92 | author_id: { type: "string" }, |