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

forked from apps/glyph

workbenchthis machine
Files cd3c7d74b323cac4ab0520540ce33bcf71a582db
173 lines · 7.4 KB · ~2.2k tok Raw History
1
/**
2
 * What a drawing is made of: the ask Claude is given, the reply read back
3
 * into SVGs, and the wall every SVG passes before it is kept. Pure
4
 * functions, no I/O — `src/draw.ts` runs Claude, `src/actions.ts` keeps
5
 * the rows.
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.
10
 * The colours themselves are data: `design/palettes.json`.
11
 */
12
13
import colours from "../design/palettes.json";
14
15
export const KINDS = {
16
  mark: "logo mark",
17
  icon: "app icon",
18
  badge: "badge",
19
  spot: "spot illustration",
20
} as const;
21
export type Kind = keyof typeof KINDS;
22
export const isKind = (value: unknown): value is Kind =>
23
  typeof value === "string" && Object.hasOwn(KINDS, value);
24
25
/** The placeholder colours every SVG is drawn in. */
26
export const INK: string = colours.placeholder.ink;
27
export const GLOW: string = colours.placeholder.glow;
28
29
/** Palettes a drawing can be shown and saved in. */
30
export const PALETTES: Readonly<
31
  Record<string, { label: string; ink: string; glow: string; ground: string }>
32
> = colours.palettes;
33
export type Palette = keyof typeof colours.palettes;
34
export const isPalette = (value: unknown): value is Palette =>
35
  typeof value === "string" && Object.hasOwn(PALETTES, value);
36
37
export const COUNT = 4;
38
export const PROMPT_MAX = 400;
39
/** The most a kept SVG may weigh, in characters. */
40
export const SVG_MAX = 16_000;
41
42
/** What Claude is asked, for one prompt. */
43
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:
45
46
${prompt}
47
48
Rules for every SVG:
49
- Root element exactly: <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
50
- 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
- Make the ${COUNT} concepts genuinely different ideas, not variations of one.
55
56
Reply with exactly ${COUNT} fenced code blocks marked svg, one per concept, and nothing else.`;
57
}
58
59
/** The SVGs in Claude's reply: each fenced block, through the wall; the ones that do not pass are dropped. */
60
export function parseReply(reply: string): string[] {
61
  const blocks = [...reply.matchAll(/```(?:svg|xml)?\s*\n([\s\S]*?)```/g)].map(
62
    (m) => m[1] ?? "",
63
  );
64
  const found =
65
    blocks.length > 0 ? blocks : [...reply.matchAll(/<svg[\s\S]*?<\/svg>/g)].map((m) => m[0]);
66
  return found
67
    .map((svg) => sanitize(svg))
68
    .filter((svg): svg is string => svg !== undefined)
69
    .slice(0, COUNT);
70
}
71
72
// The wall. An SVG is rebuilt from what is allowed, never filtered from what is
73
// not: an element or attribute missing from these lists does not survive, so a
74
// trick nobody thought of has nothing to ride in on.
75
const ELEMENTS = new Set([
76
  "svg", "g", "path", "circle", "ellipse", "rect", "line", "polyline", "polygon", "title",
77
]);
78
const ATTRIBUTES = new Set([
79
  "viewBox", "d", "cx", "cy", "r", "rx", "ry", "x", "y", "x1", "y1", "x2", "y2",
80
  "width", "height", "points", "fill", "stroke", "stroke-width", "stroke-linecap",
81
  "stroke-linejoin", "stroke-miterlimit", "stroke-dasharray", "stroke-dashoffset",
82
  "fill-rule", "clip-rule", "opacity", "fill-opacity", "stroke-opacity", "transform",
83
]);
84
/** A value: numbers, path data, a colour keyword or hex — never a url, a function but a transform's, or markup. */
85
const VALUE = /^[-\w\s.,#%()]*$/;
86
87
/** Text as text: a bare & is escaped, an entity already written stays one (entities are inert). */
88
const escapeText = (s: string): string =>
89
  s
90
    .replace(/&(?!(?:amp|lt|gt|quot|apos|#\d+|#x[0-9a-f]+);)/gi, "&amp;")
91
    .replace(/</g, "&lt;")
92
    .replace(/>/g, "&gt;");
93
94
/** The SVG rebuilt from what is allowed, or undefined when there is no SVG worth keeping. */
95
export function sanitize(input: string): string | undefined {
96
  if (input.length > SVG_MAX * 2) return undefined;
97
  // Comments, CDATA, doctypes and processing instructions go before reading tags.
98
  const source = input
99
    .replace(/<!--[\s\S]*?-->/g, "")
100
    .replace(/<!\[CDATA\[[\s\S]*?\]\]>/g, "")
101
    .replace(/<![^>]*>/g, "")
102
    .replace(/<\?[\s\S]*?\?>/g, "");
103
  const out: string[] = [];
104
  const open: string[] = [];
105
  let skipping = 0; // depth inside an element that is dropped with its content
106
  let rootSeen = false;
107
  const tag = /<(\/?)([A-Za-z][\w:-]*)((?:\s+[^\s=>\/]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+))?)*)\s*(\/?)>|([^<]+)|</g;
108
  for (const m of source.matchAll(tag)) {
109
    const [, closing, rawName, attrs = "", selfClosing, text] = m;
110
    if (text !== undefined || rawName === undefined) {
111
      // Text is kept only inside a title; anything else between tags is whitespace or noise.
112
      if (skipping === 0 && open.at(-1) === "title" && text !== undefined)
113
        out.push(escapeText(text));
114
      continue;
115
    }
116
    const name = rawName;
117
    if (closing === "/") {
118
      if (skipping > 0) {
119
        skipping--;
120
        continue;
121
      }
122
      if (open.at(-1) === name) {
123
        open.pop();
124
        out.push(`</${name}>`);
125
        if (name === "svg" && open.length === 0) break;
126
      }
127
      continue;
128
    }
129
    const allowed = ELEMENTS.has(name) && (name === "svg" ? !rootSeen : rootSeen && open.length > 0);
130
    if (!allowed || skipping > 0) {
131
      if (selfClosing !== "/") skipping++;
132
      continue;
133
    }
134
    if (name === "svg") rootSeen = true;
135
    const kept: string[] = name === "svg" ? [`xmlns="http://www.w3.org/2000/svg"`] : [];
136
    for (const a of attrs.matchAll(/([^\s=>\/]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g)) {
137
      const key = a[1] ?? "";
138
      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;
142
      kept.push(`${key}="${value}"`);
143
    }
144
    const head = `<${name}${kept.length ? ` ${kept.join(" ")}` : ""}`;
145
    if (selfClosing === "/") out.push(`${head}/>`);
146
    else {
147
      out.push(`${head}>`);
148
      open.push(name);
149
    }
150
  }
151
  if (!rootSeen) return undefined;
152
  while (open.length > 0) out.push(`</${open.pop()}>`);
153
  const svg = out.join("");
154
  // A drawing has something in it besides its name.
155
  if (!/<(path|circle|ellipse|rect|line|polyline|polygon)\b/.test(svg)) return undefined;
156
  if (!/viewBox=/.test(svg)) return undefined;
157
  return svg.length <= SVG_MAX ? svg : undefined;
158
}
159
160
/** The concept's name, from its <title>. */
161
export function nameOf(svg: string): string {
162
  const title = /<title>([^<]*)<\/title>/.exec(svg)?.[1]?.trim();
163
  return title === undefined || title === "" ? "Untitled" : title.replace(/&amp;/g, "&");
164
}
165
166
/** The SVG in a palette: INK and GLOW swapped for the palette's two. */
167
export function inPalette(svg: string, palette: Palette): string {
168
  const { ink, glow } = PALETTES[palette]!;
169
  return svg
170
    .replace(new RegExp(INK, "gi"), "\u0000ink")
171
    .replace(new RegExp(GLOW, "gi"), glow)
172
    .replace(/\u0000ink/g, ink);
173
}
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts