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

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

## [0.10.2] - 2026-06-23

Fixes three `ui` tokens that were theme-blind in dark mode. `tokens.bgSubtle`, `tokens.fgFaint`, and `tokens.borderStrong` reference CSS vars (`--color-background-tertiary`, `--color-text-tertiary`, `--color-border-secondary`) that no host injected, so they always resolved to their hardcoded **light** fallbacks — rendering white-on-white surfaces, invisible borders, and illegible faint text whenever a host signaled dark. This hit the SDK's own components (Card, ListRow, Prose, Table, Badge, Button, Avatar, SearchField, SegmentedControl, EmptyState, StatusDot) and any app pairing one of these with a theme-aware token. A `var()` fallback is a static literal that can't branch on theme, so the fix is a theme-aware default layer, not a smarter fallback.

### Fixed

- The SDK now ships a **neutral default theme** (`theme-defaults.ts`) that backs every color var the token contract references, in both light and dark. It's applied to `:root` beneath the host's variables — so the host's brand values still win for the keys it provides, and any var the host omits resolves to a theme-correct neutral default. The three previously-unbacked tokens now render correctly in dark mode against any host, a standalone `connect()` widget, or a third-party host. Values stay neutral (brand arrives only by host injection), preserving the library's host-agnostic design.
- The preview harnesses (`vite` plugin host + `preview` server) now inject the three vars in both themes, so `synapse dev`/`preview` matches a complete host, and a duplicate `--color-text-primary` key in those theme maps was removed.

### Changed

- Theme variables now reach the DOM through a single shared path, `applyThemeVariables(mode, hostVars)`, replacing three near-duplicate inline injectors in `core.ts`, `connect.ts`, and the React `<SynapseProvider>`. No public API change.

## [0.10.1] - 2026-06-19

Fixes a master/detail overlap. A too-wide child in `ListDetailLayout.List` — e.g. an auto-layout `<table>` that won't shrink below its content — spilled out of the fixed-width list rail and painted over the detail pane. Surfaced dogfooding the People CRM, whose list rendered a 3-column table inside the 320px rail.
Expand Down
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.10.1",
"version": "0.10.2",
"description": "Agent-aware app SDK for the MCP ext-apps protocol",
"type": "module",
"exports": {
Expand Down
103 changes: 103 additions & 0 deletions src/__tests__/ui/tokens.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_THEME_VARS } from "../../theme-defaults.js";
import { headingStyle, textStyle, tokens } from "../../ui/tokens.js";

/** Pull the `--var-name` out of a `var(--name, fallback)` reference. */
function referencedVar(value: string): string | null {
const m = value.match(/^var\(\s*(--[a-zA-Z0-9-]+)\s*,/);
return m ? m[1] : null;
}

describe("token contract", () => {
it("exposes tokens as host CSS var() references, not raw brand hex", () => {
// Every color/font token must be a var() so the host's injected value wins
Expand Down Expand Up @@ -32,3 +39,99 @@ describe("token contract", () => {
});
});
});

describe("default theme backs the token contract", () => {
const referenced = Object.values(tokens)
.map(referencedVar)
.filter((v): v is string => v !== null);

// Vars that look identical in light and dark — their static var() fallback is
// already correct, so they are intentionally NOT in DEFAULT_THEME_VARS. Listed
// explicitly (not inferred from the name) so the partition below is TOTAL: any
// referenced var that is neither declared invariant here nor backed in both
// modes fails the guard, forcing a conscious light/dark decision — even for a
// future theme-sensitive var whose name doesn't happen to contain "color".
const THEME_INVARIANT_VARS = new Set<string>([
"--font-sans",
"--font-mono",
"--nb-font-heading",
"--font-weight-normal",
"--font-weight-medium",
"--font-weight-semibold",
"--font-weight-bold",
"--font-text-xs-size",
"--font-text-xs-line-height",
"--font-text-sm-size",
"--font-text-sm-line-height",
"--font-text-base-size",
"--font-text-base-line-height",
"--font-text-lg-size",
"--font-text-lg-line-height",
"--font-heading-sm-size",
"--font-heading-sm-line-height",
"--font-heading-md-size",
"--font-heading-md-line-height",
"--font-heading-lg-size",
"--font-heading-lg-line-height",
"--border-radius-xs",
"--border-radius-sm",
"--border-radius-md",
"--border-radius-lg",
"--border-radius-xl",
"--border-width-regular",
"--shadow-hairline",
"--shadow-sm",
"--shadow-md",
"--shadow-lg",
]);

// Theme-sensitive = referenced but not declared invariant. These MUST be
// backed in both modes.
const themeSensitive = referenced.filter((v) => !THEME_INVARIANT_VARS.has(v));

it("backs every theme-sensitive var the token contract references in BOTH light and dark", () => {
// The regression guard. A `var()` fallback is a static literal that fires
// exactly when the var is unset — the moment there's no theme signal to
// branch on — so an unbacked theme-sensitive token resolves to its light
// fallback in dark mode (white-on-white). This fails the instant someone
// adds such a token whose var nothing backs, instead of shipping it inert.
expect(themeSensitive.length).toBeGreaterThan(0);
for (const v of themeSensitive) {
expect(Object.keys(DEFAULT_THEME_VARS.light), `light missing ${v}`).toContain(v);
expect(Object.keys(DEFAULT_THEME_VARS.dark), `dark missing ${v}`).toContain(v);
}
});

it("declares no stale invariant vars — every one is actually referenced", () => {
// Keeps THEME_INVARIANT_VARS honest: an entry no token references is dead
// and would silently shrink what the guard above covers.
const referencedSet = new Set(referenced);
for (const v of THEME_INVARIANT_VARS) {
expect(referencedSet.has(v), `stale invariant entry ${v}`).toBe(true);
}
});

it("light and dark define the same set of vars", () => {
expect(Object.keys(DEFAULT_THEME_VARS.light).sort()).toEqual(
Object.keys(DEFAULT_THEME_VARS.dark).sort(),
);
});

it("ships no dead defaults — every default var is referenced by a token", () => {
const referencedSet = new Set(referenced);
for (const v of Object.keys(DEFAULT_THEME_VARS.light)) {
expect(referencedSet.has(v), `unreferenced default ${v}`).toBe(true);
}
});

it("light defaults equal the token fallbacks (no light-mode regression)", () => {
// The light layer must reproduce each backed token's baked fallback, so
// introducing the default layer changes nothing observable in light mode.
for (const value of Object.values(tokens)) {
const v = referencedVar(value);
if (!v || THEME_INVARIANT_VARS.has(v)) continue;
const fallback = value.slice(value.indexOf(",") + 1, -1).trim();
expect(DEFAULT_THEME_VARS.light[v]).toBe(fallback);
}
});
});
20 changes: 5 additions & 15 deletions src/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { parseToolResultParams } from "./content-parser.js";
import { resolveEventMethod } from "./event-map.js";
import { createResizer } from "./resize.js";
import { parseToolResult } from "./result-parser.js";
import { applyThemeVariables } from "./theme-defaults.js";
import { SynapseTransport } from "./transport.js";
import type { App, ConnectOptions, Dimensions, Theme, ToolCallResult } from "./types.js";

Expand Down Expand Up @@ -106,8 +107,9 @@ export async function connect(options: ConnectOptions): Promise<App> {
containerDimensions = ctx.containerDimensions as Dimensions;
}

// Inject host CSS variables into the DOM
injectCssVariables(ctx.styles?.variables as Record<string, string> | undefined);
// Inject the theme into the DOM: neutral defaults for the mode back any
// var the host omits, then host values win.
applyThemeVariables(currentTheme.mode, currentTheme.tokens);
}
}

Expand All @@ -124,7 +126,7 @@ export async function connect(options: ConnectOptions): Promise<App> {
? (variables as Record<string, string>)
: currentTheme.tokens;
currentTheme = { mode, tokens };
injectCssVariables(tokens);
applyThemeVariables(mode, tokens);
const set = handlers.get(HOST_CONTEXT_CHANGED_METHOD);
if (set) {
for (const handler of set) handler(currentTheme);
Expand Down Expand Up @@ -269,15 +271,3 @@ export async function connect(options: ConnectOptions): Promise<App> {

return app;
}

// --- Helpers ---

/** Inject CSS custom properties onto :root so widgets inherit host theming. */
function injectCssVariables(vars: Record<string, string> | undefined | null): void {
if (!vars || typeof vars !== "object") return;
for (const [k, v] of Object.entries(vars)) {
if (typeof k === "string" && typeof v === "string") {
document.documentElement.style.setProperty(k, v);
}
}
}
25 changes: 10 additions & 15 deletions src/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { detectHost, extractTheme } from "./detection.js";
import { KeyboardForwarder } from "./keyboard.js";
import { parseToolResult } from "./result-parser.js";
import { callToolAsTask as callToolAsTaskImpl, createTaskStatusRouter } from "./task-handle.js";
import { applyThemeVariables } from "./theme-defaults.js";
import { SynapseTransport } from "./transport.js";
import type {
AgentAction,
Expand Down Expand Up @@ -124,9 +125,13 @@ export function createSynapse(options: SynapseOptions): Synapse {
? (rawTasks as TasksCapability)
: undefined;

// Inject host CSS variables into :root so plain-CSS styles can consume
// them via `var(--…)` without needing to read theme.tokens imperatively.
injectCssVariables(extractTheme(currentHostContext).tokens);
// Inject the theme into :root so plain-CSS styles consume it via
// `var(--…)` without reading theme.tokens imperatively. Neutral defaults
// back any var the host omits (theme-correct), then host values win.
{
const theme = extractTheme(currentHostContext);
applyThemeVariables(theme.mode, theme.tokens);
}

// Notify subscribers so React hooks (useTheme, useHostContext) and
// custom listeners reflect the handshake-provided context (not just
Expand All @@ -142,7 +147,8 @@ export function createSynapse(options: SynapseOptions): Synapse {
// full snapshot of the host context, so we replace — never merge.
const unsubHostContext = transport.onMessage(HOST_CONTEXT_CHANGED_METHOD, (params) => {
currentHostContext = (params ?? {}) as McpUiHostContext;
injectCssVariables(extractTheme(currentHostContext).tokens);
const theme = extractTheme(currentHostContext);
applyThemeVariables(theme.mode, theme.tokens);
for (const cb of hostContextCallbacks) cb(currentHostContext);
});

Expand Down Expand Up @@ -462,14 +468,3 @@ function themesEqual(a: SynapseTheme, b: SynapseTheme): boolean {
}
return true;
}

/** Inject CSS custom properties onto :root so widgets inherit host theming. */
function injectCssVariables(vars: Record<string, string> | undefined | null): void {
if (!vars || typeof vars !== "object") return;
if (typeof document === "undefined") return;
for (const [k, v] of Object.entries(vars)) {
if (typeof k === "string" && typeof v === "string") {
document.documentElement.style.setProperty(k, v);
}
}
}
28 changes: 16 additions & 12 deletions src/preview/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,17 +58,19 @@ const HOST_HTML = (uiPort: number, serverPort: number) => `<!DOCTYPE html>

// Minimal NimbleBrain bridge host — just enough to make Synapse work
var tokens = darkMode ? {
"--color-background-primary": "#0f172a", "--color-text-primary": "#e2e8f0",
"--color-background-secondary": "#1e293b", "--color-text-primary": "#e2e8f0",
"--color-background-primary": "#0f172a", "--color-background-secondary": "#1e293b",
"--color-background-tertiary": "#2a374a", "--color-text-primary": "#e2e8f0",
"--color-text-secondary": "#94a3b8", "--color-text-tertiary": "#64748b",
"--color-text-accent": "#6366f1", "--nb-color-accent-foreground": "#ffffff",
"--color-text-secondary": "#94a3b8", "--color-border-primary": "#334155",
"--color-border-primary": "#334155", "--color-border-secondary": "#475569",
"--color-ring-primary": "#6366f1", "--nb-color-danger": "#ef4444",
"--border-radius-sm": "0.5rem",
} : {
"--color-background-primary": "#ffffff", "--color-text-primary": "#1a1a1a",
"--color-background-secondary": "#f9fafb", "--color-text-primary": "#1a1a1a",
"--color-background-primary": "#ffffff", "--color-background-secondary": "#f9fafb",
"--color-background-tertiary": "#f1f5f9", "--color-text-primary": "#1a1a1a",
"--color-text-secondary": "#6b7280", "--color-text-tertiary": "#94a3b8",
"--color-text-accent": "#6366f1", "--nb-color-accent-foreground": "#ffffff",
"--color-text-secondary": "#6b7280", "--color-border-primary": "#e5e7eb",
"--color-border-primary": "#e5e7eb", "--color-border-secondary": "#cbd5e1",
"--color-ring-primary": "#6366f1", "--nb-color-danger": "#ef4444",
"--border-radius-sm": "0.5rem",
};
Expand Down Expand Up @@ -147,17 +149,19 @@ const HOST_HTML = (uiPort: number, serverPort: number) => `<!DOCTYPE html>
darkMode = !darkMode;
document.body.style.background = darkMode ? "#0f172a" : "#f1f5f9";
tokens = darkMode ? {
"--color-background-primary": "#0f172a", "--color-text-primary": "#e2e8f0",
"--color-background-secondary": "#1e293b", "--color-text-primary": "#e2e8f0",
"--color-background-primary": "#0f172a", "--color-background-secondary": "#1e293b",
"--color-background-tertiary": "#2a374a", "--color-text-primary": "#e2e8f0",
"--color-text-secondary": "#94a3b8", "--color-text-tertiary": "#64748b",
"--color-text-accent": "#6366f1", "--nb-color-accent-foreground": "#ffffff",
"--color-text-secondary": "#94a3b8", "--color-border-primary": "#334155",
"--color-border-primary": "#334155", "--color-border-secondary": "#475569",
"--color-ring-primary": "#6366f1", "--nb-color-danger": "#ef4444",
"--border-radius-sm": "0.5rem",
} : {
"--color-background-primary": "#ffffff", "--color-text-primary": "#1a1a1a",
"--color-background-secondary": "#f9fafb", "--color-text-primary": "#1a1a1a",
"--color-background-primary": "#ffffff", "--color-background-secondary": "#f9fafb",
"--color-background-tertiary": "#f1f5f9", "--color-text-primary": "#1a1a1a",
"--color-text-secondary": "#6b7280", "--color-text-tertiary": "#94a3b8",
"--color-text-accent": "#6366f1", "--nb-color-accent-foreground": "#ffffff",
"--color-text-secondary": "#6b7280", "--color-border-primary": "#e5e7eb",
"--color-border-primary": "#e5e7eb", "--color-border-secondary": "#cbd5e1",
"--color-ring-primary": "#6366f1", "--nb-color-danger": "#ef4444",
"--border-radius-sm": "0.5rem",
};
Expand Down
10 changes: 4 additions & 6 deletions src/react/provider.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { createContext, type ReactNode, useContext, useEffect, useRef, useState } from "react";
import { createSynapse } from "../core.js";
import { applyThemeVariables } from "../theme-defaults.js";
import type { Synapse, SynapseOptions, SynapseTheme } from "../types.js";

const SynapseContext = createContext<Synapse | null>(null);
Expand Down Expand Up @@ -37,12 +38,9 @@ function ThemeInjector({ synapse }: { synapse: Synapse }) {
}, [synapse]);

useEffect(() => {
if (theme.tokens) {
const style = document.documentElement.style;
for (const [k, v] of Object.entries(theme.tokens)) {
style.setProperty(k, v);
}
}
// Neutral defaults for the mode back any var the host omits, then host
// values win — the same single path used by the handshake in core.ts.
applyThemeVariables(theme.mode, theme.tokens);
}, [theme]);

return null;
Expand Down
Loading