OR Key
drop another .md file to compare - side-by-side diff against snappy-chat-invariants

snappy-chat-invariants

The guarantees that keep your chat experience reliable.
personal 2 files 1 recent eval

What it does for you

The guarantees that keep your chat experience reliable.

What it produces

A recent result, so you can see the kind of work it returns.

loading…

How to get it

These run inside the Snappy workspace. Want this working in your business? I set skills like this up with you, in one focused week.

Work with me
For developers how this skill is built, graded, and how it runs

at a glance- the short version

eval modeshape
categoryArchitecture
stages11

what's inside - the parts that make up a skill 2/4 present

A skill is just a few plain-text files. Only the main one is required. The rest are optional, added as the work needs them. This is what the skill is made of; how it runs is just below.

The skill
state/skills/snappy-chat-invariants/SKILL.md present
the skill itself, in plain text
The main file. It says what the skill is and lays out the steps in plain English.
Code
state/lib/snappy-chat-invariants.ts not present
code the skill can run
Optional. Many skills are just words and need no code at all.
Scripts
state/bin/snappy-chat-invariants/ not present
helper scripts
Optional. Added when a skill has a few commands to run.
Loader
state/skills/snappy-chat-invariants/AGENTS.md present
what the AI loads on the fly
Loaded automatically the moment this skill is needed. Kept short on purpose.

how it runs - the shared frame every skill uses 3/5 present

Every skill runs the same way. One part does the work, a separate part checks it, and a short loader hands the AI exactly what it needs for the job. Anything this skill doesn't use shows a one-line note saying why, on purpose, not by accident.

makes the work The worker
not present

No work step here. This is probably a skill that reads or coordinates, not one that produces something.

checks the work The reviewer
inferred
shape gate an automatic check
The check is an automatic pass or fail on the shape of the result, run separately from the work itself.
frame
learns Self-correction
present
fixes itself learns from gaps
When a run hits a gap, the skill gets edited on the spot [FIXED] or queued for a bigger rewrite [LOGGED], so it keeps getting better.
tidies up Background fixes
present
queued for rewrite runs in the background
Bigger fixes that can't be made on the spot get queued and rewritten in the background later.
remembers Run history
present
state/log/evals.ndjson shape runs
Every run is written down here, so the next time this skill is used it already knows how the last runs went.
Critical rules the things this skill must not get wrong
  1. Never claim a feature is shipped without a corresponding invariant + lint. If you add a new structural guarantee to snappy-chat, the work is: (1) add the invariant to SKILL.md, (2) add the one-liner to this loader, (3) write or plan the lint. Do not stop at step 1.
  2. When you find a new bug class, add an invariant FIRST, then patch the instance. The invariant catalog is the fix for the class; the patch is the fix for the instance. Both are required. Patching without cataloging is how the class recurs.
  3. Planned lints are not shipped lints. All 11 invariant lints now exist and are wired into state/lint/check.ts (shipped 2026-05-01). Do not add new invariants without a paired lint file.
  4. Invariants #1–11 are ALL ENFORCED (2026-05-01). All 11 lint files exist and are wired into state/lint/check.ts. Invariants #1–7 shipped first; #8–11 (design-fidelity) shipped alongside them in the same session. The dogfood fallback (screenshot QA) is now a secondary gate, not the primary one.
  5. The "of course" framing is load-bearing. An invariant is only real when you can finish the sentence: "of course every X has Y — we lint it." If you cannot name the lint file (or it's marked planned), the invariant is aspirational; label it (planned) in SKILL.md.
  6. Headless consumer MUST emit writeback proof-of-life even when all patterns already-elevated. Silence is indistinguishable from hung consumer to agi-loop-validator.ts. Suppress-only writeback IS work.

what it has learned - fixes written back in over time sample

When a run hits something this skill didn't handle, the fix gets written back into the skill so it doesn't happen again. FIXED means it was corrected on the spot. LOGGED means it's queued for a bigger rewrite. Either way, the skill gets a little better and never makes the same mistake twice.

  1. Loading feedback rows…

how the work flows- step by step

1 generator
Server/client Artifact schema parity
Invariant: Every field in the Artifact object the server persists and serves must match the TypeScript shape the client
what this step does
**Invariant:** Every field in the Artifact object the server persists and serves must match the TypeScript shape the client reads and renders — no missing keys, no phantom keys, no type drift. **Why:** Wave 18 (2026-04-30) surfaced a refresh_url field that existed on the server response but was absent from the client type. The client silently discarded it; the "Refresh" button never wired up. A schema parity check would have caught this at build time, not during dogfood. **En
2 data
No fully-static emitTriple in server.ts
Invariant: Every emitTriple(toolName, ...) call in server.ts must reference a dynamic data source. A call where the JSON
what this step does
**Invariant:** Every emitTriple(toolName, ...) call in server.ts must reference a dynamic data source. A call where the JSON payload is a hardcoded literal (no variable substitution, no runtime lookup) is dead weight — it means the "generative UI" is just a static template that could be a markdown block. **Why:** The legacy KNOWN_COMPONENTS + per-shape regex block (145 LOC removed 2026-04-30) was full of static emitters that matched intent strings and returned hardcoded paylo
3 generator
All system-prompt @<Op> references exist in BUIL
Invariant: Every @<Op> operator reference in any system-prompt fragment injected by server.ts must have a corresponding
what this step does
**Invariant:** Every @<Op> operator reference in any system-prompt fragment injected by server.ts must have a corresponding entry in the BUILTINS registry. A reference to an undefined operator produces no UI output and no error — it silently disappears, which is the worst failure mode possible for a chat surface. **Why:** The INCREMENTAL_EDITING system-prompt fragment (added 2026-04-30) references @Patch as a taught syntax. If Patch is not in BUILTINS, every model-emitted pat
4 data
DISPATCH_REGISTRY parser format matches server e
Invariant: Every entry in DISPATCH_REGISTRY (in web/src/dispatch-card.tsx) that parses a [[TOOL:Name]]...[[/TOOL]] block
what this step does
**Invariant:** Every entry in DISPATCH_REGISTRY (in web/src/dispatch-card.tsx) that parses a [[TOOL:Name]]...[[/TOOL]] block must expect the same format that server.ts actually emits for that tool name. Format includes: whether the body is JSON, OpenUI Lang text, or a function call string. **Why:** The Lang entry parses the body as OpenUI Lang text. A new developer adding a shape might register it in DISPATCH_REGISTRY expecting JSON but wire the server to emit a function-call
5 generator
Every dispatch-registered shape has SaveArtifact
Invariant: Any component registered in DISPATCH_REGISTRY that renders a user-facing result card must be wrapped in Dispa
what this step does
**Invariant:** Any component registered in DISPATCH_REGISTRY that renders a user-facing result card must be wrapped in DispatchCardWrapper, which provides the SaveArtifactButton. A bare render without the wrapper means the user cannot save the result — violating the Wave 19 architecture contract. **Why:** The pattern was established in Wave 19 to ensure every generative-UI output is saveable. Shapes added before the wrapper was introduced, or added by agents unfamiliar with t
6 control
Every chat-inject control verb in ALLOWLIST has
Invariant: Any verb string listed in the chat-inject ALLOWLIST (CHAT_INJECT_CONTROLS Set in routes/chat-inject.ts) must
what this step does
**Invariant:** Any verb string listed in the chat-inject ALLOWLIST (CHAT_INJECT_CONTROLS Set in routes/chat-inject.ts) must have a corresponding handler in chat-inject-controller.tsx. An ALLOWLIST entry without a handler means the verb is accepted by the server, pushed to the FIFO, popped by the client, and silently discarded — no error, no action. **Why:** The chat-inject FIFO is the primary control channel for dogfood QA agents to drive the snappy-chat UI programmatically (
7 stage
Design Token Discipline in styles.css
Invariant: Every color, shadow, or border value in web/src/styles.css must reference a semantic token via var(--). Raw o
what this step does
**Invariant:** Every color, shadow, or border value in web/src/styles.css must reference a semantic token via var(--*). Raw oklch(), rgba(), hex #rrggbb, or named color literals (red, blue, etc.) are disallowed outside token-definition blocks (:root { } and :root[data-theme] { } blocks, where canonical values are defined). **Why:** DESIGN.md declares a two-hue-axis token system (surfaces hue 95-106, accent hue 38-39). When a developer hard-codes oklch(62% 0.12 39) or rgba(0,0
8 stage
Card chrome contract — canonical radius + shadow
Invariant: Every card-class component in web/src/genui/.tsx and web/src/components/.tsx must use the canonical chrome to
what this step does
**Invariant:** Every card-class component in web/src/genui/*.tsx and web/src/components/*.tsx must use the canonical chrome tokens from DESIGN.md: borderRadius must be var(--radius-lg) or var(--radius-md) (not var(--radius) or raw numeric values), and boxShadow must be var(--shadow-card) or var(--shadow-soft) (not var(--shadow-md) or hardcoded rgba/hex). A "card-class" element is one with padding + border-or-radius + shadow in the same inline style object. **Why:** 2026-05-01
9 control
Every button-like element uses the .btn class sy
Invariant: Every <button> element in web/src/ (excluding the deprecated genui/ directory) must either use the .btn class
what this step does
**Invariant:** Every <button> element in web/src/ (excluding the deprecated genui/ directory) must either use the .btn class system (btn, btn--sm, btn--lg, btn--primary, btn--ghost, btn--icon) shipped in commit a76d29d, or use an existing named CSS class with documented semantics (dispatch-card-btn, artifacts-view-button, sidebar-*, message-actions__btn), or be a whitelisted component with a documented reason. Plain <button> elements with inline style={{ padding: ..., height:
10 stage
Space grid — padding/margin/gap snap to 4/8/12/1
Invariant: Every padding, margin, and gap value in web/src/styles.css must use a pixel value from the approved grid: 0,
what this step does
**Invariant:** Every padding, margin, and gap value in web/src/styles.css must use a pixel value from the approved grid: 0, 2, 4, 6, 8, 12, 16, 20, 24, 32, 40, 48, 64. Values between grid steps (5, 7, 9, 10, 11, 13-15, 17-19, 21-31, etc.) are disallowed. var(--*) token references, calc(), and clamp() expressions are exempt. Scrollbar widths (3-5px) are whitelisted per platform convention. Border-radius has its own allowed set: 2, 4, 6, 8, 10, 12, 16, 24, 999, 50%. **Why:** Th
11 stage
Inline style discipline — JSX style={{}} must us
Invariant: Every style={{}} prop in web/src//.tsx that sets a color, spacing, or border-radius value must use a semantic
what this step does
**Invariant:** Every style={{}} prop in web/src/**/*.tsx that sets a color, spacing, or border-radius value must use a semantic token or an approved snap value. Specifically: color/background/borderColor/boxShadow must be var(--*) or a keyword (transparent, currentColor, inherit, none); padding/margin/gap must be var(--space-*) or snap to {0,2,4,6,8,12,16,20,24,32}; borderRadius must be var(--radius-*) or snap to {0,2,4,6,8,12,16,24,999}. Whitelisted: layout/display/flex/grid

SKILL.md- the skill, written out in plain English

snappy-chat-invariants

Catalog of structural guarantees that govern the snappy-chat ↔ snappy-os boundary. Each invariant is a named contract; each contract has a lint that makes violation impossible before it ships.

The "of course" framing: just as snappy-os says "of course every skill has AGENTS.md - we lint it," snappy-chat says these things too. The invariants below are the catalog.

Boundary: invariants where snappy-os owns one side of the interface (server schema, dispatch format, chat-inject controls) may be wired into state/lint/check.ts. Pure snappy-chat frontend design discipline (tokens, card chrome, buttons, spacing, inline styles) is a cockpit-owned validation path. Those diagnostics may live here as reusable scripts, but snappy-os structural lint must not fail because a sibling app's CSS needs polish.

Invariants


1. Server/client Artifact schema parity

Invariant: Every field in the Artifact object the server persists and serves must match the TypeScript shape the client reads and renders - no missing keys, no phantom keys, no type drift.

Why: Wave 18 (2026-04-30) surfaced a refresh_url field that existed on the server response but was absent from the client type. The client silently discarded it; the "Refresh" button never wired up. A schema parity check would have caught this at build time, not during dogfood.

Enforced by: state/lint/schema-parity-check.ts - compares server-side Artifact interface against the TypeScript type in web/src/ at build time. Fails with a diff of mismatched keys.

Failure mode: Build fails. The diff is printed to stderr naming the exact mismatched field. The lint runs in CI and also as a pre-commit hook candidate.


2. No fully-static emitTriple in server.ts

Invariant: Every emitTriple(toolName, ...) call in server.ts must reference a dynamic data source. A call where the JSON payload is a hardcoded literal (no variable substitution, no runtime lookup) is dead weight - it means the "generative UI" is just a static template that could be a markdown block.

Why: The legacy KNOWN_COMPONENTS + per-shape regex block (145 LOC removed 2026-04-30) was full of static emitters that matched intent strings and returned hardcoded payloads. They made server.ts larger without adding capability and created false impressions of "working" features. The pattern recurs whenever someone adds a new shape under time pressure.

Enforced by: state/lint/no-static-emitter-data.ts - AST-walks server.ts for emitTriple calls where the third argument is an object literal containing no identifier references. Fails with line numbers.

Failure mode: Build fails. The lint output names the call site and flags it as a static emitter.


3. All system-prompt @<Op> references exist in BUILTINS

Invariant: Every @<Op> operator reference in any system-prompt fragment injected by server.ts must have a corresponding entry in the BUILTINS registry. A reference to an undefined operator produces no UI output and no error - it silently disappears, which is the worst failure mode possible for a chat surface.

Why: The INCREMENTAL_EDITING system-prompt fragment (added 2026-04-30) references @Patch as a taught syntax. If Patch is not in BUILTINS, every model-emitted patch block silently falls through the null-render path. The bug is invisible in dev because the model rarely emits patches in short test conversations; it surfaces in production multi-turn sessions.

Enforced by: state/lint/builtin-whitelist.ts - extracts all @<Op> references from system-prompt string literals in server.ts and cross-checks against the BUILTINS map. Fails with the undefined operator name.

Failure mode: Build fails. The lint prints the undefined operator and the source line where it was referenced.


4. DISPATCH_REGISTRY parser format matches server emit format

Invariant: Every entry in DISPATCH_REGISTRY (in web/src/dispatch-card.tsx) that parses a [[TOOL:Name]]...[[/TOOL]] block must expect the same format that server.ts actually emits for that tool name. Format includes: whether the body is JSON, OpenUI Lang text, or a function call string.

Why: The Lang entry parses the body as OpenUI Lang text. A new developer adding a shape might register it in DISPATCH_REGISTRY expecting JSON but wire the server to emit a function-call string - the parse succeeds (it's valid text) but renders garbage. The mismatch is caught only when the specific shape is exercised in a dogfood session.

Enforced by: state/lint/dispatch-contract.ts - reads a machine-readable format annotation from each DISPATCH_REGISTRY entry and compares it against the corresponding emitTriple call in server.ts. Fails when the annotations disagree.

Failure mode: Build fails. The lint names the tool and the conflicting format claims.


5. Every dispatch-registered shape has SaveArtifactButton via DispatchCardWrapper

Invariant: Any component registered in DISPATCH_REGISTRY that renders a user-facing result card must be wrapped in DispatchCardWrapper, which provides the SaveArtifactButton. A bare render without the wrapper means the user cannot save the result - violating the Wave 19 architecture contract.

Why: The pattern was established in Wave 19 to ensure every generative-UI output is saveable. Shapes added before the wrapper was introduced, or added by agents unfamiliar with the contract, skip the wrapper silently - the card renders but has no save affordance. Invariant shipped 2026-05-01.

Enforced by: state/lint/dispatch-wrapper-check.ts - verifies (A) the toolCalls.map block in GenAssistantMessage routes through <DispatchCardWrapper> with no bare return card bypasses, and (B) the DISPATCH_REGISTRY has the expected 115 saveable + 7 whitelisted entries. Wired into state/lint/check.ts. ENFORCED.

Whitelist (legitimate bypasses): FollowUpBlock (user-action, no save), ProgressList (live progress), PhaseDisclosure (structural), WorkingFolder (fs context), ContextPanel (conv context), FeedbackForm (form submit is the action), ConfirmDialog (modal, no artifact). Expand only when a lint failure surfaces a genuine exception.

Failure mode: state/lint/check.ts exits 1 and names the bypass location with line number.


6. Every chat-inject control verb in ALLOWLIST has a matching React handler

Invariant: Any verb string listed in the chat-inject ALLOWLIST (CHAT_INJECT_CONTROLS Set in routes/chat-inject.ts) must have a corresponding handler in chat-inject-controller.tsx. An ALLOWLIST entry without a handler means the verb is accepted by the server, pushed to the FIFO, popped by the client, and silently discarded - no error, no action.

Why: The chat-inject FIFO is the primary control channel for dogfood QA agents to drive the snappy-chat UI programmatically (WKWebView swallows synthetic clicks). As new control verbs are added (view-*, theme:*, select-thread), the ALLOWLIST on the server side and the if/else chain on the client side can drift apart. The drift is invisible until a dogfood subagent tries the verb and observes no UI change. QA Wave 21 caught one instance (view-pet was missing its client handler).

Enforced by: state/lint/chat-inject-contract.ts - extracts CHAT_INJECT_CONTROLS entries from routes/chat-inject.ts, extracts data?.control === "verb" patterns and startsWith("prefix:") wildcards from chat-inject-controller.tsx, and fails if any server verb lacks a client handler or vice versa. Server-only verbs (e.g. flush, which drains the queue without pushing to the client FIFO) are whitelisted with documented reasons inside the lint. Wired into state/lint/check.ts. ENFORCED 2026-05-01.

Verified: 11 client-side verbs verified (12 in ALLOWLIST, 1 server-only: flush). All server↔client pairs agree.

Failure mode: state/lint/check.ts exits 1 and names the out-of-sync verb with its file:line location.



7. Design Token Discipline in styles.css

Invariant: Every color, shadow, or border value in web/src/styles.css must reference a semantic token via var(--*). Raw oklch(), rgba(), hex #rrggbb, or named color literals (red, blue, etc.) are disallowed outside token-definition blocks (:root { } and :root[data-theme] { } blocks, where canonical values are defined).

Why: DESIGN.md declares a two-hue-axis token system (surfaces hue 95-106, accent hue 38-39). When a developer hard-codes oklch(62% 0.12 39) or rgba(0,0,0,0.16) directly into a rule, the theme-switch mechanism breaks silently - the value stays fixed across dark/light modes, and the single-source-of-truth principle (one token change updates every use site) is violated. Observed in current code: 8 instances including .health-dot-* tier colors, .file-drop-active drag target, .video-preview-frame pure-black background, and a user-message bubble shadow.

Enforced by: state/lint/design-token-discipline.ts - reads web/src/styles.css line by line, tracks :root { } token-definition blocks (allowed to have raw values), and flags any color-bearing property outside those blocks that contains raw oklch(), rgba()/rgb(), hex #xxx, or CSS named color keywords. Exempts: color-mix() and oklch(from var(...) ...) relative-color syntax (token-composing); :where() OpenUI overrides; @keyframes blocks; var(--x, fallback) fallback arguments. Standalone/cockpit-owned diagnostic; not part of snappy-os state/lint/check.ts.

Failure mode: The standalone diagnostic exits 1 and prints the offending file:line with the raw value and a suggested token family to use instead.


8. Card chrome contract - canonical radius + shadow tokens on card-class components

Invariant: Every card-class component in web/src/genui/*.tsx and web/src/components/*.tsx must use the canonical chrome tokens from DESIGN.md: borderRadius must be var(--radius-lg) or var(--radius-md) (not var(--radius) or raw numeric values), and boxShadow must be var(--shadow-card) or var(--shadow-soft) (not var(--shadow-md) or hardcoded rgba/hex). A "card-class" element is one with padding + border-or-radius + shadow in the same inline style object.

Why: 2026-05-01 design critique found HTMLPreview (web/src/genui/html-preview.tsx:139-141) using var(--radius) + var(--shadow-md) while sibling dispatch cards use var(--radius-lg) + var(--shadow-card). The visual inequality persists because each shape is authored independently with no linted contract - without enforcement, every new shape is a coin flip on which tokens the author reaches for. The result is an inconsistent depth vocabulary across the dispatch card catalog.

Enforced by: state/lint/card-chrome-contract.ts - walks web/src/genui/*.tsx and web/src/components/*.tsx, extracts inline style={{ }} blocks, identifies card-class elements (padding + border + shadow heuristic), and flags: var(--radius) on a card, var(--shadow-md) on a card, raw numeric borderRadius (except 999 pill pattern), and hardcoded rgba/hex boxShadow. Whitelisted: artifact-card.tsx, brain-character.tsx (icon glyphs), platform-preview shapes (Slack/LinkedIn/Tweet/Email/WhatsApp/Apple - brand-faithful colors per DESIGN.md), carousel.tsx (imperative DOM mutation, not JSX prop). Standalone/cockpit-owned diagnostic. ENFORCED 2026-05-01.

Failure mode: The standalone diagnostic exits 1 and prints the offending file:line with the disallowed token and the canonical replacement to use.


9. Every button-like element uses the .btn class system or a whitelisted exception

Invariant: Every <button> element in web/src/ (excluding the deprecated genui/ directory) must either use the .btn class system (btn, btn--sm, btn--lg, btn--primary, btn--ghost, btn--icon) shipped in commit a76d29d, or use an existing named CSS class with documented semantics (dispatch-card-btn, artifacts-view-button, sidebar-*, message-actions__btn), or be a whitelisted component with a documented reason. Plain <button> elements with inline style={{ padding: ..., height: ..., background: ... }} outside the whitelist are forbidden.

Why: Design critique 2026-05-01 surfaced three button systems with disagreeing dimensions: dispatch-card-btn at 32px height, artifacts-view-button at 33px, and the composer button at 26px. Inline-styled buttons are the vector for new bespoke heights entering the codebase without review. Every new developer context adds another one-off style block. Once .btn exists, there is no legitimate reason to reach for inline styles on a new button.

Whitelist (documented exceptions):

  • SaveArtifactButton (components/save-artifact-button.tsx) - heavy state machine, uses CSS vars only.
  • FireButton (sidebar.tsx) - state machine (idle/firing/fired/error), tooltip-wrapped icon, CSS vars throughout.
  • ProfileMenuItem (sidebar.tsx) - role=menuitem inside a dropdown, semantics differ from an action button.
  • NavRow (sidebar.tsx) - inline style is layout-only (position: relative); button appearance controlled by sidebar-nav-row CSS class.
  • ArtifactReFireButton / ReFireIntentButton (components/artifacts-view.tsx) - artifactReFireButtonStyle const uses CSS vars only; scheduled for migration to .btn--sm.

Enforced by: state/lint/button-contract.ts - walks every *.tsx under web/src/ (skipping genui/), finds <button elements, classifies each as GOOD (.btn class, named CSS class) or BAD (inline style with padding/height/background and no accepted className), skipping whitelisted components by scanning the surrounding function context. Exit 0 on pass, exit 1 with file:line + suggested fix on fail. Standalone/cockpit-owned diagnostic. ENFORCED 2026-05-01.

Failure mode: The standalone diagnostic exits 1 and prints the offending file:line with the suggested migration (className="btn btn--sm" or add to whitelist with documented reason).


10. Space grid - padding/margin/gap snap to 4/8/12/16/20/24/32

Invariant: Every padding, margin, and gap value in web/src/styles.css must use a pixel value from the approved grid: 0, 2, 4, 6, 8, 12, 16, 20, 24, 32, 40, 48, 64. Values between grid steps (5, 7, 9, 10, 11, 13-15, 17-19, 21-31, etc.) are disallowed. var(--*) token references, calc(), and clamp() expressions are exempt. Scrollbar widths (3-5px) are whitelisted per platform convention. Border-radius has its own allowed set: 2, 4, 6, 8, 10, 12, 16, 24, 999, 50%.

Why: The spacing audit (2026-05-01) found 207 off-grid values scoring 1.5/5 on visual rhythm. When arbitrary values like 7px, 9px, 10px, 14px, 18px, 22px appear in different rules, the UI reads as inconsistent even when the design tokens are correct - the eye perceives different weights of space that don't form a coherent rhythm. A single-source grid (4/8/12/16/24/32) ensures every gap is a recognizable step on the scale.

Enforced by: state/lint/space-grid.ts - reads web/src/styles.css line by line, tracks :root {} token-definition blocks (exempt), and flags any padding/margin/gap declaration whose px component is off-grid. Also audits border-radius against its own allowed set. Exits non-zero with top-20 file:line samples and total count. Standalone/cockpit-owned diagnostic. ENFORCED 2026-05-01.

Failure mode: The standalone diagnostic exits 1 and prints up to 5 file:line off-grid samples on stderr.


11. Inline style discipline - JSX style={{}} must use var(--*) tokens

Invariant: Every style={{}} prop in web/src/**/*.tsx that sets a color, spacing, or border-radius value must use a semantic token or an approved snap value. Specifically: color/background/borderColor/boxShadow must be var(--*) or a keyword (transparent, currentColor, inherit, none); padding/margin/gap must be var(--space-*) or snap to {0,2,4,6,8,12,16,20,24,32}; borderRadius must be var(--radius-*) or snap to {0,2,4,6,8,12,16,24,999}. Whitelisted: layout/display/flex/grid/position/overflow, width/height/opacity/zIndex, typography props, transform/transition, positional props (top/left/right/bottom).

Why: styles.css has Invariant #7 (design-token-discipline) and Invariant #10 (space-grid) enforcing token discipline in the CSS layer. But developers reach for style={{ padding: 14, color: "#616061" }} in JSX when iterating fast, bypassing both CSS lints. The result is an invisible two-tier system: CSS is clean, JSX is arbitrary. First observed during 2026-05-01 dogfood: slack-message-preview.tsx uses color: "#616061" (raw hex) and genui/budget-card.tsx uses gap: 10, both invisible to existing lints. 667 violations were found on first scan across 149 files - confirming the class exists at scale.

Enforced by: state/lint/inline-style-discipline.ts - scans all .tsx files under web/src/, extracts style={{ }} object literals, and flags raw string/number values for color/spacing/radius properties that don't conform to the token contract. Border shorthand values (1px solid var(--border)) are correctly allowed when they contain a var(--*) reference. Exits 1 with total count + top-10 file:line samples. Standalone/cockpit-owned diagnostic. ENFORCED 2026-05-01.

Failure mode: The standalone diagnostic exits 1 and prints the offending file:line with the property name, raw value, and which token family to use (var(--space-), var(--radius-), or var(--*) color token).


How to add a new invariant

  1. Name it plainly: "of course every X has Y."
  2. Write the bug class it prevents - cite a real wave or commit where it would have blocked a regression.
  3. Name the lint file that enforces it (state/lint/<name>.ts). If the lint doesn't exist yet, mark it (planned).
  4. Add it to this catalog AND add a one-line entry to AGENTS.md.
  5. If the lint is new, dispatch a subagent to write it. Do not ship the invariant without the lint (or a clear plan for the lint + a dogfood fallback).

Eval

This skill is a reference catalog. No actor function runs. Eval is shape: the file must parse, every invariant must have all four fields (Invariant / Why / Enforced by / Failure mode), and at least one lint must be wired (not planned).

Score convention:

OutcomeScore
All four required fields present per invariant1.0
Missing field in one invariant0.5
File does not parse or invariant section absent0.0

AGENTS.md- what the AI loads when this skill comes up

snappy-chat-invariants - loader

Per-turn rules for the snappy-chat-invariants skill. Full reference: state/skills/snappy-chat-invariants/SKILL.md. Do not skip these.

Trigger conditions

Load this loader when the prompt mentions:

  • "invariant" / "invariants"
  • "structural guarantee"
  • "snappy-chat invariants"
  • "of course every" (the catalog framing)
  • "lint" in combination with snappy-chat, dispatch-card, server.ts, or DISPATCH_REGISTRY

Critical Rules

  • Never claim a feature is shipped without a corresponding invariant + lint. If you add a new structural guarantee to snappy-chat, the work is: (1) add the invariant to SKILL.md, (2) add the one-liner to this loader, (3) write or plan the lint. Do not stop at step 1.
  • When you find a new bug class, add an invariant FIRST, then patch the instance. The invariant catalog is the fix for the class; the patch is the fix for the instance. Both are required. Patching without cataloging is how the class recurs.
  • Planned lints are not shipped lints. All 11 invariant lints now exist and are wired into state/lint/check.ts (shipped 2026-05-01). Do not add new invariants without a paired lint file.
  • Invariants #1-11 are ALL ENFORCED (2026-05-01). All 11 lint files exist and are wired into state/lint/check.ts. Invariants #1-7 shipped first; #8-11 (design-fidelity) shipped alongside them in the same session. The dogfood fallback (screenshot QA) is now a secondary gate, not the primary one.
  • The "of course" framing is load-bearing. An invariant is only real when you can finish the sentence: "of course every X has Y - we lint it." If you cannot name the lint file (or it's marked planned), the invariant is aspirational; label it (planned) in SKILL.md.
  • Headless consumer MUST emit writeback proof-of-life even when all patterns already-elevated. Silence is indistinguishable from hung consumer to agi-loop-validator.ts. Suppress-only writeback IS work.

Invariant quick-reference

#InvariantLint fileStatus
1Server/client Artifact schema parityschema-parity-check.tsENFORCED
2No fully-static emitTriple in server.tsno-static-emitter-data.tsENFORCED
3All system-prompt @\<Op\> refs exist in BUILTINSbuiltin-whitelist.tsENFORCED
4DISPATCH_REGISTRY parser format matches server emitdispatch-contract.tsENFORCED
5Every dispatch shape wrapped in DispatchCardWrapperdispatch-wrapper-check.tsENFORCED
6Every chat-inject ALLOWLIST verb has React handlerchat-inject-contract.tsENFORCED
7Design token discipline (var(--*) only in styles.css)design-token-discipline.tsENFORCED
8Card Chrome Contract - canonical radius + shadow tokens on card-class componentscard-chrome-contract.tsENFORCED
9Button System Contract - every button uses .btn class system or whitelisted exceptionbutton-contract.tsENFORCED
10Space Grid - padding/margin/gap snap to 4/8/12/16/20/24/32space-grid.tsENFORCED
11Inline Style Discipline - JSX style={{}} must use var(--*) tokens (warn-level, non-blocking)inline-style-discipline.tsENFORCED

What to check on a given turn

TaskInvariants to verify
Adding shape to DISPATCH_REGISTRY#4 (format), #5 (wrapper) - both enforced by lint
New system-prompt fragment in server.ts#3 (@Op refs exist in BUILTINS) - enforced by lint
Changing Artifact type on server#1 (schema parity) - enforced by lint
New emitTriple call#2 (no hardcoded literals) - enforced by lint
New chat-inject verb#6 (add to ALLOWLIST AND wire React handler) - enforced by lint
Adding color/shadow/border to styles.css#7 (use semantic tokens, no raw oklch/rgba/hex) - enforced by lint

Dispatch-wrapper contract (Invariant #5)

Invariant #5 shipped 2026-05-01. Every shape rendered via DISPATCH_REGISTRY must be wrapped in <DispatchCardWrapper> to provide the SaveArtifactButton. The lint enforces (A) GenAssistantMessage's toolCalls map routes through the wrapper, (B) REGISTRY has 115 saveable + 7 whitelisted (no bare return statements).

Whitelist (legitimate bypass reasons): FollowUpBlock, ProgressList, PhaseDisclosure, WorkingFolder, ContextPanel, FeedbackForm, ConfirmDialog. Expand only when a lint failure surfaces a genuine exception.

If you discover a new shape should be whitelisted, add to the REGISTRY whitelist array in web/src/dispatch-card.tsx AND update this loader.

Chat-inject contract (Invariant #6)

Invariant #6 shipped 2026-05-01. Every verb in CHAT_INJECT_CONTROLS (server-side, routes/chat-inject.ts) must have a handler in client (chat-inject-controller.tsx).

Lint enforces: Server verbs ↔ client if/else handlers match; server-only verbs (e.g. flush) whitelisted with documented reasons.

Verified (2026-05-01): 11 client-side verbs, 12 ALLOWLIST entries, 1 server-only (flush). All pairs agree.

Adding a new verb: (1) add to CHAT_INJECT_CONTROLS in routes/chat-inject.ts, (2) wire the if (data?.control === "verb_name") handler in chat-inject-controller.tsx, (3) lint will verify parity on next check.ts run.

Design-token contract (Invariant #7)

Invariant #7 shipped 2026-05-01. Every color, shadow, or border in web/src/styles.css must use semantic tokens via var(--*). Raw values (oklch(), rgba(), hex #xxx, CSS color names) are disallowed outside :root { } definition blocks.

Lint enforces: Flags any property outside token-definition blocks containing raw color values; exempts color-mix(), oklch(from var(...)) relative-color syntax, @keyframes, and var(--x, fallback) fallback args.

Known violations (8 pre-existing, now enforced): .health-dot-* tier colors, .file-drop-active drag target, .video-preview-frame background, user-message bubble shadow - migrate to --color-* / --shadow-* tokens.

Adding a new style rule: (1) reference var(--token-name), (2) define the token in :root { } once (light+dark variants), (3) lint will verify on next check.ts run.

Self-Test

An agent reading this should correctly:

  1. [ ] Know ALL 11 invariants are enforced - every lint file exists and is wired in state/lint/check.ts.
  2. [ ] Know invariant #5 - state/lint/dispatch-wrapper-check.ts, 122 shapes verified (115 saveable, 7 whitelisted).
  3. [ ] Know invariant #6 - state/lint/chat-inject-contract.ts, 11 verbs verified, 1 server-only (flush).
  4. [ ] Know invariant #7 - state/lint/design-token-discipline.ts, flags raw oklch/rgba/hex outside :root { }.
  5. [ ] Know invariants #1-4 lint files shipped 2026-05-01: schema-parity-check.ts, no-static-emitter-data.ts, builtin-whitelist.ts, dispatch-contract.ts.
  6. [ ] Know invariant #8 - state/lint/card-chrome-contract.ts, card-class components must use --radius-lg/--shadow-card tokens.
  7. [ ] Know invariant #9 - state/lint/button-contract.ts, every button uses .btn class system or is whitelisted.
  8. [ ] Know invariant #10 - state/lint/space-grid.ts, padding/margin/gap must snap to 4/8/12/16/20/24/32 grid.
  9. [ ] Know invariant #11 - state/lint/inline-style-discipline.ts, JSX style={{}} must use var(--*) tokens (warn-level).
  10. [ ] Add a new invariant to both SKILL.md AND this loader table before closing a turn.
  11. [ ] Cite the specific lint file when asked "what enforces X".

<!-- kernel-ok: historical attribution for the PID-loader pattern. -->

Self-correcting loader (PID feedback)

This is the rule that makes loaders learn. Ported from snappy-kernel, where it demonstrably worked: the snappy-dom-cartographer skill mapped Skool admin UI once, every later ship-agent inherited the full map for free.

Before you finish, do two things:

1. Fix gaps (P - proportional). MANDATORY. If this loader didn't cover your case - if you had to read another file, run an undocumented command, work around a wrong selector, or discover a quirk - you MUST attempt an Edit to this AGENTS.md before you log.

  • Default action: edit this file. Surgical. Add the missing line, table

row, or code example where it belongs. No restructuring.

  • Only LOGGED is allowed when: the fix needs >10 lines, spans multiple

files, or requires a structural rewrite. In that case the state/regen/drain.sh queue picks it up asynchronously.

  • "I didn't have time" / "it's minor" / "the next agent will figure it out"

are NOT valid reasons. Minor gaps compound - that's the whole point of this loop.

  • Concrete example of a valid P-fix: discover agent-browser screenshot

takes path positionally not as --output <path>. One line in a table. Edit it in. Do not just log it.

  • The goal: the next agent never has to leave the loader.

2. Log the result.

echo "[$(date -u +%FT%TZ)] snappy-chat-invariants: <what was missing or fixed> [FIXED|LOGGED] action_kind=<kind>" >> state/log/loader-feedback.log
  • FIXED = you patched this loader inline (P-fix).
  • LOGGED = too large for inline; the PostToolUse enqueue + Stop-hook drain

will rewrite the loader from scratch on next session-end.

  • action_kind values: shape-ok, skill-ran, loader-rewritten, pattern-elevated.

Do not skip this. Every agent run must leave the system better than it found it. The loader is the setpoint; you are the sensor; the gap is the error signal; closing the gap is the correction.

api.ts- the code it can call

⚠ no api.ts - this skill has no typed action surface

scripts- helper scripts it can run

prose-only skill - no sidecar under state/bin/ yet. Steps, if any, are described in SKILL.md.

how we check it- the checks, plus the last 1 runs

rubric shape schema-shape check (no inline rubric)
recent mean 1.00 · 1 runs actor/auditor: unverifiable
deps none declared
timestamp verb score primary_issue artifact
2026-05-03 10:50Z - 1.00 - -