Strata specs — the Spec Kit
You are an agent authoring a spec, PRD, or design doc for Layers. Build it as a single HTML document on the Strata Spec Kit — never in “anthropic design style”, never with an ad-hoc stylesheet. The kit is ONE self-contained CSS file; the guided way to author is the
/speccommand.
Authoring with /spec
Open a Claude Code session anywhere in layers/layers and type /spec. The
wizard interviews you for the substance (a topic, a draft, or a conversation
to summarize), proposes the structure, writes the file to
docs/specs/<slug>.html, and publishes immediately — every spec is hosted
from its first draft, no exceptions: an always-secret gist plus an htmlpreview
link teammates open in one click. The deliverable is the hosted URL,
clickable in chat — the wizard never opens or hands back the local file path
(not shareable, useless), and the turn isn’t done until the link is in the chat
message.
Every update is a version bump: the document’s rail-foot meta (Spec · vN)
increments, and the link carries the new gist revision sha as ?v=<sha>. The
gist id never changes, so one durable document circulates. Three mechanics in
/spec Step 5 exist because each one drew blood:
gh gist editexits 0 and writes nothing. Writes go throughgh api -X PATCH gists/<id>, never that command.- No link is handed over until the STORED content is read back from the gist API and its version matches what was just written. Exit code 0 is not proof.
?v=is load-bearing, never cosmetic, and every value is single-use. The raw CDN caches per full URL, so fetching a?v=before the push lands pins that URL to stale content permanently. The value is the revision sha (unique per push), never the document version (collides, gets poisoned).
Living example, authored end to end with the command: https://htmlpreview.github.io/?https://gist.githubusercontent.com/stevendoyledesign/9538904d78e78e3371254ddc9c4a31f8/raw/unified-approvals-walker.html
The kit
- Stylesheet: hosted at
https://strata.layers.com/spec/strata-spec.css, source in the repo atapps/strata/public/spec/strata-spec.css. Specs<link>the hosted file; they never inline it. One line replaces 1062 lines of pasted CSS, and a spec always renders against the current kit. Verified 2026-07-28 through the real delivery path (secret gist → htmlpreview): styling, the embedded fonts, and the theme toggle all work cross-origin. - Berkeley Mono (Light/Regular/Medium) ships as data URIs INSIDE that stylesheet, so the fonts arrive with it and cost no second request; the body face is the Inter-first system stack. The Strata mark is inline SVG (the skeleton below carries it) — never an external image.
- The hosted kit is the only external dependency a spec may have. No CDN
scripts, no external fonts, no remote images; author images embed as data
URIs. One known limit: a cross-origin stylesheet cannot be read by JS
(
cssRulesthrowsSecurityError). Rendering is unaffected and neither skeleton script touches it. - Tokens are compiled from
packages/ui/src/css/theme.css: light-first, dark follows the OS scheme automatically. The rail-foot toggle overrides the OS by stampingdata-theme="dark"/"light"on<html>; the kit’s:root[data-theme=…]blocks outrank itsprefers-color-schemeblock, so the override holds in both directions. Never add colors beyond the kit’s variants; never override tokens in a spec. - A rendered reference using every kit section:
https://strata.layers.com/spec/demo.html(source:apps/strata/public/spec/demo.html).
Document skeleton
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Short spec title</title>
<link rel="stylesheet" href="https://strata.layers.com/spec/strata-spec.css" />
<script>
// Theme, part 1 of 2: apply a stored choice BEFORE first paint so a reader
// who overrode the OS scheme never sees a flash of the other side. No
// stored choice means no attribute, and the OS scheme drives the document.
// localStorage throws on some file:// origins, hence the try.
(function () {
try {
var t = localStorage.getItem("strata-spec-theme");
if (t === "dark" || t === "light")
document.documentElement.setAttribute("data-theme", t);
} catch (e) {}
})();
</script>
</head>
<body>
<div class="layout">
<aside>
<div class="spec-brand">
<span class="logo-wrap">
<svg viewBox="0 0 29 27" width="18" height="18" fill="currentColor" aria-hidden="true">
<path d="M14 23.9648C21.732 23.9648 28 24.6364 28 25.4648C28 26.2933 21.732 26.9648 14 26.9648C6.26801 26.9648 0 26.2933 0 25.4648C0 24.6364 6.26801 23.9648 14 23.9648ZM15 19.9648C22.732 19.9648 29 20.6364 29 21.4648C29 22.2933 22.732 22.9648 15 22.9648C7.26801 22.9648 1 22.2933 1 21.4648C1 20.6364 7.26801 19.9648 15 19.9648ZM12.5107 18.916C6.46702 18.7499 2 18.1642 2 17.4648C2 16.739 6.81129 16.133 13.2031 15.9941L12.5107 18.916ZM13.459 14.9141C7.44155 14.746 3 14.1622 3 13.4648C3 12.741 7.78484 12.1361 14.1504 11.9951L13.459 14.9141ZM14.4062 10.9131C8.41561 10.7431 4 10.1602 4 9.46484C4 8.74307 8.75795 8.1403 15.0967 7.99707L14.4062 10.9131ZM15.3545 6.91113C9.3902 6.73926 5 6.15815 5 5.46484C5 4.74508 9.73158 4.14342 16.0439 3.99805L15.3545 6.91113ZM16.3018 2.91016C10.3643 2.73637 6 2.15611 6 1.46484C6 0.747082 10.7053 0.147497 16.9912 0L16.3018 2.91016Z" />
</svg>
</span>
<span class="wordmark">SPEC</span>
</div>
<nav>
<div class="group">Frame</div>
<a href="#tldr">TL;DR</a>
<a href="#context">01 Context</a>
<div class="group">The work</div>
<a href="#design">02 Design</a>
<a href="#phases">03 Phases</a>
</nav>
<div class="rail-foot">
<span class="meta">Spec · v1</span>
<div class="theme-toggle" role="group" aria-label="Theme">
<button type="button" data-theme-set="light">Light</button>
<button type="button" data-theme-set="dark">Dark</button>
</div>
</div>
</aside>
<main>
<header class="hero">
<div class="eyebrow">Product spec · Owner · 2026-07-07</div>
<h1>The one-line thesis of the spec</h1>
<p class="deck">Two or three sentences of framing.</p>
<div class="byline"><span>Status: Draft</span><span>Repo: layers/layers</span></div>
</header>
<div class="tldr" id="tldr">
<div class="label">TL;DR</div>
<p>One short framing sentence.</p>
<ul>
<li><strong>Where we are:</strong> one short line.</li>
<li><strong>What's next:</strong> one short line.</li>
</ul>
</div>
<section id="context">
<h2><span class="num">01 · Context</span>Why now</h2>
<p class="lede">One-paragraph section framing.</p>
...
</section>
</main>
</div>
<script>
// Scrollspy: the section nearest the top lights its rail link (the
// docs-site active pill). Reads a.hash (the RESOLVED fragment), never
// the href attribute: hosted previews (htmlpreview.github.io) rewrite
// href attributes to absolute URLs, which empties attribute selectors
// like a[href^="#"]. Self-contained; plain anchors with JS off.
(function () {
var links = Array.prototype.slice
.call(document.querySelectorAll("aside nav a"))
.filter(function (a) {
return a.hash && document.getElementById(a.hash.slice(1));
});
var targets = links.map(function (a) {
return document.getElementById(a.hash.slice(1));
});
function activate() {
var y = window.scrollY + 96;
var current = targets[0];
for (var i = 0; i < targets.length; i++) {
if (targets[i].offsetTop <= y) current = targets[i];
}
links.forEach(function (a) {
a.classList.toggle(
"active",
!!current && a.hash === "#" + current.id
);
});
}
window.addEventListener("scroll", activate, { passive: true });
window.addEventListener("resize", activate);
activate();
})();
// Theme, part 2 of 2: wire the rail-foot toggle. A click stamps
// data-theme on <html> (the attribute beats the OS media query in both
// directions) and stores the choice for the next visit. The lit segment
// reports the LIVE side, whether that came from the OS or an override.
(function () {
var root = document.documentElement;
var mq = window.matchMedia("(prefers-color-scheme: dark)");
var buttons = Array.prototype.slice.call(
document.querySelectorAll(".theme-toggle button[data-theme-set]")
);
function paint() {
var attr = root.getAttribute("data-theme");
var live = attr || (mq.matches ? "dark" : "light");
buttons.forEach(function (b) {
b.setAttribute(
"aria-pressed",
b.getAttribute("data-theme-set") === live ? "true" : "false"
);
});
}
buttons.forEach(function (b) {
b.addEventListener("click", function () {
var next = b.getAttribute("data-theme-set");
root.setAttribute("data-theme", next);
try {
localStorage.setItem("strata-spec-theme", next);
} catch (e) {}
paint();
});
});
if (mq.addEventListener) mq.addEventListener("change", paint);
// An embedder can stamp data-theme on the root itself, without a click
// (the /specs preview iframe pushes the site theme in that way), so
// follow the attribute as well.
if (window.MutationObserver)
new MutationObserver(paint).observe(root, {
attributes: true,
attributeFilter: ["data-theme"]
});
paint();
})();
</script>
</body>
</html>
The shell is the strata docs layout: a sticky 240px nav rail with a hairline
divider (brand row up top — the Layers mark in its bounded squircle box, the
logo is never a bare mark — the grouped section links as 32px pills with a
bg-secondary active state, and the Spec · v1 meta pinned in the rail
foot beside the light/dark toggle), beside a centered reading column.
Both scripts are part of the skeleton, copied verbatim, and both degrade:
- Scrollspy drives the active rail pill, and falls back to plain anchors with scripting disabled.
- The theme toggle ships in two pieces. The head script applies a stored
choice before first paint; the body script wires the two segments, stamps
data-themeon<html>, and stores the choice understrata-spec-theme. Default is the OS scheme. The lit segment always reports the live side, including when an embedder stampsdata-themeon the root itself (an observer on that attribute keeps the segments honest). With scripting off the control is inert and the OS scheme still applies.
The bar (founder ruling 2026-07-15)
A good spec is concise, highly opinionated, and specific. It exists so a human or a future Claude session reads it and understands exactly what is happening and why. No fluff, no ambiguity, no noise.
- Every section decides something. If a section only collects loose ends, it does not belong in the document.
- No boilerplate closer sections. “Open questions” and “Explicitly not doing” as trailing sections are banned as defaults. A genuinely undecided item becomes a decision with a stated default (“X, unless Y proves otherwise”) inside the section it affects, or a verification gate in a phase’s acceptance criteria. A rejection earns space only where a reader would otherwise re-propose the alternative, and it sits inline next to the decision it protects.
- Thin beats padded. Sections stay thin when the substance is thin; never inflate a document to look complete.
The Content Planner spec bar (founder rulings 2026-08-09; the feature was then named Social Loops)
The exemplar every new spec studies first: docs/specs/loops-data-model.html
(titled for the feature’s then-name, Social Loops). What it locked:
- Whiteboard first. A spec about a data model or a flow LEADS with an
inline SVG diagram — the one you would draw for a room of engineers who
have never seen the feature. Theme-aware by construction:
currentColorstrokes and text, one<marker>for arrowheads, accent color only on the money path. A TL;DR is not a substitute for a diagram; when the diagram can say it, the TL;DR goes. - Golden state only. Entirely future-looking: no today/next duality, no status framing, no legacy names, no “where we are” sections. Progress lives in trackers and code, never in the reference document.
- Decisions are a table. One label, one one-line rule per row. A decision that needs a paragraph is not decided yet.
- One unified stores table. Table · holds · one writer · read by — one table beats separate sources/stores/consumers sections.
- A worked example earns its place. Replay the model on two or three real records (raw → interpreted → clustered) so the abstractions have faces.
- Wide variant, sanctioned. A table/diagram-heavy spec may carry ONE
commented page-level
<style>override: content to 1040px, prose to 960px,.tbl-wrapas a capped scroll box with stickythead th(opaquevar(--card)fill,box-shadowstanding in for the border sticky cells drop). This is the only sanctioned exemption to the no-<style>audit, and the comment in the file must say so. - Living document. A spec that tracks an active design updates in the
SAME turn a ruling lands: bump the version, re-push the SAME gist, verify,
hand over a fresh
?v=link.
Spec kit sections
“Block” is reserved for the Strata UI tier (a composed multi-component surface like
ElleBlock— seecomponents.md). These spec-authoring pieces are sections, a different domain.
The starter vocabulary — reach for these first so recurring structures read
identically across specs. When a pattern you invent (see below) recurs across
documents, promote it into the kit (edit strata-spec.css + this doc in the
same PR).
| Section | Classes | Use for |
|---|---|---|
| Nav rail | aside + .spec-brand (.logo-wrap + .wordmark), nav with .group headers, .rail-foot + .meta + .theme-toggle | The docs-style rail: brand row up top, GROUPED section links (Frame / The work / …) as pills, the version meta and the light/dark toggle pinned in the foot; long specs carry several groups |
| Theme toggle | .theme-toggle > two button[data-theme-set] | Mandatory, in the rail foot of every spec. Markup and script both come verbatim from the skeleton; never restyle it and never hand-roll a second one |
| TL;DR | .tldr + .label, p + ul | The mandatory summary card right after the hero. NEVER a wall paragraph (founder ruling 2026-07-25): one framing sentence of ~15 words, then 3 to 6 bullets, each ONE line anchored by a <strong> lead (“Where we are:”, “What’s next:”). No nested lists, no second paragraph |
| Section header | section > h2 + span.num | Numbered sections (01 · Context) |
| Callout | .callout (+ .note .info .warn .success) + .label | Decisions, constraints, risks, wins |
| Pull quote | .pullquote | One line worth enlarging |
| Pill | .pill (+ .purple .blue .green .peach) | Inline status/category chips |
| Table | .tbl-wrap > table | Comparisons, matrices (mono uppercase headers come free) |
| Anti-list | .antilist + .antilist-head, li > .ban + .why | A rejected alternative a reader would re-propose, inline beside the decision it protects (never a trailing junk-drawer section — see “The bar”) |
| Phase card | .phase + .idx, .goal, optional .accept + .deferred | Rollout phases with acceptance criteria |
| Concept | .concept + .ctag | Definitions of core terms |
| Checklist | .checklist | Launch gates inside a phase — never a trailing “Open questions” section (see “The bar”) |
| Metrics | .metrics > .metric (.value + .label) | Headline numbers: targets, baselines, results |
| Component veneer | .spec-btn (.secondary .outline .sm), .spec-badge (.secondary .outline .success), .spec-alert (.title + .desc), .spec-input, .spec-chat > .spec-message (.end) > .spec-bubble (.secondary) | Demoing REAL product UI: static, pixel-faithful renderings of the @layers/ui components |
| Rule | hr.rule | Hard breaks between arcs |
| Source note | .src-note | File paths, URLs, commit refs |
Beyond the kit — invent freely, on-brand
You have complete freedom to build whatever presents the content best: charts, product wireframes, UI mockups, timelines, diagrams, testimonials, images, comparison visuals, anything HTML/CSS can do. The kit sections are a starter set, not a boundary. What makes an invention on-brand is obeying the style law, not picking from a list:
- Color: only the kit tokens. Pastels (
--brand-purple/blue/green/peach) are flat signal fills and accents; surfaces are--background/--card; lines are--border. Never a new hex, never a gradient, never a shadow, never 3D. Inventions theme automatically ONLY if they use the tokens. Check both sides with the rail-foot toggle. - Type: body and data values are the sans at the 14px scale. Every
ALL-CAPS label on an invented element uses
.mono-labelverbatim. Mono never appears on mixed-case text or numbers. - Geometry: 8px spacing grid (4px fallback), radii 4/6/8/10px matching the kit, 1px hairlines.
- Charts: pure HTML/CSS (divs) or inline SVG. Flat pastel fills, hairline
axes in
--border,.mono-labelaxis labels, values in sans. No chart libraries (self-containment), no decoration that data doesn’t earn. - Product UI: when the spec shows REAL product surfaces (a chat stream, a
form, buttons, an alert), use the component veneer classes (
.spec-*in the vocabulary table): static, pixel-faithful renderings transcribed verbatim from@layers/ui. Never approximate a real component by hand. The veneer is a manual mirror of the components; if a component restyles, update the veneer in the same PR. - Wireframes / loose concepts: when the UI does NOT exist yet or the idea
is deliberately loose, compose from the
.wire,.wire-bar,.wire-dot,.wire-fillprimitives (hairline frames + muted skeleton shapes). Suggest the product, never counterfeit it: no real chrome colors, no fake screenshots. - Containers: invented panels sit on
.surface(the house card). - Images: embed as data URIs (self-containment), radius 8px+, and the caption goes BELOW the frame, never padded inside it.
- Quotes / testimonials: the
.pullquotetreatment, attribution in.mono-labelbeneath.
The reference for the spirit: /spec/demo.html sections 05 and 06 render an
invented chart, a wireframe, a metrics row, and a conversation mockup with
nothing but tokens + helpers. Scale complex specs the way that document
does: group the TOC into labeled sections, mix kit sections with inventions, and
let long documents grow more groups rather than flatter lists.
House rules (baked into the kit, obey in the copy)
The first three are founder rulings that got restated by hand run after run, so
they are CATEGORIES with a test, not phrase lists to pattern-match. /spec
Step 4.5 greps the finished file for them before it publishes; zero hits is the
bar.
- The TL;DR is never a giant paragraph (founder ruling 2026-07-25). The
reader has ADHD; the card gets scanned, not read. Hard shape: ONE framing
sentence of ~15 words, then 3 to 6 bullets, each ONE line with a
<strong>lead. No nested lists, no second paragraph. Short sentences throughout the rest of the document too, and a body sentence past ~25 words gets split.- Test: delete everything after the
<strong>leads. If the leads alone still say what is happening, the shape is right.
- Test: delete everything after the
- No meta discourse (founder ruling 2026-07-26, non-negotiable). The
document never mentions the document. No sentence takes the spec, a section,
a table, a list, or “we the authors” as its subject. Banned: “this document
covers”, “this spec proposes”, “in this section”, “as mentioned above”, “the
following table shows”, “we will describe” — and every variant, which is the
point.
- Test: the subject of every sentence is something in the PRODUCT (a user, a job, a route, a number), never the document or its authors. A sentence that stops being true when the doc is reorganized is meta discourse.
- Before: “The following table shows the three dispatch modes we evaluated.”
- After: “Three dispatch modes, one row each.” (then the table)
- No tour-guide writing (founder ruling 2026-07-26, non-negotiable). Never
narrate the reader’s path through the page, and never announce a fact before
stating it. Banned: “let’s look at”, “now we turn to”, “as you can see”,
“note that”, “first, we”, “keep in mind”, “here’s how it works”.
- Test: every sentence survives being read alone, out of order, with no neighbours. A sentence whose only job is to introduce the next one gets deleted, and the next one stands.
- Before: “Now let’s look at retries. As you can see, note that the backoff is capped.”
- After: “Retries back off exponentially, capped at 60s.”
.deckand.ledeare where those three break. Both slots ask for “framing”, and framing is what turns into meta discourse. A deck says what changes for the product and why now; a lede says what its section DECIDES. Neither describes the document.- No hedging. Banned: “likely”, “probably”, “seems”, “appears to”, “might be”, “could be”. A spec states decisions. Something unverified is said plainly to be unverified, or becomes a gate in a phase’s acceptance criteria.
- No marketing register. No escalating-count tricolons (“seven entities, three tiers, one principle”), no taglines, no one-word-sentence titles, no dressed-up enumerations. Say what the thing IS.
- No em dashes anywhere in the document copy; restructure the sentence. The two skeleton scripts carry em dashes in their comments and are still copied verbatim; never edit them to satisfy this rule.
- Mono is only ever ALL-CAPS — the kit’s labels/eyebrows handle this; never
put mono on mixed-case values, IDs, or URLs in body copy (
.src-noteandcodeare the exceptions, as code). - 8px spacing grid — the kit’s sections are on it; don’t add inline styles.
- In PRODUCT copy the verb is “post”, never “publish” (a social platform
gets posted to). Shipping the spec itself is still “publish”, per “Authoring
with /spec” above; a spec is never “posted”. No industry jargon (“Comments”,
not “Engagement”); no
@prefix on usernames. - Sections are numbered (
01 · Name); every spec opens with the brand row, hero, and a TL;DR. - Specs do NOT live on the strata site (the site hosts the system). They live
in
docs/specs/<slug>.html, in the repo the work lives in. - Specs are published to a gist that is always secret, never public and
never an
Artifact. The gist id is the durable address: it is re-pushed in place forever, and each push is addressed by its own revision sha in?v=. Secret means unlisted, not access-controlled; the link is the credential.