From 7991e71b156dff10fa84626d10747348639eac28 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 6 Jul 2026 14:47:34 -0700 Subject: [PATCH 1/8] docs: spec-rendering playback redesign design spec Encapsulated component CSS on --ds-* tokens (no Tailwind utilities), render-green accent, syntax-colored streaming JSON, restyle shared transport timeline in place. Co-Authored-By: Claude Opus 4.8 --- ...spec-rendering-playback-redesign-design.md | 183 ++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-06-spec-rendering-playback-redesign-design.md diff --git a/docs/superpowers/specs/2026-07-06-spec-rendering-playback-redesign-design.md b/docs/superpowers/specs/2026-07-06-spec-rendering-playback-redesign-design.md new file mode 100644 index 000000000..941688980 --- /dev/null +++ b/docs/superpowers/specs/2026-07-06-spec-rendering-playback-redesign-design.md @@ -0,0 +1,183 @@ +# Spec-Rendering Playback Redesign + +**Date:** 2026-07-06 +**Status:** Approved design, ready for planning +**Capability:** `cockpit/render/spec-rendering` + +## Problem + +The spec-rendering example's playback UI renders as unstyled text on the live +site (`examples.threadplane.ai/render/spec-rendering`, embedded at +`cockpit.threadplane.ai/render/core-capabilities/spec-rendering/overview`). + +Root cause, confirmed via Chrome MCP: **the example's UI is styled entirely with +Tailwind utility classes in component templates, but Tailwind generates zero +utility rules for the embedded example builds.** Measured `tailwindRuleCount: 0` +across every embedded example; spec-picker buttons compute to browser defaults +(`padding: 0`, `border-radius: 0`, `font-size: 16px`). The shared +`@import "tailwindcss"` lives inside `@threadplane/example-layouts/theme.css` +(resolved from `node_modules`), and Tailwind v4 automatic source-detection never +scans the consuming app's templates — so it emits base + design-token variables +but no utilities. This has never worked; most examples don't notice because +their visible UI comes from pre-styled *library* components +(`@threadplane/chat`, `example-layouts`), whereas spec-rendering hand-rolls its +entire playback UI with utilities. + +## Approach + +**Self-contained redesign (chosen over "fix the Tailwind build").** Rebuild the +spec-rendering playback UI — and the shared transport timeline it uses — with +**encapsulated Angular component styles** driven by the real `--ds-*` design +tokens. Angular always compiles component `styles:`, independent of Tailwind, so +this is guaranteed to render both standalone and embedded, in light and dark +themes. No Tailwind utility classes remain in the touched files. + +This is also a design upgrade: the same proven structure (spec tabs → split +render/JSON → transport footer) polished into a "spec player." + +### Why not fix the Tailwind build + +Fixing source-detection would restore all ~31 example components at once, but +it's shared-infra churn affecting every example plus a bundle-size concern, and +it leaves the design quality untouched. The self-contained path is lower-risk, +higher-quality for this example, and each render example is meant to stand +alone anyway. + +## Design tokens & palette + +All surfaces, text, borders, radii, shadows, and fonts reference **theme-aware +`--ds-*` tokens** (installed on `` by `installEmbeddedTheme()`, present +standalone and embedded, and flipped by the cockpit host's `tplane:theme` +messages). **No hardcoded canvas/surface/text colors** — the mockup used literal +values only for fidelity. + +Accent = **render-green**, in two roles to hold contrast on the near-black +canvas: + +- `var(--ds-render-green)` (`#1a7a40`, theme-invariant) — **solid fills**: active + tab, play button, scrubber base. Light text/icons on top. +- A local derived **brighter green** `#35b06a` — anything sitting **directly on + dark**: status pulse, badge text, "Streaming…" label, JSON accents, scrubber + highlight, active-speed text. Defined as a local CSS custom property + (e.g. `--sr-green-bright`) inside the component styles, **not** a new global + design token (avoids design-tokens infra changes). If it later proves broadly + useful it can be promoted to a token. + +Reference values (dark): canvas `rgb(17,17,17)`, surface `rgb(28,28,28)`, +surface-tinted `rgb(44,44,44)`, surface-dim `rgb(10,10,10)`, border +`rgb(45,45,45)`, text primary/secondary/muted `245/200/160`. Radii +sm6/md10/lg14/full. Mono = `--ds-font-mono`. + +## Scope + +**In scope** + +1. `cockpit/render/spec-rendering/angular/src/app/spec-rendering.component.ts` — + rewrite template + add encapsulated `styles:`. Keep using the shared + `ExampleSplitLayoutComponent` frame; restyle only the slot *content*. +2. The four inline demo components in that file — `DemoText`, `DemoHeading`, + `DemoBadge`, `DemoCard` — convert Tailwind classes to encapsulated styles, + preserving skeleton-loading behavior. +3. `cockpit/render/shared/streaming-timeline.component.ts` — **restyle in place** + with encapsulated styles (render-green). This fixes the transport for all 6 + render examples that import it; keep all mouse/touch drag-seek logic intact. +4. New JSON syntax-highlighter utility (local to spec-rendering) + unit tests. +5. `cockpit/render/spec-rendering/angular/src/styles.css` — trim now-unused + global skeleton CSS if it moves into components; keep design-token wiring. + +**Out of scope** + +- The Tailwind build fix (deliberately not pursued). +- `StreamingSimulator` (shared, pure logic, no CSS) — unchanged, still imported. +- The other 5 render examples' bespoke content (they inherit only the restyled + transport; their own panes remain as-is for a later pass). +- `example-layouts`, `design-tokens`, and any global/theme infrastructure. + +## Component design + +Structure maps onto the existing `ExampleSplitLayoutComponent` slots (header / +primary / 20rem secondary / footer; responsive: stacked on mobile, row ≥768px): + +- **`[header]` — spec picker + status.** Segmented pill tabs (`Heading + Text`, + `Card + Badge`, `Nested Layout`) in a `--ds-surface-dim` track; active tab = + render-green fill with light text. Right-aligned **"Streaming" status pulse** + (brighter-green dot with soft glow + label) bound to `simulator.playing()`. +- **`[primary]` — Live Render Output.** Uppercase tracked caption, then the + rendered spec via ``. The demo components are the visual payload: + - `DemoCard` — `--ds-surface` panel, `--ds-border`, `--ds-radius-lg`, soft + shadow; title; children via ``; skeleton lines while a child + hasn't streamed. + - `DemoBadge` — pill: soft green surface, brighter-green text, green border. + - `DemoHeading` / `DemoText` — token-colored type; skeleton shimmer while + empty and loading. Subtle fade-in when content arrives (optional polish). +- **`[secondary]` — Streaming JSON.** `--ds-surface-dim` inset, mono font, + **syntax-colored** tokens, a blinking brighter-green cursor at the stream head, + and a footer row: state label (`Streaming…` / `Complete` / `Paused`, green + when streaming) + percent (muted, tabular). +- **`[footer]` — transport (shared timeline).** Circular render-green play/pause + (light icon); scrubber track (`--ds-surface-tinted`) with green→brighter-green + gradient fill + white handle ringed green; char counter (mono, tabular, + `min-width` to prevent reflow); speed toggles `1x/2x/4x`, active = soft-green. + +### JSON syntax highlighter + +New pure function, local to spec-rendering (e.g. `json-highlight.ts`): + +``` +highlightJson(raw: string): JsonToken[] +JsonToken = { text: string; kind: 'key' | 'string' | 'punct' | 'number' | 'literal' | 'plain' } +``` + +- **Truncation-tolerant** — input is partial streaming JSON that cannot be + `JSON.parse`'d. Single forward scan; an unterminated trailing string still + emits as a `string` token. +- **Key vs. string** — a string token is a `key` iff the next non-whitespace + character after it is `:`. The trailing (possibly unterminated) string has no + `:` yet → treated as a plain string. +- Numbers `[-0-9.eE+]`, literals `true/false/null` (incl. partial), punctuation + `{}[]:,`, everything else `plain`. +- Consumed by a `computed()` memoized on `simulator.rawJson()` and rendered as + `@for` spans with `j-` classes. Re-tokenizing per tick is O(n) on + few-hundred-char specs — negligible. + +Isolation: one pure function, one input, deterministic output — ideal for TDD. + +## Data flow + +`StreamingSimulator` (unchanged) exposes signals `rawJson`, `spec`, `position`, +`total`, `playing`, `speed`, `progress` and methods `play/pause/toggle/seek/ +setSpeed/setSource`. The component reads `simulator.spec()` into `` +and `simulator.rawJson()` into the highlighter; `loading` = `simulator.playing()` +drives skeletons. The footer timeline takes the simulator as input and calls its +methods. No data-flow changes — this is a presentation-layer redesign. + +## Theming & error handling + +- **Light/dark**: because all chrome uses `--ds-*` tokens, both themes work; the + brighter green is theme-invariant and legible on both. Verify by toggling. +- **Empty/paused state**: preserve the current "Press play to start streaming…" + placeholder, restyled. +- **Malformed/partial JSON**: the highlighter never throws; worst case a token is + `plain`. No `JSON.parse` on the raw stream. + +## Testing & verification + +- **Unit (TDD)**: `json-highlight` — key/value disambiguation, numbers, literals, + punctuation, unterminated trailing string, empty input, whitespace. +- **No Tailwind classes** remain in the three touched component files (grep gate). +- **Visual (Chrome MCP / preview)** against the served example (`/render/ + spec-rendering`): confirm styled render (buttons have padding/radius/fill), + `tailwindRuleCount` no longer required, skeleton→content transition, scrubber + drag, speed switch, and **theme flip** (light + dark). Render e2e is manual + (no LLM / aimock not applicable), so verification is via the running app. +- **Sibling check**: load one other render example (e.g. `element-rendering`) to + confirm the restyled shared timeline renders correctly there too. + +## Success criteria + +1. spec-rendering playback UI is fully styled with **zero** reliance on Tailwind + utilities, standalone and embedded. +2. Render-green player matches the approved mockup; contrast holds on dark. +3. Streaming JSON is syntax-colored and tolerant of partial input. +4. Shared transport timeline is styled (render-green) for all 6 render examples. +5. Light and dark themes both render correctly. From 2e4eedc7eab9f8ba1bd3c2c6d8330325f1a18e9e Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 6 Jul 2026 14:54:41 -0700 Subject: [PATCH 2/8] docs: spec-rendering playback redesign implementation plan Co-Authored-By: Claude Opus 4.8 --- ...-07-06-spec-rendering-playback-redesign.md | 862 ++++++++++++++++++ 1 file changed, 862 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-06-spec-rendering-playback-redesign.md diff --git a/docs/superpowers/plans/2026-07-06-spec-rendering-playback-redesign.md b/docs/superpowers/plans/2026-07-06-spec-rendering-playback-redesign.md new file mode 100644 index 000000000..e7e084b85 --- /dev/null +++ b/docs/superpowers/plans/2026-07-06-spec-rendering-playback-redesign.md @@ -0,0 +1,862 @@ +# Spec-Rendering Playback Redesign Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Rebuild the spec-rendering example's playback UI (and the shared transport timeline it uses) with encapsulated Angular component styles on the real `--ds-*` design tokens, eliminating all Tailwind utility classes so it renders styled standalone and embedded, in light and dark themes. + +**Architecture:** Angular always compiles component `styles:` regardless of Tailwind, so the fix is to move every visual rule off Tailwind utilities into scoped component CSS driven by theme-aware `--ds-*` tokens. Structure is unchanged (spec tabs → split render/JSON → transport footer) and reuses the working `ExampleSplitLayoutComponent` frame. Accent is render-green: `--ds-render-green` (`#1a7a40`) for solid fills, a local brighter `#35b06a` for on-dark text/glows. A new truncation-tolerant JSON syntax-highlighter colorizes the streaming JSON. + +**Tech Stack:** Angular (standalone components, signals, control-flow syntax), `@threadplane/render`, `@threadplane/design-tokens` (`--ds-*` CSS vars), Vitest (node env, globals) for the highlighter unit tests, Nx. + +**Design reference:** `docs/superpowers/specs/2026-07-06-spec-rendering-playback-redesign-design.md` + +--- + +## File structure + +- **Create** `cockpit/render/spec-rendering/angular/src/app/json-highlight.ts` — pure, truncation-tolerant JSON tokenizer for syntax highlighting. +- **Create** `cockpit/render/spec-rendering/angular/src/app/json-highlight.spec.ts` — unit tests (Vitest). +- **Modify** `cockpit/render/shared/streaming-timeline.component.ts` — replace Tailwind template classes with encapsulated `styles:` (render-green). Shared by all 6 render examples; drag logic unchanged. +- **Modify** `cockpit/render/spec-rendering/angular/src/app/spec-rendering.component.ts` — full rewrite of the 4 inline demo components + the shell component (header tabs + status pulse, render pane, syntax-colored JSON pane). +- **Modify** `cockpit/render/spec-rendering/angular/src/styles.css` — `--ds-*`-based body + shared `.sr-skeleton` loader; drop the old Tailwind-oriented shimmer. + +--- + +## Task 1: JSON syntax-highlighter utility (TDD) + +**Files:** +- Create: `cockpit/render/spec-rendering/angular/src/app/json-highlight.ts` +- Test: `cockpit/render/spec-rendering/angular/src/app/json-highlight.spec.ts` + +- [ ] **Step 1: Write the failing tests** + +Create `cockpit/render/spec-rendering/angular/src/app/json-highlight.spec.ts`: + +```ts +// SPDX-License-Identifier: MIT +import { highlightJson } from './json-highlight'; + +describe('highlightJson', () => { + it('returns no tokens for empty input', () => { + expect(highlightJson('')).toEqual([]); + }); + + it('classifies an object key vs a string value', () => { + const toks = highlightJson('{"type": "Card"}'); + expect(toks.find((t) => t.text === '"type"')?.kind).toBe('key'); + expect(toks.find((t) => t.text === '"Card"')?.kind).toBe('string'); + }); + + it('marks structural characters as punct', () => { + const puncts = highlightJson('{}[],:').filter((t) => t.kind === 'punct'); + expect(puncts.map((t) => t.text)).toEqual(['{', '}', '[', ']', ',', ':']); + }); + + it('tokenizes numbers including negatives and exponents', () => { + const nums = highlightJson('[1, -2.5, 3e4]').filter((t) => t.kind === 'number'); + expect(nums.map((t) => t.text)).toEqual(['1', '-2.5', '3e4']); + }); + + it('tokenizes true/false/null as literals', () => { + const lits = highlightJson('[true, false, null]').filter((t) => t.kind === 'literal'); + expect(lits.map((t) => t.text)).toEqual(['true', 'false', 'null']); + }); + + it('treats an unterminated trailing string as a plain string, not a key', () => { + const toks = highlightJson('{"title": "Streaming De'); + expect(toks.find((t) => t.text === '"title"')?.kind).toBe('key'); + const last = toks[toks.length - 1]; + expect(last.text).toBe('"Streaming De'); + expect(last.kind).toBe('string'); + }); + + it('is loss-less: joining token texts reproduces the input', () => { + const sample = '{\n "root": "root",\n "elements": {\n "a": { "type": "Card" }\n }\n}'; + expect(highlightJson(sample).map((t) => t.text).join('')).toBe(sample); + }); + + it('preserves whitespace as plain tokens', () => { + const toks = highlightJson('{ }'); + expect(toks.map((t) => t.text).join('')).toBe('{ }'); + expect(toks.some((t) => t.kind === 'plain' && t.text === ' ')).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx vitest run cockpit/render/spec-rendering/angular/src/app/json-highlight.spec.ts` +Expected: FAIL — cannot resolve `./json-highlight`. + +- [ ] **Step 3: Write the implementation** + +Create `cockpit/render/spec-rendering/angular/src/app/json-highlight.ts`: + +```ts +// SPDX-License-Identifier: MIT + +export type JsonTokenKind = 'key' | 'string' | 'punct' | 'number' | 'literal' | 'plain'; + +export interface JsonToken { + text: string; + kind: JsonTokenKind; +} + +const PUNCT = new Set(['{', '}', '[', ']', ':', ',']); +const WS = new Set([' ', '\n', '\r', '\t']); +const isWs = (c: string) => WS.has(c); +const isDigit = (c: string) => c >= '0' && c <= '9'; +const isAlpha = (c: string) => (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'); +const isNumberPart = (c: string) => isDigit(c) || c === '.' || c === 'e' || c === 'E' || c === '+' || c === '-'; + +/** + * Tokenize a (possibly incomplete) streaming JSON string for syntax + * highlighting. Never throws; tolerant of truncation. A string token is + * classified as a `key` only when it is properly closed AND the next + * non-whitespace character is a colon; the trailing (possibly unterminated) + * string has no colon yet and is emitted as a plain string. The token stream + * is loss-less — concatenating every token's `text` reproduces the input. + */ +export function highlightJson(raw: string): JsonToken[] { + const tokens: JsonToken[] = []; + const n = raw.length; + let i = 0; + + while (i < n) { + const ch = raw[i]; + + if (isWs(ch)) { + let j = i + 1; + while (j < n && isWs(raw[j])) j++; + tokens.push({ text: raw.slice(i, j), kind: 'plain' }); + i = j; + continue; + } + + if (PUNCT.has(ch)) { + tokens.push({ text: ch, kind: 'punct' }); + i += 1; + continue; + } + + if (ch === '"') { + let j = i + 1; + let closed = false; + while (j < n) { + if (raw[j] === '\\') { j += 2; continue; } + if (raw[j] === '"') { j += 1; closed = true; break; } + j += 1; + } + const text = raw.slice(i, Math.min(j, n)); + let k = j; + while (k < n && isWs(raw[k])) k++; + const isKey = closed && k < n && raw[k] === ':'; + tokens.push({ text, kind: isKey ? 'key' : 'string' }); + i = Math.min(j, n); + continue; + } + + if (ch === '-' || isDigit(ch)) { + let j = i + 1; + while (j < n && isNumberPart(raw[j])) j++; + tokens.push({ text: raw.slice(i, j), kind: 'number' }); + i = j; + continue; + } + + if (isAlpha(ch)) { + let j = i + 1; + while (j < n && isAlpha(raw[j])) j++; + tokens.push({ text: raw.slice(i, j), kind: 'literal' }); + i = j; + continue; + } + + tokens.push({ text: ch, kind: 'plain' }); + i += 1; + } + + return tokens; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run cockpit/render/spec-rendering/angular/src/app/json-highlight.spec.ts` +Expected: PASS (8 tests). + +- [ ] **Step 5: Commit** + +```bash +git add cockpit/render/spec-rendering/angular/src/app/json-highlight.ts cockpit/render/spec-rendering/angular/src/app/json-highlight.spec.ts +git commit -m "feat(cockpit-render): truncation-tolerant JSON syntax tokenizer" +``` + +--- + +## Task 2: Restyle the shared streaming-timeline (render-green) + +**Files:** +- Modify: `cockpit/render/shared/streaming-timeline.component.ts` + +- [ ] **Step 1: Replace the component's template + add encapsulated styles** + +Replace the `template:` in the `@Component` decorator and add a `styles:` block, leaving the class body (imports, `simulator` input, `track` viewChild, `speeds`, and all `onTrack*`/`seekFrom*` methods) **unchanged**. The decorator becomes: + +```ts +@Component({ + selector: 'streaming-timeline', + standalone: true, + styles: ` + :host { + --tl-green: var(--ds-render-green, #1a7a40); + --tl-green-bright: #35b06a; + display: block; + } + .tl { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 1rem; + background: var(--ds-surface, #1c1c1c); + border-top: 1px solid var(--ds-border, #2d2d2d); + } + .tl__play { + width: 34px; + height: 34px; + border-radius: 999px; + border: 0; + flex-shrink: 0; + color: #eafff2; + background: var(--tl-green); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + box-shadow: 0 2px 12px rgba(26, 122, 64, 0.5); + transition: transform 0.1s ease, box-shadow 0.15s ease; + } + .tl__play:hover { box-shadow: 0 3px 16px rgba(53, 176, 106, 0.6); } + .tl__play:active { transform: scale(0.94); } + .tl__track { + flex: 1; + position: relative; + height: 6px; + border-radius: 999px; + background: var(--ds-surface-tinted, #2c2c2c); + cursor: pointer; + } + .tl__fill { + position: absolute; + inset: 0 auto 0 0; + border-radius: 999px; + background: linear-gradient(90deg, var(--tl-green), var(--tl-green-bright)); + } + .tl__handle { + position: absolute; + top: 50%; + width: 15px; + height: 15px; + border-radius: 999px; + background: #fff; + border: 2px solid var(--tl-green-bright); + transform: translate(-50%, -50%); + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.4); + transition: left 0.075s linear; + } + .tl__count { + flex-shrink: 0; + min-width: 100px; + text-align: right; + font-family: var(--ds-font-mono, ui-monospace, monospace); + font-size: 11px; + color: var(--ds-text-muted, #a0a0a0); + font-variant-numeric: tabular-nums; + } + .tl__count b { color: var(--ds-text-primary, #f5f5f5); font-weight: 600; } + .tl__speeds { display: flex; gap: 4px; flex-shrink: 0; } + .tl__speed { + font-size: 10.5px; + padding: 5px 10px; + border-radius: 7px; + border: 1px solid var(--ds-border, #2d2d2d); + background: var(--ds-surface-dim, #0a0a0a); + color: var(--ds-text-muted, #a0a0a0); + cursor: pointer; + transition: color 0.12s ease, background 0.12s ease, border-color 0.12s ease; + } + .tl__speed:hover { color: var(--ds-text-secondary, #c8c8c8); } + .tl__speed--on { + color: var(--tl-green-bright); + border-color: rgba(53, 176, 106, 0.35); + background: rgba(53, 176, 106, 0.12); + font-weight: 600; + } + `, + template: ` +
+ + +
+
+
+
+ +
{{ simulator().position() }} / {{ simulator().total() }} chars
+ +
+ @for (s of speeds; track s) { + + } +
+
+ `, +}) +``` + +- [ ] **Step 2: Verify no Tailwind utilities remain** + +Run: `grep -nE 'class="[^"]*(px-|py-|text-\[|bg-\[|rounded|flex |gap-|w-[0-9]|h-[0-9]|from-|to-|shadow-|tabular|shrink|inset|absolute|relative)' cockpit/render/shared/streaming-timeline.component.ts` +Expected: no output (exit 1 — no matches). + +- [ ] **Step 3: Type-check the shared file compiles** + +Run: `npx tsc --noEmit -p cockpit/render/spec-rendering/angular/tsconfig.app.json` +Expected: no errors (the spec-rendering app imports this shared component, so its tsconfig covers it). + +- [ ] **Step 4: Commit** + +```bash +git add cockpit/render/shared/streaming-timeline.component.ts +git commit -m "feat(cockpit-render): restyle streaming transport timeline with encapsulated CSS on --ds-* tokens" +``` + +--- + +## Task 3: Rewrite spec-rendering component (demo components + shell) + +**Files:** +- Modify: `cockpit/render/spec-rendering/angular/src/app/spec-rendering.component.ts` + +- [ ] **Step 1: Replace the whole file** + +Replace the entire contents of `cockpit/render/spec-rendering/angular/src/app/spec-rendering.component.ts` with: + +```ts +// SPDX-License-Identifier: MIT +import { Component, computed, input, OnDestroy, viewChild, ElementRef, effect } from '@angular/core'; +import { + RenderSpecComponent, + RenderElementComponent, + defineAngularRegistry, + signalStateStore, +} from '@threadplane/render'; +import type { Spec } from '@json-render/core'; +import { StreamingTimelineComponent } from '../../../../shared/streaming-timeline.component'; +import { ExampleSplitLayoutComponent } from '@threadplane/example-layouts'; +import { SPEC_RENDERING_SPECS } from './specs'; +import { highlightJson } from './json-highlight'; + +// --- Inline view components registered in the demo registry --- + +@Component({ + selector: 'demo-text', + standalone: true, + styles: `.t { margin: 0; font-size: 13px; line-height: 1.55; color: var(--ds-text-secondary, #c8c8c8); }`, + template: ` + @if (displayContent()) { +

{{ displayContent() }}

+ } @else if (loading()) { +
+
+ } + `, +}) +class DemoTextComponent { + readonly content = input(''); + readonly displayContent = computed(() => { + const c = this.content(); + return typeof c === 'string' ? c : ''; + }); + readonly childKeys = input([]); + readonly spec = input(null); + readonly bindings = input>({}); + readonly emit = input<(event: string) => void>(() => {}); + readonly loading = input(false); +} + +@Component({ + selector: 'demo-heading', + standalone: true, + imports: [RenderElementComponent], + styles: `.h { margin: 0 0 0.5rem; font-size: 1.05rem; font-weight: 700; color: var(--ds-text-primary, #f5f5f5); }`, + template: ` + @if (displayContent()) { +

{{ displayContent() }}

+ } @else if (loading()) { +
+ } + @for (key of childKeys(); track key) { + + } + `, +}) +class DemoHeadingComponent { + readonly content = input(''); + readonly displayContent = computed(() => { + const c = this.content(); + return typeof c === 'string' ? c : ''; + }); + readonly childKeys = input([]); + readonly spec = input(null); + readonly bindings = input>({}); + readonly emit = input<(event: string) => void>(() => {}); + readonly loading = input(false); +} + +@Component({ + selector: 'demo-badge', + standalone: true, + styles: ` + .b { + display: inline-block; + margin-bottom: 0.5rem; + padding: 3px 10px; + font-size: 11px; + font-weight: 600; + border-radius: 999px; + color: #35b06a; + background: rgba(53, 176, 106, 0.12); + border: 1px solid rgba(53, 176, 106, 0.35); + } + `, + template: `{{ label() }}`, +}) +class DemoBadgeComponent { + readonly label = input(''); + readonly childKeys = input([]); + readonly spec = input(null); + readonly bindings = input>({}); + readonly emit = input<(event: string) => void>(() => {}); + readonly loading = input(false); +} + +@Component({ + selector: 'demo-card', + standalone: true, + imports: [RenderElementComponent], + styles: ` + .card { + margin-bottom: 0.75rem; + padding: 16px; + background: var(--ds-surface, #1c1c1c); + border: 1px solid var(--ds-border, #2d2d2d); + border-radius: var(--ds-radius-lg, 14px); + box-shadow: var(--ds-shadow-md, 0 4px 6px -1px rgba(0, 0, 0, 0.3)); + } + .card__title { + margin: 0 0 0.625rem; + font-size: 13px; + font-weight: 600; + color: var(--ds-text-primary, #f5f5f5); + } + `, + template: ` +
+ @if (title()) { +
{{ title() }}
+ } @else if (loading()) { +
+ } + @if (childKeys().length) { + @for (key of childKeys(); track key) { + + } + } @else if (loading()) { +
+
+ } +
+ `, +}) +class DemoCardComponent { + readonly title = input(''); + readonly childKeys = input([]); + readonly spec = input(null); + readonly bindings = input>({}); + readonly emit = input<(event: string) => void>(() => {}); + readonly loading = input(false); +} + +@Component({ + selector: 'app-spec-rendering', + standalone: true, + imports: [RenderSpecComponent, StreamingTimelineComponent, ExampleSplitLayoutComponent], + styles: ` + .bar { display: flex; align-items: center; gap: 0.875rem; padding: 0.75rem 1rem; } + .bar__lbl { + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--ds-text-muted, #a0a0a0); + } + .tabs { + display: flex; + gap: 4px; + padding: 4px; + border-radius: 999px; + background: var(--ds-surface-dim, #0a0a0a); + border: 1px solid var(--ds-border, #2d2d2d); + } + .tab { + padding: 6px 14px; + font-size: 12px; + border: 0; + border-radius: 999px; + background: transparent; + color: var(--ds-text-muted, #a0a0a0); + cursor: pointer; + transition: color 0.15s ease, background 0.15s ease; + } + .tab:hover { color: var(--ds-text-secondary, #c8c8c8); } + .tab--on { + background: var(--ds-render-green, #1a7a40); + color: #eafff2; + font-weight: 600; + box-shadow: 0 1px 8px rgba(26, 122, 64, 0.5); + } + .status { + margin-left: auto; + display: flex; + align-items: center; + gap: 7px; + font-size: 11px; + color: var(--ds-text-muted, #a0a0a0); + } + .status__dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #35b06a; + box-shadow: 0 0 0 4px rgba(53, 176, 106, 0.13); + } + .status__dot--live { animation: sr-pulse 1.6s ease-in-out infinite; } + @keyframes sr-pulse { 50% { opacity: 0.4; } } + .cap { + margin-bottom: 1rem; + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.14em; + color: var(--ds-text-muted, #a0a0a0); + } + .placeholder { font-size: 13px; font-style: italic; color: var(--ds-text-muted, #a0a0a0); } + .json-pane { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + padding: 1rem; + background: var(--ds-surface-dim, #0a0a0a); + } + .json { + flex: 1; + min-height: 0; + overflow-y: auto; + margin: 0; + font-family: var(--ds-font-mono, ui-monospace, monospace); + font-size: 11.5px; + line-height: 1.7; + white-space: pre-wrap; + word-break: break-all; + color: var(--ds-text-secondary, #c8c8c8); + } + .json .j-key { color: #7fe0a3; } + .json .j-string { color: #c9c17f; } + .json .j-number { color: #e6b673; } + .json .j-literal { color: #7fd6ff; } + .json .j-punct { color: var(--ds-text-muted, #a0a0a0); } + .json__cursor { + display: inline-block; + width: 7px; + height: 14px; + vertical-align: -2px; + background: #35b06a; + animation: sr-blink 1s step-end infinite; + } + @keyframes sr-blink { 50% { opacity: 0; } } + .json__foot { + display: flex; + justify-content: space-between; + margin-top: 0.75rem; + padding-top: 0.625rem; + font-size: 10.5px; + border-top: 1px solid var(--ds-border, #2d2d2d); + } + .json__foot .state { color: #35b06a; font-weight: 600; } + .json__foot .state--idle { color: var(--ds-text-muted, #a0a0a0); font-weight: 400; } + .json__foot .pct { color: var(--ds-text-muted, #a0a0a0); font-variant-numeric: tabular-nums; } + `, + template: ` + + +
+ Spec +
+ @for (spec of specs; track spec.label; let i = $index) { + + } +
+ + + {{ statusLabel() }} + +
+ + +
+
Live Render Output
+ @if (simulator.spec(); as renderedSpec) { + + } @else { +
Press play to start streaming…
+ } +
+ + +
+
+
Streaming JSON
+
@for (tok of jsonTokens(); track $index) {{{ tok.text }}}
+
+ {{ streamStateLabel() }} + {{ percent() }}% +
+
+
+ + + +
+ `, +}) +export class SpecRenderingComponent implements OnDestroy { + protected readonly specs = SPEC_RENDERING_SPECS; + protected activeIndex = 0; + + protected readonly simulator = new StreamingSimulator(this.specs[0].json); + + protected readonly jsonTokens = computed(() => highlightJson(this.simulator.rawJson())); + + private readonly jsonScroll = viewChild>('jsonScroll'); + + constructor() { + effect(() => { + this.simulator.rawJson(); + const el = this.jsonScroll()?.nativeElement; + if (el) { + requestAnimationFrame(() => { + el.scrollTop = el.scrollHeight; + }); + } + }); + } + + protected readonly registry = defineAngularRegistry({ + Text: DemoTextComponent, + Heading: DemoHeadingComponent, + Badge: DemoBadgeComponent, + Card: DemoCardComponent, + }); + + protected readonly store = signalStateStore({}); + + protected percent(): number { + return Math.round(this.simulator.progress() * 100); + } + + protected statusLabel(): string { + if (this.simulator.playing()) return 'Streaming'; + return this.simulator.position() >= this.simulator.total() ? 'Complete' : 'Paused'; + } + + protected streamStateLabel(): string { + if (this.simulator.playing()) return 'Streaming…'; + return this.simulator.position() >= this.simulator.total() ? 'Complete' : 'Paused'; + } + + protected selectSpec(index: number): void { + this.activeIndex = index; + this.simulator.setSource(this.specs[index].json); + this.simulator.play(); + } + + ngOnDestroy(): void { + this.simulator.destroy(); + } +} +``` + +> **Whitespace note:** the `
@for (…) {{{ tok.text }}}
` line MUST stay on a single line with no spaces/newlines between `>` `@for`, `{`, ``, `}`, and the cursor span. Token text carries all real whitespace; any template whitespace inside `
` would render as stray gaps.
+
+- [ ] **Step 2: Add the missing StreamingSimulator import**
+
+The class instantiates `new StreamingSimulator(...)`. Add it to the imports at the top of the file (alongside the existing `StreamingTimelineComponent` import):
+
+```ts
+import { StreamingSimulator } from '../../../../shared/streaming-simulator';
+```
+
+- [ ] **Step 3: Verify no Tailwind utilities remain in the file**
+
+Run: `grep -nE 'class="[^"]*(px-|py-|text-\[|text-\(|bg-\[|rounded|flex |gap-|w-[0-9]|h-[0-9]|from-|to-|shadow-|tabular|shrink|inset|absolute|animate-|uppercase|tracking|space-y|leading-|border-\[)' cockpit/render/spec-rendering/angular/src/app/spec-rendering.component.ts`
+Expected: no output (exit 1). Only plain scoped class names (`tab`, `card`, `json`, `sr-skeleton`, etc.) remain.
+
+- [ ] **Step 4: Type-check**
+
+Run: `npx tsc --noEmit -p cockpit/render/spec-rendering/angular/tsconfig.app.json`
+Expected: no errors.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add cockpit/render/spec-rendering/angular/src/app/spec-rendering.component.ts
+git commit -m "feat(cockpit-render): redesign spec-rendering playback UI with encapsulated CSS + syntax-colored JSON"
+```
+
+---
+
+## Task 4: Update global styles.css
+
+**Files:**
+- Modify: `cockpit/render/spec-rendering/angular/src/styles.css`
+
+- [ ] **Step 1: Replace the file contents**
+
+Replace the entire contents of `cockpit/render/spec-rendering/angular/src/styles.css` with:
+
+```css
+@import "@threadplane/example-layouts/theme.css";
+
+html,
+body {
+  height: 100%;
+  margin: 0;
+  background: var(--ds-canvas, #111);
+  color: var(--ds-text-primary, #f5f5f5);
+  font-family: var(--ds-font-sans, system-ui, sans-serif);
+}
+
+/* Shared skeleton loader for streamed-but-incomplete demo elements. */
+.sr-skeleton {
+  border-radius: 5px;
+  background: linear-gradient(
+    90deg,
+    var(--ds-surface-tinted, #2c2c2c) 0%,
+    rgba(90, 90, 90, 0.55) 50%,
+    var(--ds-surface-tinted, #2c2c2c) 100%
+  );
+  background-size: 200% 100%;
+  animation: sr-shimmer 1.5s ease-in-out infinite;
+}
+
+@keyframes sr-shimmer {
+  0% { background-position: 200% 0; }
+  100% { background-position: -200% 0; }
+}
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add cockpit/render/spec-rendering/angular/src/styles.css
+git commit -m "chore(cockpit-render): --ds-* body styling + shared sr-skeleton loader"
+```
+
+---
+
+## Task 5: Integration gate — production build + smoke + Tailwind-free check
+
+**Files:** none (verification only)
+
+- [ ] **Step 1: Production build succeeds within budgets**
+
+Run: `npx nx build cockpit-render-spec-rendering-angular --configuration=production`
+Expected: build succeeds; no `anyComponentStyle` budget error (per-component CSS < 16kb) and no initial-bundle error (< 1.5mb).
+
+- [ ] **Step 2: Smoke target passes (module shape intact)**
+
+Run: `npx nx smoke cockpit-render-spec-rendering-angular`
+Expected: exits 0 (no "Unexpected module shape" throw).
+
+- [ ] **Step 3: Repo-wide Tailwind-free assertion for the three touched component/style files**
+
+Run:
+```bash
+grep -REn 'class="[^"]*(px-|py-|text-\[|bg-\[|rounded-|gap-[0-9]|from-indigo|to-indigo|animate-pulse|space-y-|tracking-w)' \
+  cockpit/render/shared/streaming-timeline.component.ts \
+  cockpit/render/spec-rendering/angular/src/app/spec-rendering.component.ts
+```
+Expected: no output (exit 1).
+
+- [ ] **Step 4: Highlighter tests still green**
+
+Run: `npx vitest run cockpit/render/spec-rendering/angular/src/app/json-highlight.spec.ts`
+Expected: PASS.
+
+- [ ] **Step 5: Commit (if any incidental fixes were needed)**
+
+Only if steps required edits. Otherwise skip.
+
+---
+
+## Task 6: Visual verification (orchestrator, Chrome MCP)
+
+**Files:** none (manual/tool verification — performed by the orchestrator, not a subagent, since render e2e is manual and aimock is not applicable to render examples)
+
+- [ ] **Step 1: Serve the example**
+
+Run: `npx nx serve cockpit-render-spec-rendering-angular` (dev config). Note the served URL/port.
+
+- [ ] **Step 2: Verify with Chrome MCP / preview**
+  - Buttons/tabs have real padding, radius, and render-green active fill (no browser-default `padding:0`).
+  - Pressing play streams: JSON is syntax-colored with a blinking cursor; the Card/Badge/Text render on the left with skeleton lines for not-yet-streamed children.
+  - Scrubber drag seeks; speed toggles switch and highlight; status pulse reflects play state.
+
+- [ ] **Step 3: Theme flip**
+  - Confirm the UI renders correctly in both dark and light themes (tokens flip; the brighter green stays legible on both).
+
+- [ ] **Step 4: Sibling regression check**
+  - Serve/inspect one other render example (e.g. `cockpit-render-element-rendering-angular`) and confirm the restyled shared transport timeline renders correctly there too.
+
+---
+
+## Self-review
+
+- **Spec coverage:** encapsulated CSS on `--ds-*` (Tasks 2–4) ✓; render-green two-role accent (Tasks 2–3) ✓; segmented tabs + status pulse (Task 3) ✓; skeleton loaders (Tasks 3–4) ✓; syntax-colored truncation-tolerant JSON (Tasks 1, 3) ✓; restyle shared timeline in place for all 6 examples (Task 2) ✓; `StreamingSimulator` untouched ✓; light/dark + sibling verification (Task 6) ✓; grep gate + build/smoke (Task 5) ✓.
+- **Placeholder scan:** every code step contains complete code; no TBD/TODO.
+- **Type consistency:** `JsonToken.kind` values (`key|string|punct|number|literal|plain`) map to `.j-` classes styled in Task 3; `highlightJson` signature matches its use in `jsonTokens`; `StreamingSimulator`/`StreamingTimelineComponent` APIs used unchanged.

From cf2125a04f3d535a08177a2590823413b30a0bdd Mon Sep 17 00:00:00 2001
From: Brian Love 
Date: Mon, 6 Jul 2026 14:56:30 -0700
Subject: [PATCH 3/8] feat(cockpit-render): truncation-tolerant JSON syntax
 tokenizer

---
 .../angular/src/app/json-highlight.spec.ts    | 48 +++++++++++
 .../angular/src/app/json-highlight.ts         | 85 +++++++++++++++++++
 2 files changed, 133 insertions(+)
 create mode 100644 cockpit/render/spec-rendering/angular/src/app/json-highlight.spec.ts
 create mode 100644 cockpit/render/spec-rendering/angular/src/app/json-highlight.ts

diff --git a/cockpit/render/spec-rendering/angular/src/app/json-highlight.spec.ts b/cockpit/render/spec-rendering/angular/src/app/json-highlight.spec.ts
new file mode 100644
index 000000000..563c66b2d
--- /dev/null
+++ b/cockpit/render/spec-rendering/angular/src/app/json-highlight.spec.ts
@@ -0,0 +1,48 @@
+// SPDX-License-Identifier: MIT
+import { highlightJson } from './json-highlight';
+
+describe('highlightJson', () => {
+  it('returns no tokens for empty input', () => {
+    expect(highlightJson('')).toEqual([]);
+  });
+
+  it('classifies an object key vs a string value', () => {
+    const toks = highlightJson('{"type": "Card"}');
+    expect(toks.find((t) => t.text === '"type"')?.kind).toBe('key');
+    expect(toks.find((t) => t.text === '"Card"')?.kind).toBe('string');
+  });
+
+  it('marks structural characters as punct', () => {
+    const puncts = highlightJson('{}[],:').filter((t) => t.kind === 'punct');
+    expect(puncts.map((t) => t.text)).toEqual(['{', '}', '[', ']', ',', ':']);
+  });
+
+  it('tokenizes numbers including negatives and exponents', () => {
+    const nums = highlightJson('[1, -2.5, 3e4]').filter((t) => t.kind === 'number');
+    expect(nums.map((t) => t.text)).toEqual(['1', '-2.5', '3e4']);
+  });
+
+  it('tokenizes true/false/null as literals', () => {
+    const lits = highlightJson('[true, false, null]').filter((t) => t.kind === 'literal');
+    expect(lits.map((t) => t.text)).toEqual(['true', 'false', 'null']);
+  });
+
+  it('treats an unterminated trailing string as a plain string, not a key', () => {
+    const toks = highlightJson('{"title": "Streaming De');
+    expect(toks.find((t) => t.text === '"title"')?.kind).toBe('key');
+    const last = toks[toks.length - 1];
+    expect(last.text).toBe('"Streaming De');
+    expect(last.kind).toBe('string');
+  });
+
+  it('is loss-less: joining token texts reproduces the input', () => {
+    const sample = '{\n  "root": "root",\n  "elements": {\n    "a": { "type": "Card" }\n  }\n}';
+    expect(highlightJson(sample).map((t) => t.text).join('')).toBe(sample);
+  });
+
+  it('preserves whitespace as plain tokens', () => {
+    const toks = highlightJson('{ }');
+    expect(toks.map((t) => t.text).join('')).toBe('{ }');
+    expect(toks.some((t) => t.kind === 'plain' && t.text === ' ')).toBe(true);
+  });
+});
diff --git a/cockpit/render/spec-rendering/angular/src/app/json-highlight.ts b/cockpit/render/spec-rendering/angular/src/app/json-highlight.ts
new file mode 100644
index 000000000..e6c8b05ab
--- /dev/null
+++ b/cockpit/render/spec-rendering/angular/src/app/json-highlight.ts
@@ -0,0 +1,85 @@
+// SPDX-License-Identifier: MIT
+
+export type JsonTokenKind = 'key' | 'string' | 'punct' | 'number' | 'literal' | 'plain';
+
+export interface JsonToken {
+  text: string;
+  kind: JsonTokenKind;
+}
+
+const PUNCT = new Set(['{', '}', '[', ']', ':', ',']);
+const WS = new Set([' ', '\n', '\r', '\t']);
+const isWs = (c: string) => WS.has(c);
+const isDigit = (c: string) => c >= '0' && c <= '9';
+const isAlpha = (c: string) => (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z');
+const isNumberPart = (c: string) => isDigit(c) || c === '.' || c === 'e' || c === 'E' || c === '+' || c === '-';
+
+/**
+ * Tokenize a (possibly incomplete) streaming JSON string for syntax
+ * highlighting. Never throws; tolerant of truncation. A string token is
+ * classified as a `key` only when it is properly closed AND the next
+ * non-whitespace character is a colon; the trailing (possibly unterminated)
+ * string has no colon yet and is emitted as a plain string. The token stream
+ * is loss-less — concatenating every token's `text` reproduces the input.
+ */
+export function highlightJson(raw: string): JsonToken[] {
+  const tokens: JsonToken[] = [];
+  const n = raw.length;
+  let i = 0;
+
+  while (i < n) {
+    const ch = raw[i];
+
+    if (isWs(ch)) {
+      let j = i + 1;
+      while (j < n && isWs(raw[j])) j++;
+      tokens.push({ text: raw.slice(i, j), kind: 'plain' });
+      i = j;
+      continue;
+    }
+
+    if (PUNCT.has(ch)) {
+      tokens.push({ text: ch, kind: 'punct' });
+      i += 1;
+      continue;
+    }
+
+    if (ch === '"') {
+      let j = i + 1;
+      let closed = false;
+      while (j < n) {
+        if (raw[j] === '\\') { j += 2; continue; }
+        if (raw[j] === '"') { j += 1; closed = true; break; }
+        j += 1;
+      }
+      const text = raw.slice(i, Math.min(j, n));
+      let k = j;
+      while (k < n && isWs(raw[k])) k++;
+      const isKey = closed && k < n && raw[k] === ':';
+      tokens.push({ text, kind: isKey ? 'key' : 'string' });
+      i = Math.min(j, n);
+      continue;
+    }
+
+    if (ch === '-' || isDigit(ch)) {
+      let j = i + 1;
+      while (j < n && isNumberPart(raw[j])) j++;
+      tokens.push({ text: raw.slice(i, j), kind: 'number' });
+      i = j;
+      continue;
+    }
+
+    if (isAlpha(ch)) {
+      let j = i + 1;
+      while (j < n && isAlpha(raw[j])) j++;
+      tokens.push({ text: raw.slice(i, j), kind: 'literal' });
+      i = j;
+      continue;
+    }
+
+    tokens.push({ text: ch, kind: 'plain' });
+    i += 1;
+  }
+
+  return tokens;
+}

From 0bd636e840c60fabf472b867e5cd4efd917846a0 Mon Sep 17 00:00:00 2001
From: Brian Love 
Date: Mon, 6 Jul 2026 14:57:58 -0700
Subject: [PATCH 4/8] feat(cockpit-render): restyle streaming transport
 timeline with encapsulated CSS on --ds-* tokens

---
 .../shared/streaming-timeline.component.ts    | 126 ++++++++++++++----
 1 file changed, 101 insertions(+), 25 deletions(-)

diff --git a/cockpit/render/shared/streaming-timeline.component.ts b/cockpit/render/shared/streaming-timeline.component.ts
index a1cf0c1ec..09ed7a18d 100644
--- a/cockpit/render/shared/streaming-timeline.component.ts
+++ b/cockpit/render/shared/streaming-timeline.component.ts
@@ -5,48 +5,124 @@ import { StreamingSimulator } from './streaming-simulator';
 @Component({
   selector: 'streaming-timeline',
   standalone: true,
+  styles: `
+    :host {
+      --tl-green: var(--ds-render-green, #1a7a40);
+      --tl-green-bright: #35b06a;
+      display: block;
+    }
+    .tl {
+      display: flex;
+      align-items: center;
+      gap: 0.75rem;
+      padding: 0.75rem 1rem;
+      background: var(--ds-surface, #1c1c1c);
+      border-top: 1px solid var(--ds-border, #2d2d2d);
+    }
+    .tl__play {
+      width: 34px;
+      height: 34px;
+      border-radius: 999px;
+      border: 0;
+      flex-shrink: 0;
+      color: #eafff2;
+      background: var(--tl-green);
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      cursor: pointer;
+      box-shadow: 0 2px 12px rgba(26, 122, 64, 0.5);
+      transition: transform 0.1s ease, box-shadow 0.15s ease;
+    }
+    .tl__play:hover { box-shadow: 0 3px 16px rgba(53, 176, 106, 0.6); }
+    .tl__play:active { transform: scale(0.94); }
+    .tl__track {
+      flex: 1;
+      position: relative;
+      height: 6px;
+      border-radius: 999px;
+      background: var(--ds-surface-tinted, #2c2c2c);
+      cursor: pointer;
+    }
+    .tl__fill {
+      position: absolute;
+      inset: 0 auto 0 0;
+      border-radius: 999px;
+      background: linear-gradient(90deg, var(--tl-green), var(--tl-green-bright));
+    }
+    .tl__handle {
+      position: absolute;
+      top: 50%;
+      width: 15px;
+      height: 15px;
+      border-radius: 999px;
+      background: #fff;
+      border: 2px solid var(--tl-green-bright);
+      transform: translate(-50%, -50%);
+      box-shadow: 0 2px 6px rgba(0, 0, 0, 0.4);
+      transition: left 0.075s linear;
+    }
+    .tl__count {
+      flex-shrink: 0;
+      min-width: 100px;
+      text-align: right;
+      font-family: var(--ds-font-mono, ui-monospace, monospace);
+      font-size: 11px;
+      color: var(--ds-text-muted, #a0a0a0);
+      font-variant-numeric: tabular-nums;
+    }
+    .tl__count b { color: var(--ds-text-primary, #f5f5f5); font-weight: 600; }
+    .tl__speeds { display: flex; gap: 4px; flex-shrink: 0; }
+    .tl__speed {
+      font-size: 10.5px;
+      padding: 5px 10px;
+      border-radius: 7px;
+      border: 1px solid var(--ds-border, #2d2d2d);
+      background: var(--ds-surface-dim, #0a0a0a);
+      color: var(--ds-text-muted, #a0a0a0);
+      cursor: pointer;
+      transition: color 0.12s ease, background 0.12s ease, border-color 0.12s ease;
+    }
+    .tl__speed:hover { color: var(--ds-text-secondary, #c8c8c8); }
+    .tl__speed--on {
+      color: var(--tl-green-bright);
+      border-color: rgba(53, 176, 106, 0.35);
+      background: rgba(53, 176, 106, 0.12);
+      font-weight: 600;
+    }
+  `,
   template: `
-    
-
-
-
-
-
+
+
-
- {{ simulator().position() }} - / {{ simulator().total() }} chars -
+
{{ simulator().position() }} / {{ simulator().total() }} chars
-
+
@for (s of speeds; track s) { From fe3bf6950c858ccfa07088e8a84cc365dde345e1 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 6 Jul 2026 14:59:56 -0700 Subject: [PATCH 5/8] feat(cockpit-render): redesign spec-rendering playback UI with encapsulated CSS + syntax-colored JSON --- .../src/app/spec-rendering.component.ts | 238 ++++++++++++++---- 1 file changed, 190 insertions(+), 48 deletions(-) diff --git a/cockpit/render/spec-rendering/angular/src/app/spec-rendering.component.ts b/cockpit/render/spec-rendering/angular/src/app/spec-rendering.component.ts index c47ec7ae6..160f71c6a 100644 --- a/cockpit/render/spec-rendering/angular/src/app/spec-rendering.component.ts +++ b/cockpit/render/spec-rendering/angular/src/app/spec-rendering.component.ts @@ -11,20 +11,20 @@ import { StreamingSimulator } from '../../../../shared/streaming-simulator'; import { StreamingTimelineComponent } from '../../../../shared/streaming-timeline.component'; import { ExampleSplitLayoutComponent } from '@threadplane/example-layouts'; import { SPEC_RENDERING_SPECS } from './specs'; +import { highlightJson } from './json-highlight'; // --- Inline view components registered in the demo registry --- @Component({ selector: 'demo-text', standalone: true, + styles: `.t { margin: 0; font-size: 13px; line-height: 1.55; color: var(--ds-text-secondary, #c8c8c8); }`, template: ` @if (displayContent()) { -

{{ displayContent() }}

+

{{ displayContent() }}

} @else if (loading()) { -
-
-
-
+
+
} `, }) @@ -45,21 +45,16 @@ class DemoTextComponent { selector: 'demo-heading', standalone: true, imports: [RenderElementComponent], + styles: `.h { margin: 0 0 0.5rem; font-size: 1.05rem; font-weight: 700; color: var(--ds-text-primary, #f5f5f5); }`, template: ` @if (displayContent()) { -

{{ displayContent() }}

+

{{ displayContent() }}

} @else if (loading()) { -
+
} @for (key of childKeys(); track key) { } - @if (!childKeys().length && loading()) { -
-
-
-
- } `, }) class DemoHeadingComponent { @@ -78,7 +73,20 @@ class DemoHeadingComponent { @Component({ selector: 'demo-badge', standalone: true, - template: `{{ label() }}`, + styles: ` + .b { + display: inline-block; + margin-bottom: 0.5rem; + padding: 3px 10px; + font-size: 11px; + font-weight: 600; + border-radius: 999px; + color: #35b06a; + background: rgba(53, 176, 106, 0.12); + border: 1px solid rgba(53, 176, 106, 0.35); + } + `, + template: `{{ label() }}`, }) class DemoBadgeComponent { readonly label = input(''); @@ -93,22 +101,36 @@ class DemoBadgeComponent { selector: 'demo-card', standalone: true, imports: [RenderElementComponent], + styles: ` + .card { + margin-bottom: 0.75rem; + padding: 16px; + background: var(--ds-surface, #1c1c1c); + border: 1px solid var(--ds-border, #2d2d2d); + border-radius: var(--ds-radius-lg, 14px); + box-shadow: var(--ds-shadow-md, 0 4px 6px -1px rgba(0, 0, 0, 0.3)); + } + .card__title { + margin: 0 0 0.625rem; + font-size: 13px; + font-weight: 600; + color: var(--ds-text-primary, #f5f5f5); + } + `, template: ` -
+
@if (title()) { -

{{ title() }}

+
{{ title() }}
} @else if (loading()) { -
+
} @if (childKeys().length) { @for (key of childKeys(); track key) { } } @else if (loading()) { -
-
-
-
+
+
}
`, @@ -126,46 +148,154 @@ class DemoCardComponent { selector: 'app-spec-rendering', standalone: true, imports: [RenderSpecComponent, StreamingTimelineComponent, ExampleSplitLayoutComponent], + styles: ` + .bar { display: flex; align-items: center; gap: 0.875rem; padding: 0.75rem 1rem; } + .bar__lbl { + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--ds-text-muted, #a0a0a0); + } + .tabs { + display: flex; + gap: 4px; + padding: 4px; + border-radius: 999px; + background: var(--ds-surface-dim, #0a0a0a); + border: 1px solid var(--ds-border, #2d2d2d); + } + .tab { + padding: 6px 14px; + font-size: 12px; + border: 0; + border-radius: 999px; + background: transparent; + color: var(--ds-text-muted, #a0a0a0); + cursor: pointer; + transition: color 0.15s ease, background 0.15s ease; + } + .tab:hover { color: var(--ds-text-secondary, #c8c8c8); } + .tab--on { + background: var(--ds-render-green, #1a7a40); + color: #eafff2; + font-weight: 600; + box-shadow: 0 1px 8px rgba(26, 122, 64, 0.5); + } + .status { + margin-left: auto; + display: flex; + align-items: center; + gap: 7px; + font-size: 11px; + color: var(--ds-text-muted, #a0a0a0); + } + .status__dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #35b06a; + box-shadow: 0 0 0 4px rgba(53, 176, 106, 0.13); + } + .status__dot--live { animation: sr-pulse 1.6s ease-in-out infinite; } + @keyframes sr-pulse { 50% { opacity: 0.4; } } + .cap { + margin-bottom: 1rem; + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.14em; + color: var(--ds-text-muted, #a0a0a0); + } + .placeholder { font-size: 13px; font-style: italic; color: var(--ds-text-muted, #a0a0a0); } + .json-pane { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + padding: 1rem; + background: var(--ds-surface-dim, #0a0a0a); + } + .json { + flex: 1; + min-height: 0; + overflow-y: auto; + margin: 0; + font-family: var(--ds-font-mono, ui-monospace, monospace); + font-size: 11.5px; + line-height: 1.7; + white-space: pre-wrap; + word-break: break-all; + color: var(--ds-text-secondary, #c8c8c8); + } + .json .j-key { color: #7fe0a3; } + .json .j-string { color: #c9c17f; } + .json .j-number { color: #e6b673; } + .json .j-literal { color: #7fd6ff; } + .json .j-punct { color: var(--ds-text-muted, #a0a0a0); } + .json__cursor { + display: inline-block; + width: 7px; + height: 14px; + vertical-align: -2px; + background: #35b06a; + animation: sr-blink 1s step-end infinite; + } + @keyframes sr-blink { 50% { opacity: 0; } } + .json__foot { + display: flex; + justify-content: space-between; + margin-top: 0.75rem; + padding-top: 0.625rem; + font-size: 10.5px; + border-top: 1px solid var(--ds-border, #2d2d2d); + } + .json__foot .state { color: #35b06a; font-weight: 600; } + .json__foot .state--idle { color: var(--ds-text-muted, #a0a0a0); font-weight: 400; } + .json__foot .pct { color: var(--ds-text-muted, #a0a0a0); font-variant-numeric: tabular-nums; } + `, template: ` - -
- Spec: - @for (spec of specs; track spec.label; let i = $index) { - - } + +
+ Spec +
+ @for (spec of specs; track spec.label; let i = $index) { + + } +
+ + + {{ statusLabel() }} +
- +
-
Live Render Output
+
Live Render Output
@if (simulator.spec(); as renderedSpec) { } @else { -
Press play to start streaming...
+
Press play to start streaming…
}
- -
- -
-
Streaming JSON
-
{{ simulator.rawJson() }}|
-
- {{ simulator.playing() ? 'Streaming...' : simulator.position() >= simulator.total() ? 'Complete' : 'Paused' }} - {{ percent() }}% + +
+
+
Streaming JSON
+
@for (tok of jsonTokens(); track $index) {{{ tok.text }}}
+
+ {{ streamStateLabel() }} + {{ percent() }}%
- - + + `, }) @@ -175,12 +305,14 @@ export class SpecRenderingComponent implements OnDestroy { protected readonly simulator = new StreamingSimulator(this.specs[0].json); - private readonly jsonPane = viewChild>('jsonPane'); + protected readonly jsonTokens = computed(() => highlightJson(this.simulator.rawJson())); + + private readonly jsonScroll = viewChild>('jsonScroll'); constructor() { effect(() => { this.simulator.rawJson(); - const el = this.jsonPane()?.nativeElement; + const el = this.jsonScroll()?.nativeElement; if (el) { requestAnimationFrame(() => { el.scrollTop = el.scrollHeight; @@ -202,6 +334,16 @@ export class SpecRenderingComponent implements OnDestroy { return Math.round(this.simulator.progress() * 100); } + protected statusLabel(): string { + if (this.simulator.playing()) return 'Streaming'; + return this.simulator.position() >= this.simulator.total() ? 'Complete' : 'Paused'; + } + + protected streamStateLabel(): string { + if (this.simulator.playing()) return 'Streaming…'; + return this.simulator.position() >= this.simulator.total() ? 'Complete' : 'Paused'; + } + protected selectSpec(index: number): void { this.activeIndex = index; this.simulator.setSource(this.specs[index].json); From 223d0ece374eee55b52340f4e943b6f4029a33c3 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 6 Jul 2026 15:00:42 -0700 Subject: [PATCH 6/8] chore(cockpit-render): --ds-* body styling + shared sr-skeleton loader --- .../spec-rendering/angular/src/styles.css | 30 ++++++++++++------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/cockpit/render/spec-rendering/angular/src/styles.css b/cockpit/render/spec-rendering/angular/src/styles.css index f578f9fae..46d364b7f 100644 --- a/cockpit/render/spec-rendering/angular/src/styles.css +++ b/cockpit/render/spec-rendering/angular/src/styles.css @@ -1,20 +1,28 @@ @import "@threadplane/example-layouts/theme.css"; -html, body { +html, +body { height: 100%; margin: 0; - background: var(--tplane-chat-bg); - color: var(--tplane-chat-text); - font-family: var(--tplane-chat-font-family); + background: var(--ds-canvas, #111); + color: var(--ds-text-primary, #f5f5f5); + font-family: var(--ds-font-sans, system-ui, sans-serif); } -@keyframes shimmer { - 0% { background-position: -200% 0; } - 100% { background-position: 200% 0; } +/* Shared skeleton loader for streamed-but-incomplete demo elements. */ +.sr-skeleton { + border-radius: 5px; + background: linear-gradient( + 90deg, + var(--ds-surface-tinted, #2c2c2c) 0%, + rgba(90, 90, 90, 0.55) 50%, + var(--ds-surface-tinted, #2c2c2c) 100% + ); + background-size: 200% 100%; + animation: sr-shimmer 1.5s ease-in-out infinite; } -.skeleton-shimmer { - background: linear-gradient(90deg, rgb(31 41 55 / 0) 0%, rgb(55 65 81 / 0.3) 50%, rgb(31 41 55 / 0) 100%); - background-size: 200% 100%; - animation: shimmer 1.5s ease-in-out infinite; +@keyframes sr-shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } } From 719706c7f4c2e7b3c28b26fcbcbce663e23f8a47 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 6 Jul 2026 15:02:47 -0700 Subject: [PATCH 7/8] fix(cockpit-render): exclude colocated spec files from spec-rendering app build The new json-highlight.spec.ts lives in src/app; the app tsconfig included all src/**/*.ts, so the production build compiled the vitest spec and failed on the global `expect`. Exclude *.spec.ts / *.test.ts from the app build, matching the default Angular CLI convention. Co-Authored-By: Claude Opus 4.8 --- cockpit/render/spec-rendering/angular/tsconfig.app.json | 1 + 1 file changed, 1 insertion(+) diff --git a/cockpit/render/spec-rendering/angular/tsconfig.app.json b/cockpit/render/spec-rendering/angular/tsconfig.app.json index c3eee2239..77c8da2a1 100644 --- a/cockpit/render/spec-rendering/angular/tsconfig.app.json +++ b/cockpit/render/spec-rendering/angular/tsconfig.app.json @@ -6,6 +6,7 @@ }, "files": ["src/main.ts"], "include": ["src/**/*.d.ts", "src/**/*.ts"], + "exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"], "references": [ { "path": "../../../../libs/cockpit-telemetry" From 53be9d3a478482b43474ea238f37f7cfdb9d010c Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 6 Jul 2026 15:10:44 -0700 Subject: [PATCH 8/8] fix(cockpit-render): pin streaming-JSON console to dark surface in both themes The syntax-highlight palette is tuned for a dark backdrop; on light theme the JSON pane inherited a near-white --ds-surface-dim and the colors washed out. Pin the JSON viewer to a fixed dark console surface with a scoped code palette so keys/values/punctuation stay legible in light and dark alike. Co-Authored-By: Claude Opus 4.8 --- .../src/app/spec-rendering.component.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/cockpit/render/spec-rendering/angular/src/app/spec-rendering.component.ts b/cockpit/render/spec-rendering/angular/src/app/spec-rendering.component.ts index 160f71c6a..dbe9024a3 100644 --- a/cockpit/render/spec-rendering/angular/src/app/spec-rendering.component.ts +++ b/cockpit/render/spec-rendering/angular/src/app/spec-rendering.component.ts @@ -208,14 +208,20 @@ class DemoCardComponent { color: var(--ds-text-muted, #a0a0a0); } .placeholder { font-size: 13px; font-style: italic; color: var(--ds-text-muted, #a0a0a0); } + /* The JSON viewer is a code console: pin it to a dark surface with a + fixed palette in BOTH themes so the syntax colors stay legible. */ .json-pane { + --code-fg: rgb(200, 200, 200); + --code-muted: rgb(150, 150, 150); + --code-border: rgba(255, 255, 255, 0.09); display: flex; flex-direction: column; flex: 1; min-height: 0; padding: 1rem; - background: var(--ds-surface-dim, #0a0a0a); + background: #0b0b0b; } + .json-pane .cap { color: var(--code-muted); } .json { flex: 1; min-height: 0; @@ -226,13 +232,13 @@ class DemoCardComponent { line-height: 1.7; white-space: pre-wrap; word-break: break-all; - color: var(--ds-text-secondary, #c8c8c8); + color: var(--code-fg); } .json .j-key { color: #7fe0a3; } .json .j-string { color: #c9c17f; } .json .j-number { color: #e6b673; } .json .j-literal { color: #7fd6ff; } - .json .j-punct { color: var(--ds-text-muted, #a0a0a0); } + .json .j-punct { color: var(--code-muted); } .json__cursor { display: inline-block; width: 7px; @@ -248,11 +254,11 @@ class DemoCardComponent { margin-top: 0.75rem; padding-top: 0.625rem; font-size: 10.5px; - border-top: 1px solid var(--ds-border, #2d2d2d); + border-top: 1px solid var(--code-border); } .json__foot .state { color: #35b06a; font-weight: 600; } - .json__foot .state--idle { color: var(--ds-text-muted, #a0a0a0); font-weight: 400; } - .json__foot .pct { color: var(--ds-text-muted, #a0a0a0); font-variant-numeric: tabular-nums; } + .json__foot .state--idle { color: var(--code-muted); font-weight: 400; } + .json__foot .pct { color: var(--code-muted); font-variant-numeric: tabular-nums; } `, template: `