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
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,22 @@ All notable changes to this project will be documented in this file.

This project adheres to [Semantic Versioning](https://semver.org/).

## [0.15.0] - 2026-07-27

### Breaking

- **The `warm` tone and the `warm` / `warmLight` tokens are removed.** `--nb-color-warm` was a second accent channel that no host injects — the NimbleBrain runtime's projection covers ten colour vars and this was never among them — so every app rendering `<Badge tone="warm">` painted the SDK's own fallback, `#d4620a`, a hex from a retired brand generation. A library cannot ship a channel it has no unbranded value for: the neutral answer to "a second accent" is that there isn't one.

**Migration.** `tone="warm"` → `tone="accent"` where the badge is informational, `tone="warning"` where it is a caution. `tokens.warm` / `tokens.warmLight` → `tokens.accent` / `tokens.infoLight`, or the `warning` pair. Apps pinned to `^0.11.0`–`^0.14.0` are unaffected until they bump, since caret on `0.x` will not cross a minor.

### Fixed

- **Headings fall back to the body stack, not a serif.** `tokens.fontHeading` fell back to `Georgia, 'Times New Roman', serif`, so any host that injects no `--nb-font-heading` rendered headings in a display serif — the same brand-by-default problem the removal of `@nimblebrain/synapse/ui/fonts` addressed in 0.13.0, one token over. The fallback is now the sans stack: one family, hierarchy from weight and size.
- **The vendored Python IIFE tracks the build.** `python/nimblebrain_synapse/_assets/synapse-ui.iife.js` is a second shipping path, and it carried the old values independently of `dist/`.

### Added

- **A neutrality guard on the colour defaults.** Every hex in `DEFAULT_THEME_VARS` *and* in the `tokens` `var()` fallbacks must appear in a declared sanctioned set — neutral ladder, a generic blue, and the generic semantic hues. Both maps, because both are the unbranded-default claim and the brand orange sat in both: one is the block the SDK injects, the other is what each component resolves against when a host declares nothing. Typography got a guard in 0.13.0; colour had none, which is how that orange survived two releases under docblocks claiming the values were unbranded. An allowlist rather than a denylist: a denylist only catches the brand values someone thought to name, which is how the last one got in.
## [0.14.1] - 2026-07-27

### Fixed
Expand Down
5 changes: 5 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ npm run ci # lint → typecheck → build → test
To cut a release:

1. Bump `version` in `package.json` and add a CHANGELOG entry on the release branch.
**A `### Breaking` entry means the minor moves.** Nothing enforces this: the workflow
checks the tag matches `package.json` and that a `## [<version>]` heading exists, and
neither tells a minor from a patch. Consumers pin caret on `0.x`, which does not cross a
minor — so cutting a breaking change as a patch carries it to every pinned consumer
silently, which is the opposite of what the CHANGELOG's migration note promises.
2. Merge the PR.
3. Tag the merge commit on `main` and push the tag:
```bash
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,8 +121,8 @@ tokens, and the SDK loads them into the app document.
Three things worth knowing:

- **Sending no fonts is a supported configuration, not a degraded one.** Omit
`fontFaces` and the app renders in the web-safe fallbacks (`system-ui`,
`ui-monospace`, `Georgia`).
`fontFaces` and the app renders in the web-safe fallbacks (`system-ui` for
both body and headings, `ui-monospace` for code).
- **Give every `--font-*` token value a web-safe tail.** A bare family name with
no matching face falls through to the browser default, not to your intended
stack. `"'Your Sans', system-ui, sans-serif"`, never `"'Your Sans'"`.
Expand Down
4 changes: 1 addition & 3 deletions gallery/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,6 @@ const BADGE_TONES: BadgeTone[] = [
"warning",
"danger",
"processing",
"warm",
];
const STATUSES: Status[] = ["working", "completed", "failed", "idle"];

Expand Down Expand Up @@ -241,7 +240,6 @@ export function App() {
<Swatch name="danger" value={tokens.danger} />
<Swatch name="success" value={tokens.success} />
<Swatch name="warning" value={tokens.warning} />
<Swatch name="warm" value={tokens.warm} />
<Swatch name="processing" value={tokens.processing} />
<Swatch name="infoLight" value={tokens.infoLight} />
</Grid>
Expand Down Expand Up @@ -317,7 +315,7 @@ export function App() {
<Inline gap="0.5rem">
<Badge tone="accent">Inline</Badge>
<Badge tone="neutral">with</Badge>
<Badge tone="warm">gap</Badge>
<Badge tone="warning">gap</Badge>
<Spacer />
<Text size="xs" tone="faint">
← Spacer pushes →
Expand Down
16 changes: 0 additions & 16 deletions gallery/src/demo-theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,6 @@ const DEFAULT = preset({
"--nb-color-danger": "#dc2626",
"--nb-color-success": "#16a34a",
"--nb-color-warning": "#d97706",
"--nb-color-warm": "#ea580c",
"--nb-color-warm-light": "#fff7ed",
"--nb-color-processing": "#7c3aed",
"--nb-color-processing-light": "#f5f3ff",
"--nb-color-info-light": "#eff6ff",
Expand All @@ -111,8 +109,6 @@ const DEFAULT = preset({
"--nb-color-danger": "#f87171",
"--nb-color-success": "#4ade80",
"--nb-color-warning": "#fbbf24",
"--nb-color-warm": "#fb923c",
"--nb-color-warm-light": "#2a1408",
"--nb-color-processing": "#a78bfa",
"--nb-color-processing-light": "#1e1b2e",
"--nb-color-info-light": "#0c1a33",
Expand Down Expand Up @@ -153,8 +149,6 @@ const ALMANAC = preset({
"--nb-color-danger": "#a72a25",
"--nb-color-success": "#5a6e30",
"--nb-color-warning": "#9a6312",
"--nb-color-warm": "#99551f",
"--nb-color-warm-light": "#f3e9d6",
"--nb-color-processing": "#6a4a6f",
"--nb-color-processing-light": "#efe6ef",
"--nb-color-info-light": "#ece3d2",
Expand All @@ -178,8 +172,6 @@ const ALMANAC = preset({
"--nb-color-danger": "#e08a84",
"--nb-color-success": "#a3b072",
"--nb-color-warning": "#d3a85e",
"--nb-color-warm": "#cf9a5e",
"--nb-color-warm-light": "#2c2114",
"--nb-color-processing": "#b596b8",
"--nb-color-processing-light": "#241a26",
"--nb-color-info-light": "#221a10",
Expand Down Expand Up @@ -220,8 +212,6 @@ const FLUX = preset({
"--nb-color-danger": "#c81e3a",
"--nb-color-success": "#0a8f4f",
"--nb-color-warning": "#b3690a",
"--nb-color-warm": "#b5530a",
"--nb-color-warm-light": "#fdf0e6",
"--nb-color-processing": "#1d6fb8",
"--nb-color-processing-light": "#e8f1fb",
"--nb-color-info-light": "#e9f7ef",
Expand All @@ -245,8 +235,6 @@ const FLUX = preset({
"--nb-color-danger": "#ff4d6d",
"--nb-color-success": "#39ff97",
"--nb-color-warning": "#ffd23f",
"--nb-color-warm": "#ff9f45",
"--nb-color-warm-light": "#241405",
"--nb-color-processing": "#4cc2ff",
"--nb-color-processing-light": "#07212e",
"--nb-color-info-light": "#06231a",
Expand Down Expand Up @@ -287,8 +275,6 @@ const BLOOM = preset({
"--nb-color-danger": "#e26d7a",
"--nb-color-success": "#4fae8f",
"--nb-color-warning": "#e0a64e",
"--nb-color-warm": "#e08a5e",
"--nb-color-warm-light": "#fdf0e9",
"--nb-color-processing": "#9b7bff",
"--nb-color-processing-light": "#f1ecff",
"--nb-color-info-light": "#eef0ff",
Expand All @@ -312,8 +298,6 @@ const BLOOM = preset({
"--nb-color-danger": "#f0909b",
"--nb-color-success": "#6fccaf",
"--nb-color-warning": "#f0c074",
"--nb-color-warm": "#f0a37e",
"--nb-color-warm-light": "#2c1c14",
"--nb-color-processing": "#b9a3ff",
"--nb-color-processing-light": "#241d38",
"--nb-color-info-light": "#1a1d33",
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@nimblebrain/synapse",
"version": "0.14.1",
"version": "0.15.0",
"description": "Agent-aware app SDK for the MCP ext-apps protocol",
"type": "module",
"exports": {
Expand Down
2 changes: 1 addition & 1 deletion python/nimblebrain_synapse/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,4 @@
# independently of the JS one (different cadence, different consumers); the two
# meet only on the wire protocol. CI keeps this equal to the sibling package.json
# version (ci.yml build job), so the pin can't silently go stale.
__client_version__ = "0.14.1"
__client_version__ = "0.15.0"
4 changes: 2 additions & 2 deletions python/nimblebrain_synapse/_assets/synapse-ui.iife.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/__tests__/theme-font-faces.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ describe("web-safe defaults — the no-host guarantee", () => {
// with no host, no network and no font files still renders correctly.
expect(tokens.fontSans).toContain("system-ui");
expect(tokens.fontMono).toContain("ui-monospace");
expect(tokens.fontHeading).toContain("Georgia");
expect(tokens.fontHeading).toContain("system-ui");

// ...and carries no brand, current or historical.
for (const value of [tokens.fontSans, tokens.fontMono, tokens.fontHeading]) {
Expand Down
2 changes: 1 addition & 1 deletion src/__tests__/ui/components.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ describe("Badge + Card", () => {
it("renders a badge label and card content", () => {
render(
<Card>
<Badge tone="warm">HIGH</Badge>
<Badge tone="warning">HIGH</Badge>
</Card>,
);
expect(screen.getByText("HIGH")).toBeDefined();
Expand Down
97 changes: 96 additions & 1 deletion src/__tests__/ui/tokens.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,105 @@ describe("token contract", () => {
expect(tokens.fg).toBe("var(--color-text-primary, #111827)");
expect(tokens.fontSans).toContain("system-ui"); // not 'Satoshi'
expect(tokens.fontSans).not.toContain("Satoshi");
expect(tokens.fontHeading).toContain("Georgia"); // serif fallback, not 'Erode'
// Headings fall back to the same sans stack as body: one family, with
// hierarchy from weight and size. A serif heading fallback would reproduce
// the display-serif signature on any host that injects nothing.
expect(tokens.fontHeading).toContain("system-ui");
expect(tokens.fontHeading).not.toContain("Erode");
});

it("backs every default with an unbranded value, never a brand hex", () => {
// The font side got a guard in 0.13.0 when a brand stylesheet import was
// removed; the colour side got none, which is how #d4620a — the warm accent
// from a NimbleBrain brand generation — sat across two releases in *both*
// unbranded-default maps, each under a docblock claiming otherwise.
//
// Both are checked here, because both are that claim: DEFAULT_THEME_VARS is
// the block the SDK injects, and `tokens` holds the `var()` fallback each
// component resolves against when a host declares nothing. A guard on one
// leaves the other free to reintroduce exactly what this test exists to
// catch.
//
// An allowlist rather than a denylist: the map is small and hand-authored,
// so the sanctioned set can be stated outright. A denylist only catches the
// brand values someone thought to name, which is how the last one got in.
//
// ADMISSION RULE, so this stays a guard and does not decay into a list to
// append to: a hex qualifies when it was not *chosen for a brand*. In
// practice every hue below is a stock Tailwind ramp step — blue-600,
// red-600/400, emerald-600/400, amber-500/400, violet-600/400, indigo-400 —
// and every `*-light` ground is a neutral tint of one of them. The test is
// not "no brand uses this hue", which nothing could pass: #7c3aed is
// violet-600 off the shelf and a brand happens to use it too. #d4620a fails
// because it is bespoke and brand-tuned, picked to be one product's warm
// accent and nothing else's. If a candidate is not a stock ramp step or a
// tint of one, it does not belong here — take it to the host instead.
const SANCTIONED = new Set(
[
// Neutral ladder — surfaces, text, borders.
"#ffffff",
"#fafafa",
"#f3f4f6",
"#111827",
"#6b7280",
"#9ca3af",
"#e5e7eb",
"#d1d5db",
"#18181b",
"#27272a",
"#2f2f34",
"#a1a1aa",
"#71717a",
"#3f3f46",
"#52525b",
// A generic blue, and the generic semantic hues.
"#2563eb",
"#818cf8",
"#dc2626",
"#f87171",
"#059669",
"#34d399",
"#f59e0b",
"#fbbf24",
"#7c3aed",
"#a78bfa",
// Tints those hues are laid on.
"#f3eeff",
"#2a2440",
"#eef4ff",
"#1e2a44",
].map((h) => h.toLowerCase()),
);

for (const mode of ["light", "dark"] as const) {
for (const [name, value] of Object.entries(DEFAULT_THEME_VARS[mode])) {
// Notation first, membership second. Scanning a value for hexes and
// checking only what turns up leaves every other CSS colour syntax
// unguarded — `rgb(212, 98, 10)` is byte-for-byte #d4620a and passed
// this whole file. Requiring a hex literal is what makes the
// membership check below total.
expect(value, `${mode} ${name}: expected a hex literal`).toMatch(/^#[0-9a-f]{3,8}$/i);
for (const hex of value.toLowerCase().match(/#[0-9a-f]{3,8}/g) ?? []) {
expect(SANCTIONED.has(hex), `${mode} ${name}: ${hex} is not a sanctioned neutral`).toBe(
true,
);
}
}
}

// No anchor here, and the extracting loop is load-bearing for it: these
// values are `var(--token, <fallback>)` strings, not bare hexes, and the
// non-colour ones (font stacks, radii, weights) carry no hex at all and
// simply contribute nothing to check.
for (const [name, value] of Object.entries(tokens)) {
for (const hex of value.toLowerCase().match(/#[0-9a-f]{3,8}/g) ?? []) {
expect(SANCTIONED.has(hex), `tokens.${name}: ${hex} is not a sanctioned neutral`).toBe(
true,
);
}
}
});

it("maps the type scale to the matching size + line-height vars", () => {
expect(textStyle("sm")).toEqual({
fontSize: "var(--font-text-sm-size, 0.875rem)",
Expand Down
10 changes: 4 additions & 6 deletions src/theme-defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@
* - any var nobody declares still resolves to a theme-correct neutral value.
*
* It's the `var()` fallback, but able to branch on theme. The values stay
* deliberately NEUTRAL (grays + a generic blue), never NimbleBrain brand —
* brand arrives only by host injection, mirroring `ui/tokens.ts`. This keeps
* neutral grays, a generic blue, and generic semantic hues for danger, success,
* warning and processing — never a *brand* value. Brand arrives only by host
* injection, mirroring `ui/tokens.ts`. `__tests__/ui/tokens.test.ts` holds the
* line: every hex here must be in the sanctioned set. This keeps
* the library host-agnostic while guaranteeing every token resolves correctly
* in both themes even against an incomplete host, a standalone `connect()`
* widget, or a third-party host.
Expand Down Expand Up @@ -70,8 +72,6 @@ const LIGHT: Record<string, string> = {
"--nb-color-danger": "#dc2626",
"--nb-color-success": "#059669",
"--nb-color-warning": "#f59e0b",
"--nb-color-warm": "#d4620a",
"--nb-color-warm-light": "#fef5ee",
"--nb-color-processing": "#7c3aed",
"--nb-color-processing-light": "#f3eeff",
"--nb-color-info-light": "#eef4ff",
Expand Down Expand Up @@ -103,8 +103,6 @@ const DARK: Record<string, string> = {
"--nb-color-danger": "#f87171",
"--nb-color-success": "#34d399",
"--nb-color-warning": "#fbbf24",
"--nb-color-warm": "#fb923c",
"--nb-color-warm-light": "#3a2a1e",
"--nb-color-processing": "#a78bfa",
"--nb-color-processing-light": "#2a2440",
"--nb-color-info-light": "#1e2a44",
Expand Down
10 changes: 1 addition & 9 deletions src/ui/components/Badge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,7 @@
import type { HTMLAttributes, ReactNode } from "react";
import { tokens } from "../tokens.js";

export type BadgeTone =
| "neutral"
| "accent"
| "success"
| "warning"
| "danger"
| "processing"
| "warm";
export type BadgeTone = "neutral" | "accent" | "success" | "warning" | "danger" | "processing";

/** `{ bg, fg }` per tone. Soft tinted background, same-hue label. The status
* tones have no `*Light` token so their tint is derived from the fg color. */
Expand All @@ -24,7 +17,6 @@ const TONE: Record<BadgeTone, { bg: string; fg: string }> = {
warning: { bg: `color-mix(in oklab, ${tokens.warning} 14%, transparent)`, fg: tokens.warning },
danger: { bg: `color-mix(in oklab, ${tokens.danger} 14%, transparent)`, fg: tokens.danger },
processing: { bg: tokens.processingLight, fg: tokens.processing },
warm: { bg: tokens.warmLight, fg: tokens.warm },
};

interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
Expand Down
13 changes: 7 additions & 6 deletions src/ui/tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,13 @@
* with no React re-render: the host swaps the `:root` vars and every `var()`
* re-resolves.
*
* The fallbacks are deliberately **neutral** (system fonts, neutral grays, a
* generic blue) — NOT NimbleBrain brand. Brand arrives by injection when the
* app runs inside the NimbleBrain host; standalone/static renders get a sane
* The fallbacks are deliberately **unbranded** — system fonts, neutral grays, a
* generic blue, and generic semantic hues for the status channels. NOT
* NimbleBrain brand. Brand arrives by injection when the app runs inside the
* NimbleBrain host; standalone/static renders get a sane
* unbranded default. This keeps the library host-agnostic.
* `__tests__/ui/tokens.test.ts` holds the line: every hex in these fallbacks
* must be in the sanctioned set, same as `theme-defaults.ts`.
*
* Components import the static `tokens` object and style with CSS `var()`, so
* theming (incl. light/dark) resolves in CSS with no re-render.
Expand Down Expand Up @@ -42,16 +45,14 @@ export const tokens = {
danger: "var(--nb-color-danger, #dc2626)",
success: "var(--nb-color-success, #059669)",
warning: "var(--nb-color-warning, #f59e0b)",
warm: "var(--nb-color-warm, #d4620a)",
warmLight: "var(--nb-color-warm-light, #fef5ee)",
processing: "var(--nb-color-processing, #7c3aed)",
processingLight: "var(--nb-color-processing-light, #f3eeff)",
infoLight: "var(--nb-color-info-light, #eef4ff)",

// ── Typography ──
fontSans: "var(--font-sans, system-ui, -apple-system, BlinkMacSystemFont, sans-serif)",
fontMono: "var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)",
fontHeading: "var(--nb-font-heading, Georgia, 'Times New Roman', serif)",
fontHeading: "var(--nb-font-heading, system-ui, -apple-system, BlinkMacSystemFont, sans-serif)",
weightNormal: "var(--font-weight-normal, 400)",
weightMedium: "var(--font-weight-medium, 500)",
weightSemibold: "var(--font-weight-semibold, 600)",
Expand Down
2 changes: 1 addition & 1 deletion src/ui/typography.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* Typography components bound to the type scale and the sans/heading font
* slots. `Text` for body copy, `Heading` for display type (uses the heading
* font — Erode in the NimbleBrain host, a serif fallback elsewhere).
* font — Hanken Grotesk in the NimbleBrain host, `system-ui` elsewhere).
*/

import type { CSSProperties, ElementType, HTMLAttributes, ReactNode } from "react";
Expand Down
Loading