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

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

## [0.11.0] - 2026-06-23

Fixes a silent full-pane collapse. `AppFrame` fills its host pane with `height: 100%`, but that only resolves against a definite-height ancestor chain (`#root` → `body` → `html`) — and the SDK shipped no such chain. A Synapse app iframe is its own bare document, so unless the app added `html, body, #root { height: 100% }` to its own `index.html`, `AppFrame`'s `height: 100%` resolved against a content-height ancestor and the whole app collapsed to content height: full width, short height (e.g. an empty board rendering as a ~442px band inside an 868px pane). The precondition the shell depended on was neither supplied nor enforced, so multiple apps hit it.

### Added

- `@nimblebrain/synapse/ui/base` — a side-effect import that injects the root-height chain (`html, body, #root { height: 100% }`) plus `body { margin: 0 }`. Import it in an app entry to establish the chain before first paint (no layout jump), or for a full-pane app that renders without `AppFrame`.

### Fixed

- `AppFrame` now establishes the root-height chain itself, calling the same base reset on render, so **every** app built on it fills the pane with no per-app `index.html` requirement — existing apps included. The fix uses a percentage chain rather than a viewport unit (`vh`/`dvh`): percentages resolve against the actual allocated pane, staying correct on hosts that give an app a pane shorter than the viewport (where a viewport unit would overflow with a second scrollbar). `AppFrame`'s `height: 100%` and internal scroll model are unchanged; the missing precondition is simply now supplied.

## [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.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ npm install @nimblebrain/synapse
| `@nimblebrain/synapse/react` | React hooks and providers (`AppProvider`, `SynapseProvider`) |
| `@nimblebrain/synapse/ui` | Component library — tokens, primitives, components, layouts |
| `@nimblebrain/synapse/ui/fonts` | Side-effect import that loads the brand fonts into the iframe |
| `@nimblebrain/synapse/ui/base` | Side-effect import that establishes the root-height chain (`html, body, #root`) the app shell fills. `AppFrame` does this automatically on render; import it in your entry to apply it before first paint |
| `@nimblebrain/synapse/vite` | Vite plugin for dev mode |
| `@nimblebrain/synapse/codegen` | CLI + programmatic code generation |
| `@nimblebrain/synapse/iife` | Pre-built IIFE bundle for `<script>` tags (`window.Synapse`) |
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.

7 changes: 6 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@nimblebrain/synapse",
"version": "0.10.2",
"version": "0.11.0",
"description": "Agent-aware app SDK for the MCP ext-apps protocol",
"type": "module",
"exports": {
Expand All @@ -24,6 +24,11 @@
"import": "./dist/ui/fonts.js",
"require": "./dist/ui/fonts.cjs"
},
"./ui/base": {
"types": "./dist/ui/base.d.ts",
"import": "./dist/ui/base.js",
"require": "./dist/ui/base.cjs"
},
"./vite": {
"types": "./dist/vite/index.d.ts",
"import": "./dist/vite/index.js",
Expand Down
34 changes: 34 additions & 0 deletions src/__tests__/ui/base.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
// Importing the side-effect entry runs `injectBaseReset()` once at load — the
// re-exported function is what we exercise per-test.
import { injectBaseReset } from "../../ui/base.js";

const STYLE_ID = "nb-synapse-base";

describe("injectBaseReset", () => {
beforeEach(() => {
document.getElementById(STYLE_ID)?.remove();
});
afterEach(() => {
document.getElementById(STYLE_ID)?.remove();
});

it("supplies the root-height chain and removes the body margin", () => {
injectBaseReset();
const el = document.getElementById(STYLE_ID);
expect(el).not.toBeNull();
// The fix is a percentage chain, not a viewport unit — guard against a
// regression back to vh/dvh (matching "vh" also catches "dvh"), which
// overflows panes shorter than the viewport.
expect(el?.textContent).toContain("html, body, #root");
expect(el?.textContent).toContain("height: 100%");
expect(el?.textContent).toContain("margin: 0");
expect(el?.textContent).not.toMatch(/vh/);
});

it("is idempotent — injecting twice yields a single style element", () => {
injectBaseReset();
injectBaseReset();
expect(document.querySelectorAll(`#${STYLE_ID}`)).toHaveLength(1);
});
});
14 changes: 14 additions & 0 deletions src/__tests__/ui/layouts.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,20 @@ describe("AppFrame", () => {
expect(screen.getByText("Body")).toBeDefined();
expect(screen.getByText("Footer")).toBeDefined();
});

it("establishes the root-height chain on render so the shell can't collapse", () => {
document.getElementById("nb-synapse-base")?.remove();
render(
<AppFrame>
<AppFrame.Body>Body</AppFrame.Body>
</AppFrame>,
);
// Without a definite-height ancestor chain, AppFrame's `height: 100%` would
// collapse to content height inside the host pane (issue #22). Rendering it
// must supply the chain, with no per-app `index.html` requirement.
const reset = document.getElementById("nb-synapse-base");
expect(reset?.textContent).toContain("height: 100%");
});
});

describe("SidebarLayout", () => {
Expand Down
24 changes: 24 additions & 0 deletions src/ui/base.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/**
* Establishes the root-height chain a full-pane Synapse app needs. Import for
* the side effect:
*
* ```ts
* import "@nimblebrain/synapse/ui/base";
* ```
*
* This injects `html, body, #root { height: 100% }` (so a `height: 100%` app
* shell resolves against the iframe's allocated pane) and `body { margin: 0 }`
* (so the pane has no UA-margin gap). See `./internal/base-reset.ts` for why a
* percentage chain — not a viewport unit — is the correct mechanism.
*
* `AppFrame` already calls `injectBaseReset()` on render, so apps built on it
* get the chain automatically. Import this module explicitly to establish the
* chain *before* React mounts — avoiding a first-paint layout jump — or for an
* app that renders a full-pane root without `AppFrame`.
*/

import { injectBaseReset } from "./internal/base-reset.js";

injectBaseReset();

export { injectBaseReset };
33 changes: 33 additions & 0 deletions src/ui/internal/base-reset.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* The root-height chain every full-pane Synapse app needs, as a plain function
* (no side effect on import — so a component can call it on render without the
* mere act of importing injecting anything). The side-effect entry point is
* `../base.ts` (`@nimblebrain/synapse/ui/base`).
*
* `AppFrame` sizes the app shell with `height: 100%`, which only resolves if its
* ancestor chain (`#root` → `body` → `html`) has a definite height. Each app
* iframe is its own bare document, so without this chain the shell collapses to
* content height — full width, short height — inside the host pane.
*
* Percentages resolve against the actual pane box, which is correct whether the
* app is sandboxed in an iframe or mounted into a host panel. A viewport unit
* (`vh`/`dvh`) would instead assume the pane equals the viewport — wrong on
* hosts that allocate a pane shorter than the viewport (it overflows with a
* second scrollbar). The `#root` rule targets the conventional mount id and
* harmlessly matches nothing if an app mounts elsewhere; `html, body` still
* apply. `body { margin: 0 }` removes the UA default so the pane has no gap.
*/

import { ensureStyle } from "./inject-style.js";

const STYLE_ID = "nb-synapse-base";
const RULES = "html, body, #root { height: 100%; } body { margin: 0; }";

/**
* Inject the root-height + body-margin reset once. Idempotent (keyed by a
* single style id) and SSR-safe (no-ops when `document` is unavailable, via
* `ensureStyle`).
*/
export function injectBaseReset(): void {
ensureStyle(STYLE_ID, RULES);
}
11 changes: 11 additions & 0 deletions src/ui/layouts/AppFrame.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@
* an optional fixed footer, filling the iframe pane. Every Synapse app uses it;
* a body layout (SidebarLayout, ListView, …) goes inside `AppFrame.Body`.
*
* The shell fills the pane with `height: 100%`, which needs a definite-height
* ancestor chain (`#root` → `body` → `html`) — something a bare app document
* does not supply on its own. So AppFrame establishes that chain itself on
* render via `injectBaseReset()`; without it the shell would collapse to
* content height. Apps wanting the chain before first paint can also
* `import "@nimblebrain/synapse/ui/base"` in their entry.
*
* The `contentWidth` knob is a personality lever: `reading` centers header,
* body, and footer in a ~760px column (Conversations, Research), while `full`
* uses the whole pane (CRM, dashboards). Header/body/footer share the same
Expand All @@ -16,6 +23,7 @@ import {
type ReactNode,
useContext,
} from "react";
import { injectBaseReset } from "../internal/base-reset.js";
import { tokens } from "../tokens.js";

type ContentWidth = "reading" | "full";
Expand All @@ -32,6 +40,9 @@ interface AppFrameProps extends HTMLAttributes<HTMLDivElement> {
}

function AppFrameRoot({ contentWidth = "full", style, children, ...rest }: AppFrameProps) {
// The shell's `height: 100%` only resolves against a definite-height ancestor
// chain; supply it (same render-time pattern as the components' `ensureStyle`).
injectBaseReset();
return (
<ContentWidthCtx.Provider value={contentWidth}>
<div
Expand Down
1 change: 1 addition & 0 deletions tsup.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export default defineConfig([
"react/index": "src/react/index.ts",
"ui/index": "src/ui/index.ts",
"ui/fonts": "src/ui/fonts.ts",
"ui/base": "src/ui/base.ts",
"vite/index": "src/vite/index.ts",
"codegen/index": "src/codegen/index.ts",
"codegen/cli": "src/codegen/cli.ts",
Expand Down