Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
e3b6cb5
test inline HTML theme bridge injection
SuuBro Jul 22, 2026
2a367a0
Merge inline theme reproducer
SuuBro Jul 22, 2026
01615e1
Fix inline HTML theme inheritance
SuuBro Jul 22, 2026
0951291
Merge inline HTML theme bridge
SuuBro Jul 22, 2026
df807e1
test packaged inline HTML theme runtime
SuuBro Jul 22, 2026
092cf05
Merge inline theme implementation
SuuBro Jul 22, 2026
6d60904
test inline HTML renderer lifecycle
SuuBro Jul 22, 2026
65e997e
Merge inline theme lifecycle tests
SuuBro Jul 22, 2026
513341a
test inline HTML source theme journey
SuuBro Jul 22, 2026
239cc9c
Merge inline theme browser journey
SuuBro Jul 22, 2026
9c65e6b
fix packaged HTML smoke fixture
SuuBro Jul 22, 2026
7c7b33d
Merge packaged inline theme smoke
SuuBro Jul 22, 2026
318805b
test: register inline HTML theme coverage
SuuBro Jul 22, 2026
412b0f0
Register inline theme tests
SuuBro Jul 22, 2026
165237c
fix: validate inline theme bridge marker
SuuBro Jul 22, 2026
c654dbe
Fix inline bridge marker collision
SuuBro Jul 22, 2026
4f47912
test inline theme through Vite source runtime
SuuBro Jul 22, 2026
b41c2d6
Merge latest inline theme implementation
SuuBro Jul 22, 2026
8163a08
stabilize isolated source Vite smoke
SuuBro Jul 22, 2026
9efdd76
Add source Vite inline theme smoke
SuuBro Jul 22, 2026
ed12870
Document inline HTML theme bridge
SuuBro Jul 22, 2026
11aafc1
Document inline HTML theme bridge
SuuBro Jul 22, 2026
b2cb459
Merge remote-tracking branch 'origin/master' into goal/fix-inline-htm…
SuuBro Jul 22, 2026
0fb13d4
Memoize inline HTML preparation
SuuBro Jul 23, 2026
6219e27
Memoize inline HTML preparation
SuuBro Jul 23, 2026
644ca96
Merge remote-tracking branch 'origin/master' into goal/fix-inline-htm…
SuuBro Jul 23, 2026
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
68 changes: 33 additions & 35 deletions defaults/docs/html-rendering.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,9 @@ Full architecture: [docs/preview-architecture.md](../../docs/preview-architectur

## Theme integration is mandatory

Bobbit injects a theme-bridge script (`src/shared/preview-bridge-scripts.ts`)
into every preview iframe. The bridge mirrors:
Bobbit injects the shared theme bridge (`src/shared/preview-bridge-scripts.ts`)
into both side-panel previews and inline `.html` / `.htm` chat cards produced by
`write` or `edit`. The bridge mirrors:

- the parent's `dark` class
- the parent's `data-palette` attribute
Expand Down Expand Up @@ -231,9 +232,9 @@ mode), **not** a text colour. If you alias it to a foreground:
the live bridge mirrors the real `--muted` (the light surface) as an **inline**
style on the iframe root, and inline styles **beat your `:root` alias**. So
`color: var(--muted)` resolves to the light surface colour → light-on-light →
invisible. This only manifests with the live bridge (the **preview pane**);
standalone / inline render has no bridge to clobber the alias, so it looks fine
— *the exact "fine inline, broken in preview" signature.*
invisible. This manifests in both embedded surfaces—the side-panel iframe and
inline chat-card render—because both use the live bridge. A standalone
side-panel tab uses the server snapshot instead.

**Rule: never name a custom property after a real token** (`--muted`,
`--card`, `--border`, `--accent`, `--ring`, `--primary`, `--secondary`,
Expand Down Expand Up @@ -275,31 +276,28 @@ body { background: var(--background); color: var(--foreground); }
```

**Do not add a surface-token fallback at all for anything rendered inside
Bobbit** (`preview_open`, inline `.html` render). Both surfaces inject a
complete, contrast-correct, *palette-matched* `:root`/`.dark` snapshot, and the
live bridge mirrors the app's tokens on top. A standalone fallback can only
make things worse here — see the next rule. The single exception is an HTML
file that will *only ever* be opened directly from disk **outside** Bobbit
(never via `preview_open` or inline render); only then supply a **matched
`:root` + `.dark` pair** so light and dark stay internally consistent (never a
lone single-mode hex). If there is any chance the file is previewed in Bobbit,
omit it.

### ❌ Never override the snapshot from your own `:root{}`

The server injects a complete, contrast-correct, palette-matched `:root`/`.dark`
theme snapshot *before* your `<style>`. A surface token you redeclare in your
own `:root{}`/`.dark{}` block comes **later in source order at equal
specificity, so it silently wins** — replacing the real palette (e.g.
`--background: oklch(0.935 0.012 148)` for the forest theme) with your flat
hardcode (`#ffffff`). The inline-render surface masks this (its live bridge
sets values *inline*, which beat your `:root`), but the preview pane relies on
the snapshot, so the **same document renders correctly inline yet off-theme /
broken in the preview pane.** Keep your `:root{}` block limited to **chart and
semantic tokens only** (see previous section); never put `--background`,
`--foreground`, `--card`, `--muted-foreground`, or `--border` in `:root`/`.dark`.
Reference them directly with `var(--…)` and let the snapshot + bridge supply
the values.
Bobbit** (`preview_open`, inline `.html` render). The live bridge supplies the
host's complete computed token set to embedded surfaces; a standalone
side-panel URL receives the server's contrast-safe `:root` / `.dark` snapshot.
A standalone fallback can only make things worse here—see the next rule. The
single exception is an HTML file that will *only ever* be opened directly from
disk **outside** Bobbit (never via `preview_open` or inline render); only then
supply a **matched `:root` + `.dark` pair** so light and dark stay internally
consistent (never a lone single-mode hex). If there is any chance the file is
previewed in Bobbit, omit it.

### ❌ Never override Bobbit surface tokens in `:root{}`

In embedded side-panel and inline chat-card iframes, the live bridge sets theme
variables as inline root styles, which beat authored `:root` declarations. A
standalone side-panel URL instead receives the server snapshot before authored
styles, so a later authored declaration can replace its value. Either behaviour
makes a custom `--background`, `--foreground`, or other Bobbit token inconsistent
across surfaces. Keep your `:root` block limited to **chart and semantic fallback
tokens only** (see the previous section); never put `--background`,
`--foreground`, `--card`, `--muted-foreground`, or `--border` in `:root` / `.dark`.
Reference them directly with `var(--…)` and let the live bridge or standalone
snapshot supply the values.

### Self-check before you ship

Expand Down Expand Up @@ -387,11 +385,11 @@ Visual work is iterative by nature. Optimise the loop:

## Tailwind

Full Tailwind 4 is available in the preview iframe via the same-origin Vite
dev server. Add `<link rel="stylesheet" href="/src/ui/app.css">` to use
component classes (`bg-card`, `text-foreground`, `border-border`, etc.).
This also pulls in every theme variable directly, useful when you'd rather
write `class="bg-card"` than `style="background: var(--card)"`.
Do not link `/src/ui/app.css` from authored HTML. That source URL is a Vite
development detail and is not part of the compiled or packaged UI contract.
The bridge supplies theme variables, not Bobbit's generated utility classes;
write local CSS that consumes `var(--…)` so the same document works in source,
built, and `npx bobbit` runtimes.

## Anti-patterns — do not do these

Expand Down
98 changes: 91 additions & 7 deletions docs/preview-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ The preview side-panel renders agent-authored HTML alongside the chat. This
document covers the v3 architecture introduced by the embedded-html-preview
rewrite — a per-session content mount served from a cookie-authed origin path,
with SSE-driven hot reload. The browser renders that mount inside the dynamic
per-session side-panel workspace shared by regular and assistant sessions.
per-session side-panel workspace shared by regular and assistant sessions. It
also defines the theming boundary with `.html` / `.htm` files rendered directly
inside chat cards.

## Mental model

Expand Down Expand Up @@ -274,8 +276,10 @@ Behaviour by path shape:
| Method other than `GET`/`HEAD` | `405` |

**Theme bridge.** `injectBaseAndScripts(body, baseTag, PREVIEW_BRIDGE_SCRIPTS)`
adds the `<base href>` and the bridge scripts only when the response is
`text/html`. Static assets pass through untouched.
adds the `<base href>`, server theme snapshot, and shared theme/swipe scripts
only when the response is `text/html`. Static assets pass through untouched.
This is the side-panel content path; inline chat-card HTML has a separate
browser-side injection path described below.

**MIME lookup.** `src/server/preview/mime.ts`. Minimal table covering the
HTML-report long tail (HTML, CSS, JS, images, fonts, video, JSON, etc.); the
Expand Down Expand Up @@ -623,6 +627,82 @@ break old archived sessions. Those legacy flows preserve historical-tab
semantics even though the unified mount endpoint now returns `contentHash`.
Only v3 markers opt into live-tab collapse by content identity.

## HTML theme surfaces

The canonical live implementation is `PREVIEW_THEME_BRIDGE` in
`src/shared/preview-bridge-scripts.ts`. Both embedded HTML surfaces use it so
Bobbit has one definition of theme inheritance, but their injection and gesture
contracts differ:

| Surface | Injection and theme behaviour |
|---|---|
| Inline `.html` / `.htm` chat card | `HtmlRenderer` prepares the browser `srcdoc` with `PREVIEW_THEME_BRIDGE` only. It live-mirrors the host and never receives the side-panel swipe script or server snapshot. |
| Embedded side-panel iframe | The preview content route injects a `<base>`, server snapshot, and `PREVIEW_BRIDGE_SCRIPTS` (theme plus swipe). The live bridge overrides the snapshot from the host. |
| Standalone side-panel URL | The same server response carries the snapshot, but the live bridge returns when `parent === window`; its values remain fixed until the document is reloaded. |

### Inline chat-card path

`WriteRenderer` delegates `.html` and `.htm` writes to `HtmlRenderer`, including
historical completed calls. During streaming, prepared HTML is applied through
the existing debounced `document.open()` / `write()` / `close()` path; completion
switches to the declarative `srcdoc` binding. A successful HTML edit fetches the
resulting file snapshot and delegates to the same completed renderer. Thus write,
edit, streaming, and completed cards share one preparation helper rather than
separate theme implementations.

The helper parses authored input inertly with the browser HTML parser, inserts a
parsed copy of the canonical bridge as the first node in `<head>`, and serializes the
document with its doctype and document-level nodes. Parser-backed insertion is
required: tag-shaped text inside scripts, comments, styles, textareas, and other
raw-text content must not become an injection point. First-in-head execution
also means an authored initialization script can synchronously read Bobbit's
theme state while the document is parsing. The iframe receives the prepared
payload, while the collapsed source view retains the original authored HTML.
Authored script order and auto-resize behaviour are unchanged, and the iframe
sandbox remains `allow-scripts allow-same-origin`.

On initial execution and every relevant host-root mutation, the bridge mirrors
the `dark` class, `data-palette`, font stack, and the computed values of all CSS
custom properties declared by accessible host stylesheets. The existing iframe
therefore follows light/dark and palette changes without recreating the tool
call. Stylesheets whose rules cannot be read are skipped.

Preparation and execution are idempotent. Preparation only accepts a marked
script as already installed when its content and executable attributes match
the canonical bridge; an unrelated authored marker does not suppress
injection. At runtime, one observer is installed per document root. A streaming
rewrite replaces the root, disconnects the old observer, and installs one for
the replacement. Parser, serializer, parent-document, style, or observer
failures are caught so authored HTML continues to render even when theming is
unavailable.

The inline iframe backdrop, streaming veil, and spinner use host surface and
foreground tokens with browser-system fallbacks. This keeps the card chrome
readable in either theme without coupling it to the authored document.

### Author contract

Inline and side-panel documents should consume Bobbit variables such as
`--background`, `--foreground`, `--card`, semantic status tokens, and chart
tokens rather than hardcoding a mode. Authors do not need to include a bridge,
and must not add the side-panel swipe bridge to inline content. See the
[canonical HTML rendering guide](../defaults/docs/html-rendering.md) for token
usage and contrast rules.

### Build and package boundary

The inline preparation helper statically imports `PREVIEW_THEME_BRIDGE`. Vite
therefore includes the canonical script through its dependency graph in both
the source development server and compiled `dist/ui` assets; a packaged browser
must not resolve a TypeScript source path at runtime. The npm package ships the
built UI used by `npx bobbit` for this path.

The server theme snapshot is deliberately separate. It reads canonical
`src/ui/app.css` from the package root to provide defaults for standalone
side-panel URLs, so npm packages must continue to include that source CSS as
well as `dist/ui`. Inline `srcdoc` preparation must not call or reproduce this
server filesystem mechanism.

## Theme-token snapshot for standalone tabs

Single source of truth: `src/server/preview/theme-snapshot.ts`.
Expand All @@ -640,8 +720,8 @@ and the preview document has no theme vars of its own, so every
`var(--background)` / `var(--chart-N)` resolved to empty and the page
rendered unstyled.

**How.** At server startup `theme-snapshot.ts` parses the `:root` and `.dark`
blocks of `src/ui/app.css` once, extracts every `--*` declaration, and caches
**How.** On first use, `theme-snapshot.ts` parses the `:root` and `.dark`
blocks of `src/ui/app.css`, extracts every `--*` declaration, and caches
a ready-to-emit `<style data-bobbit-preview-theme="snapshot">…</style>`
string. `content-route.ts` injects this snapshot for every served HTML
response — the `data-bobbit-preview-theme="snapshot"` marker is the debugging
Expand Down Expand Up @@ -720,7 +800,8 @@ back the preview tree sees the same bytes the gateway just wrote.
| File | Responsibility |
|---|---|
| `src/server/preview/mount.ts` | Per-session mount lifecycle, atomic writes, `mountFile` (explicit asset opt-in), `contentHash` calculation, watcher |
| `src/server/preview/content-route.ts` | `/preview/<sid>/<rel>` handler (live mount) and `/preview/<sid>/_artifact/<id>/<rel>` (per-artifact stable URL), entry pick, `<base>` + bridge injection |
| `src/server/preview/content-route.ts` | `/preview/<sid>/<rel>` handler (live mount) and `/preview/<sid>/_artifact/<id>/<rel>` (per-artifact stable URL), entry pick, `<base>` + snapshot + bridge injection |
| `src/server/preview/theme-snapshot.ts` | Cached `src/ui/app.css` token snapshot for standalone side-panel URLs |
| `src/server/preview/path-guard.ts` | Path-traversal defence (realpath-based) |
| `src/server/preview/mime.ts` | MIME-type lookup |
| `src/server/preview/events.ts` | Per-session `preview-changed` channel carrying mount identity payloads |
Expand All @@ -729,7 +810,10 @@ back the preview tree sees the same bytes the gateway just wrote.
| `src/server/auth/browser-cookie.ts` | Central browser bootstrap/renewal eligibility classifier |
| `src/server/server.ts` | `POST/GET /api/preview/mount`, SSE route, broadcast on success |
| `src/server/agent/docker-args.ts` | Sandbox bind mounts (`/bobbit/preview`, `/bobbit/preview-root`) |
| `src/shared/preview-bridge-scripts.ts` | Theme/swipe bridge scripts injected into HTML responses |
| `src/shared/preview-bridge-scripts.ts` | Canonical live theme bridge plus the side-panel-only swipe bridge |
| `src/ui/tools/renderers/prepare-inline-html.ts` | Parser-backed, early theme-bridge preparation for inline `srcdoc` HTML |
| `src/ui/tools/renderers/HtmlRenderer.ts` | Completed and streaming inline iframe lifecycle; original-source disclosure and themed chrome |
| `src/ui/tools/renderers/WriteRenderer.ts`, `EditRenderer.ts` | `.html` / `.htm` write and successful-edit delegation into `HtmlRenderer` |
| `defaults/tools/html/extension.ts` | `preview_open` tool — POSTs to `/api/preview/mount`, stamps v3 marker with optional `contentHash` |
| `defaults/tools/html/snapshot.ts` | Marker constants, `buildPreviewSnapshotV3Block`, `parseSnapshot`, 250-byte v3 cap |
| `src/server/preview/artifacts.ts` | Immutable artifact store — capture, restore, hash-based dedupe, orphan sweep |
Expand Down
90 changes: 54 additions & 36 deletions src/shared/preview-bridge-scripts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,57 +21,75 @@ export const PREVIEW_THEME_BRIDGE = `<script>
Embedded iframes (parent !== window) continue past this guard so live
theme toggles in the host app flow through. */
if (parent === window) return;

/* A repeated canonical bridge in the same document must not install
another observer. document.open() keeps the Window but replaces the
root, so streaming rewrites intentionally install for the new root. */
var installKey = '__bobbitPreviewThemeBridgeInstalled_v1__';
var root = document.documentElement;
var parentRoot = parent.document.documentElement;
var parentStyles = parent.getComputedStyle(parentRoot);
var previousInstall = window[installKey];
if (previousInstall && previousInstall.root === root) return;
if (previousInstall && previousInstall.observer) {
try { previousInstall.observer.disconnect(); } catch(e) {}
}
var install = { root: root, observer: null };
window[installKey] = install;

var parentDocument = parent.document;
var parentRoot = parentDocument.documentElement;

function sync() {
/* Mirror dark class */
root.classList.toggle('dark', parentRoot.classList.contains('dark'));
try {
var parentStyles = parent.getComputedStyle(parentRoot);

/* Mirror data-palette attribute */
var palette = parentRoot.getAttribute('data-palette');
if (palette) root.setAttribute('data-palette', palette);
else root.removeAttribute('data-palette');
/* Mirror dark class */
root.classList.toggle('dark', parentRoot.classList.contains('dark'));

/* Copy all CSS custom properties from the app stylesheet */
var vars = [];
try {
for (var s = 0; s < parent.document.styleSheets.length; s++) {
var sheet = parent.document.styleSheets[s];
try {
var rules = sheet.cssRules || sheet.rules;
for (var r = 0; r < rules.length; r++) {
var rule = rules[r];
if (rule.style) {
for (var i = 0; i < rule.style.length; i++) {
var name = rule.style[i];
if (name.startsWith('--')) vars.push(name);
/* Mirror data-palette attribute */
var palette = parentRoot.getAttribute('data-palette');
if (palette) root.setAttribute('data-palette', palette);
else root.removeAttribute('data-palette');

/* Copy all CSS custom properties from the app stylesheet */
var vars = [];
try {
for (var s = 0; s < parentDocument.styleSheets.length; s++) {
var sheet = parentDocument.styleSheets[s];
try {
var rules = sheet.cssRules || sheet.rules;
for (var r = 0; r < rules.length; r++) {
var rule = rules[r];
if (rule.style) {
for (var i = 0; i < rule.style.length; i++) {
var name = rule.style[i];
if (name.startsWith('--')) vars.push(name);
}
}
}
}
} catch(e) { /* cross-origin sheet, skip */ }
} catch(e) { /* cross-origin sheet, skip */ }
}
} catch(e) {}

/* Deduplicate and copy computed values */
var seen = {};
for (var v = 0; v < vars.length; v++) {
if (seen[vars[v]]) continue;
seen[vars[v]] = true;
var val = parentStyles.getPropertyValue(vars[v]);
if (val) root.style.setProperty(vars[v], val);
}
} catch(e) {}

/* Deduplicate and copy computed values */
var seen = {};
for (var v = 0; v < vars.length; v++) {
if (seen[vars[v]]) continue;
seen[vars[v]] = true;
var val = parentStyles.getPropertyValue(vars[v]);
if (val) root.style.setProperty(vars[v], val);
}
}

/* Copy the app font stack */
root.style.fontFamily = parentStyles.fontFamily;
/* Copy the app font stack alongside every live theme sync. */
root.style.fontFamily = parentStyles.fontFamily;
} catch(e) { /* transient parent/style access failure — keep authored HTML running */ }
}

/* Initial sync */
sync();

/* Watch for class/attribute changes on the parent root element */
var observer = new MutationObserver(sync);
install.observer = observer;
observer.observe(parentRoot, { attributes: true, attributeFilter: ['class', 'data-palette', 'style'] });
} catch(e) { /* cross-origin or other error — degrade gracefully */ }
})();
Expand Down
Loading