Skip to content
Merged
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
50 changes: 18 additions & 32 deletions bin/demoData.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,44 +2,30 @@
// agents draw on the surface. Keep this file dependency-free like the CLI.

const JWT_DIAGRAM = `
<svg width="100%" viewBox="0 0 680 320" font-family="var(--font-sans)" font-size="13">
<defs>
<marker id="arr" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6.5" markerHeight="6.5" orient="auto-start-reverse">
<path d="M0 0L10 5L0 10z" fill="var(--color-text-secondary)"/>
</marker>
<marker id="arr-danger" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6.5" markerHeight="6.5" orient="auto-start-reverse">
<path d="M0 0L10 5L0 10z" fill="var(--color-text-danger)"/>
</marker>
<marker id="arr-success" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6.5" markerHeight="6.5" orient="auto-start-reverse">
<path d="M0 0L10 5L0 10z" fill="var(--color-text-success)"/>
</marker>
</defs>
<svg width="100%" viewBox="0 0 680 320">
<line class="leader" x1="110" y1="52" x2="110" y2="300"/>
<line class="leader" x1="340" y1="52" x2="340" y2="300"/>
<line class="leader" x1="570" y1="52" x2="570" y2="300"/>

<line x1="110" y1="52" x2="110" y2="300" stroke="var(--color-border-tertiary)" stroke-dasharray="3 4"/>
<line x1="340" y1="52" x2="340" y2="300" stroke="var(--color-border-tertiary)" stroke-dasharray="3 4"/>
<line x1="570" y1="52" x2="570" y2="300" stroke="var(--color-border-tertiary)" stroke-dasharray="3 4"/>
<rect class="box" x="35" y="10" width="150" height="40"/>
<text class="th" x="110" y="35" text-anchor="middle">Client</text>
<g class="c-blue"><rect class="box" x="265" y="10" width="150" height="40"/><text class="th" x="340" y="35" text-anchor="middle">/api (guarded)</text></g>
<g class="c-amber"><rect class="box" x="495" y="10" width="150" height="40"/><text class="th" x="570" y="35" text-anchor="middle">/auth/refresh</text></g>

<rect x="35" y="10" width="150" height="40" rx="8" fill="var(--color-background-secondary)" stroke="var(--color-border-tertiary)"/>
<text x="110" y="35" text-anchor="middle" font-weight="500" fill="var(--color-text-primary)">Client</text>
<rect x="265" y="10" width="150" height="40" rx="8" fill="var(--color-background-info)" stroke="var(--color-border-info)"/>
<text x="340" y="35" text-anchor="middle" font-weight="500" fill="var(--color-text-info)">/api (guarded)</text>
<rect x="495" y="10" width="150" height="40" rx="8" fill="var(--color-background-warning)" stroke="var(--color-border-warning)"/>
<text x="570" y="35" text-anchor="middle" font-weight="500" fill="var(--color-text-warning)">/auth/refresh</text>
<text class="ts" x="225" y="84" text-anchor="middle">request + expired JWT</text>
<line class="arr" x1="110" y1="92" x2="334" y2="92" marker-end="url(#arrow)"/>

<text x="225" y="84" text-anchor="middle" fill="var(--color-text-secondary)">request + expired JWT</text>
<line x1="110" y1="92" x2="334" y2="92" stroke="var(--color-text-secondary)" marker-end="url(#arr)"/>
<text class="ts c-red" x="225" y="120" text-anchor="middle">401 token_expired</text>
<line class="arr c-red" x1="340" y1="128" x2="116" y2="128" marker-end="url(#arrow)"/>

<text x="225" y="120" text-anchor="middle" fill="var(--color-text-danger)">401 token_expired</text>
<line x1="340" y1="128" x2="116" y2="128" stroke="var(--color-text-danger)" marker-end="url(#arr-danger)"/>
<text class="ts" x="340" y="172" text-anchor="middle">refresh token (httpOnly cookie)</text>
<line class="arr" x1="110" y1="180" x2="564" y2="180" marker-end="url(#arrow)"/>

<text x="340" y="172" text-anchor="middle" fill="var(--color-text-secondary)">refresh token (httpOnly cookie)</text>
<line x1="110" y1="180" x2="564" y2="180" stroke="var(--color-text-secondary)" marker-end="url(#arr)"/>
<text class="ts c-green" x="340" y="208" text-anchor="middle">new JWT + rotated refresh token</text>
<line class="arr c-green" x1="570" y1="216" x2="116" y2="216" marker-end="url(#arrow)"/>

<text x="340" y="208" text-anchor="middle" fill="var(--color-text-success)">new JWT + rotated refresh token</text>
<line x1="570" y1="216" x2="116" y2="216" stroke="var(--color-text-success)" marker-end="url(#arr-success)"/>

<text x="225" y="260" text-anchor="middle" fill="var(--color-text-secondary)">retry with new JWT</text>
<line x1="110" y1="268" x2="334" y2="268" stroke="var(--color-text-secondary)" marker-end="url(#arr)"/>
<text class="ts" x="225" y="260" text-anchor="middle">retry with new JWT</text>
<line class="arr" x1="110" y1="268" x2="334" y2="268" marker-end="url(#arrow)"/>
</svg>`;

const JWT_EXPLAINER = `
Expand Down
53 changes: 44 additions & 9 deletions guide/DESIGN_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<g>`); child `<text>` auto-switches to readable ink in light and dark |

A `<marker id="arrow">` is injected into every snippet doc — end any line with
`marker-end="url(#arrow)"` and the arrowhead inherits the line's stroke color.

```html
<svg width="100%" viewBox="0 0 680 70">
<g class="c-blue">
<rect class="box" x="10" y="10" width="130" height="40" />
<text class="th" x="75" y="35" text-anchor="middle">API</text>
</g>
<text class="ts" x="250" y="24" text-anchor="middle">202 + job id</text>
<line class="arr" x1="140" y1="30" x2="360" y2="30" marker-end="url(#arrow)" />
</svg>
```

Icons: the Tabler webfont is on the CSP allowlist —
`<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3/dist/tabler-icons.min.css">`
then `<i class="ti ti-check"></i>`.

## 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?

Expand All @@ -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 width="100%" viewBox="0 0 680 H">`.
- SVG works great — for diagrams use `<svg width="100%" viewBox="0 0 680 H">`
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.
77 changes: 76 additions & 1 deletion server/snippetPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `<svg width="0" height="0" style="position:absolute" aria-hidden="true"><defs><marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6.5" markerHeight="6.5" orient="auto-start-reverse"><path d="M0 0L10 5L0 10z" fill="context-stroke"/></marker></defs></svg>`;

// 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.
Expand Down Expand Up @@ -135,9 +209,10 @@ export function renderSnippetPage(snippet: Snippet): string {
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="${CSP}">
<title>${escapeHtml(snippet.title)}</title>
<style>${TOKENS_CSS}</style>
<style>${TOKENS_CSS}${KIT_CSS}</style>
</head>
<body>
${SVG_DEFS}
${snippet.html}
<script>${BRIDGE_JS}</script>
</body>
Expand Down
4 changes: 3 additions & 1 deletion skills/sideshow/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,9 @@ Rules of thumb:
beats one giant page.
- **Iterate with `sideshow update <id>`** (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

Expand Down
7 changes: 6 additions & 1 deletion test/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,13 +118,18 @@ test("update bumps version and keeps history; old version renderable", async ()
assert.ok(old.includes("<p>v1</p>"));
});

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: "<p>x</p>" }))).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('<marker id="arrow"') < page.indexOf("<p>x</p>"));
assert.ok(page.includes('<marker id="arrow"'));
});

test("comments attach to snippets and filter by author/after", async () => {
Expand Down
Loading