diff --git a/CHANGELOG.md b/CHANGELOG.md
index e7b6c41..fccf0da 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -18,6 +18,12 @@ All notable user-visible changes to this project are documented in this file.
body and both MCP `publish_snippet` tools, `--session-title` on
`sideshow publish`. Applied only when the publish creates the session —
it never overwrites a title, including renames made in the viewer.
+- A snippet kit baked into every snippet doc, so agents publish compact
+ markup instead of hand-written inline CSS: bare `button`/`input`/`select`/
+ `textarea` pre-styled to match the viewer, SVG utility classes (`t`/`ts`/
+ `th` text presets, `box`, `arr`, `leader`, `node`, `c-*` color ramps with
+ dark-mode-aware text), and a shared `#arrow` marker injected into every
+ doc. The design guide documents it as a compact reference table.
### Changed
diff --git a/bin/demoData.js b/bin/demoData.js
index 53fdc28..b824bb2 100644
--- a/bin/demoData.js
+++ b/bin/demoData.js
@@ -2,44 +2,30 @@
// agents draw on the surface. Keep this file dependency-free like the CLI.
const JWT_DIAGRAM = `
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
-
-
-
+
+ Client
+ /api (guarded)
+ /auth/refresh
-
- Client
-
- /api (guarded)
-
- /auth/refresh
+ request + expired JWT
+
- request + expired JWT
-
+ 401 token_expired
+
- 401 token_expired
-
+ refresh token (httpOnly cookie)
+
- refresh token (httpOnly cookie)
-
+ new JWT + rotated refresh token
+
- new JWT + rotated refresh token
-
-
- retry with new JWT
-
+ retry with new JWT
+
`;
const JWT_EXPLAINER = `
diff --git a/guide/DESIGN_GUIDE.md b/guide/DESIGN_GUIDE.md
index 2e0c3e0..00dda32 100644
--- a/guide/DESIGN_GUIDE.md
+++ b/guide/DESIGN_GUIDE.md
@@ -55,19 +55,53 @@ replies short; do substantial revisions as snippet updates instead.
- **Never use `position: fixed`** — the iframe sizes to content height and
fixed elements break that. Use normal-flow layout.
+## Built-in kit — reach for it before writing CSS
+
+Bare `button`, `input`, `select`, and `textarea` are pre-styled to match the
+viewer, hover/focus included — write the plain element, don't restyle it.
+Checkboxes, radios, ranges, and progress bars are themed via `accent-color`.
+
+SVG utility classes, available in every snippet:
+
+| class | effect |
+| ---------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
+| `t` / `ts` / `th` | text presets: 14px / 12px muted / 14px medium heading |
+| `box` | neutral rect — secondary fill, faint stroke, rx 8 |
+| `arr` | 1.2px connector line |
+| `leader` | dashed guide line |
+| `node` | pointer cursor + hover dim, for clickable shapes |
+| `c-blue` `c-teal` `c-amber` `c-coral` `c-green` `c-red` `c-gray` | color ramp: fill+stroke on shapes (or a whole ``); child `` auto-switches to readable ink in light and dark |
+
+A `` is injected into every snippet doc — end any line with
+`marker-end="url(#arrow)"` and the arrowhead inherits the line's stroke color.
+
+```html
+
+
+
+ API
+
+ 202 + job id
+
+
+```
+
+Icons: the Tabler webfont is on the CSP allowlist —
+` `
+then ` `.
+
## Theming — dark mode is mandatory
-CSS variables are pre-defined and adapt to light/dark automatically. Use them
-instead of hardcoded colors; never write `color: #333` (invisible in dark mode).
+For anything the kit doesn't cover, use the pre-defined CSS variables — they
+adapt to light/dark automatically. Never hardcode colors; `color: #333` is
+invisible in dark mode.
-- Backgrounds: `--color-background-primary` (surface), `-secondary`, `-tertiary`,
- and semantic `-info`, `-danger`, `-success`, `-warning`
-- Text: `--color-text-primary`, `-secondary` (muted), `-tertiary` (hints),
- plus semantic variants as above
+- Backgrounds: `--color-background-primary|secondary|tertiary` and semantic
+ `-info|-danger|-success|-warning`
+- Text: `--color-text-primary|secondary|tertiary`, plus the same semantic variants
- Borders: `--color-border-tertiary` (default, faint), `-secondary`, `-primary`,
plus semantic variants
-- Fonts: `--font-sans` (default), `--font-serif`, `--font-mono`
-- Radius: `--border-radius-md` (8px), `-lg` (12px), `-xl` (16px)
+- Fonts: `--font-sans|serif|mono`; radius: `--border-radius-md|lg|xl` (8/12/16px)
Mental test: if the background were near-black, would every element still read?
@@ -91,6 +125,7 @@ Two globals are injected into every snippet:
- Flat and clean: no gradients, drop shadows, or decorative effects.
- Sentence case for headings and labels. No emoji.
- Two font weights only: 400 and 500.
-- SVG works great — for diagrams use ``.
+- SVG works great — for diagrams use ``
+ with the kit classes above.
- Keep it focused: one concept per snippet. Publish a series of small snippets
with distinct titles rather than one giant page.
diff --git a/server/snippetPage.ts b/server/snippetPage.ts
index 1d8436d..7dc73ae 100644
--- a/server/snippetPage.ts
+++ b/server/snippetPage.ts
@@ -88,6 +88,80 @@ body {
}
`;
+// Snippet kit: element defaults and SVG utility classes baked into every
+// snippet doc so agents publish compact markup instead of hand-writing inline
+// CSS. Documented as a reference table in guide/DESIGN_GUIDE.md — keep the
+// two in sync. Note: CSS rules override SVG presentation attributes, so bare
+// element selectors here must never set properties snippets commonly set via
+// attributes (fill/font-size on text, etc.) — that's why text styling is
+// opt-in via classes.
+const KIT_CSS = `
+:root {
+ color-scheme: light dark;
+ --c-teal-bg: #e1f4f1; --c-teal-line: #1fa996; --c-teal-text: #0c6e62;
+ --c-coral-bg: #fdece5; --c-coral-line: #e8835e; --c-coral-text: #a44f28;
+}
+@media (prefers-color-scheme: dark) {
+ :root {
+ --c-teal-bg: rgba(31, 169, 150, 0.18); --c-teal-text: #6fd0c2;
+ --c-coral-bg: rgba(232, 131, 94, 0.18); --c-coral-text: #f0a987;
+ }
+}
+button {
+ font: 500 14px/1.4 var(--font-sans);
+ color: var(--color-text-primary);
+ background: none;
+ border: 0.5px solid var(--color-border-secondary);
+ border-radius: var(--border-radius-md);
+ padding: 6px 14px;
+ cursor: pointer;
+}
+button:hover { background: var(--color-background-secondary); }
+input:not([type=checkbox]):not([type=radio]):not([type=range]), select, textarea {
+ font: 14px/1.4 var(--font-sans);
+ color: var(--color-text-primary);
+ background: var(--color-background-primary);
+ border: 0.5px solid var(--color-border-secondary);
+ border-radius: var(--border-radius-md);
+ padding: 6px 10px;
+ outline: none;
+}
+input:focus, select:focus, textarea:focus { border-color: var(--color-border-info); }
+input::placeholder, textarea::placeholder { color: var(--color-text-tertiary); }
+textarea { resize: vertical; }
+input[type=checkbox], input[type=radio], input[type=range], progress {
+ accent-color: var(--color-border-info);
+}
+svg { font-family: var(--font-sans); fill: var(--color-text-primary); }
+.t { font-size: 14px; }
+.ts { font-size: 12px; fill: var(--color-text-secondary); }
+.th { font-size: 14px; font-weight: 500; }
+.box { fill: var(--color-background-secondary); stroke: var(--color-border-tertiary); rx: 8px; }
+.arr { stroke: var(--color-text-secondary); stroke-width: 1.2; fill: none; }
+.leader { stroke: var(--color-border-secondary); stroke-width: 1; stroke-dasharray: 3 4; fill: none; }
+.node { cursor: pointer; }
+.node:hover { opacity: 0.75; }
+.c-blue, .c-blue .box { fill: var(--color-background-info); stroke: var(--color-border-info); }
+.c-blue text, text.c-blue { fill: var(--color-text-info); stroke: none; }
+.c-teal, .c-teal .box { fill: var(--c-teal-bg); stroke: var(--c-teal-line); }
+.c-teal text, text.c-teal { fill: var(--c-teal-text); stroke: none; }
+.c-amber, .c-amber .box { fill: var(--color-background-warning); stroke: var(--color-border-warning); }
+.c-amber text, text.c-amber { fill: var(--color-text-warning); stroke: none; }
+.c-coral, .c-coral .box { fill: var(--c-coral-bg); stroke: var(--c-coral-line); }
+.c-coral text, text.c-coral { fill: var(--c-coral-text); stroke: none; }
+.c-green, .c-green .box { fill: var(--color-background-success); stroke: var(--color-border-success); }
+.c-green text, text.c-green { fill: var(--color-text-success); stroke: none; }
+.c-red, .c-red .box { fill: var(--color-background-danger); stroke: var(--color-border-danger); }
+.c-red text, text.c-red { fill: var(--color-text-danger); stroke: none; }
+.c-gray, .c-gray .box { fill: var(--color-background-secondary); stroke: var(--color-border-secondary); }
+.c-gray text, text.c-gray { fill: var(--color-text-secondary); stroke: none; }
+`;
+
+// Shared SVG defs injected into every snippet doc. Inline SVGs anywhere in
+// the document can reference these by id; the arrowhead inherits the
+// referencing line's stroke color via context-stroke.
+const SVG_DEFS = ` `;
+
// Bridge to the host viewer: sendPrompt/openLink mirror Claude's widget
// globals, and a ResizeObserver reports content height so the parent can
// size the sandboxed (opaque-origin) iframe.
@@ -135,9 +209,10 @@ export function renderSnippetPage(snippet: Snippet): string {
${escapeHtml(snippet.title)}
-
+
+${SVG_DEFS}
${snippet.html}
diff --git a/skills/sideshow/SKILL.md b/skills/sideshow/SKILL.md
index 2106ad8..475c6a1 100644
--- a/skills/sideshow/SKILL.md
+++ b/skills/sideshow/SKILL.md
@@ -43,7 +43,9 @@ Rules of thumb:
beats one giant page.
- **Iterate with `sideshow update `** (same card, new version) instead of
publishing near-duplicates. Versions are kept; the user can flip between them.
-- Use the theme CSS variables from the guide so snippets work in dark mode.
+- Use the built-in kit from the guide (pre-styled form elements, SVG utility
+ classes) before writing CSS; for anything else use the theme CSS variables
+ so snippets work in dark mode.
## The feedback loop
diff --git a/test/api.test.ts b/test/api.test.ts
index 92aebd8..c351c7f 100644
--- a/test/api.test.ts
+++ b/test/api.test.ts
@@ -118,13 +118,18 @@ test("update bumps version and keeps history; old version renderable", async ()
assert.ok(old.includes("v1
"));
});
-test("snippet page is wrapped with CSP and bridge", async () => {
+test("snippet page is wrapped with CSP, bridge, and kit", async () => {
const app = makeApp();
const s = (await (await app.request("/api/snippets", json({ html: "x
" }))).json()) as any;
const page = await (await app.request(`/s/${s.id}`)).text();
assert.ok(page.includes("Content-Security-Policy"));
assert.ok(page.includes("window.sendPrompt"));
assert.ok(page.includes("__sideshow"));
+ // Snippet kit: SVG utilities in the stylesheet and the shared arrow marker
+ // injected before the snippet body so url(#arrow) resolves.
+ assert.ok(page.includes(".c-blue"));
+ assert.ok(page.indexOf('x
"));
+ assert.ok(page.includes(' {