@for (s of speeds; track s) {
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;
+}
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..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
@@ -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,160 @@ 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); }
+ /* 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: #0b0b0b;
+ }
+ .json-pane .cap { color: var(--code-muted); }
+ .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(--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(--code-muted); }
+ .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(--code-border);
+ }
+ .json__foot .state { color: #35b06a; font-weight: 600; }
+ .json__foot .state--idle { color: var(--code-muted); font-weight: 400; }
+ .json__foot .pct { color: var(--code-muted); 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 }}}
+
-
-
+
+
`,
})
@@ -175,12 +311,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 +340,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);
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; }
}
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"
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 }}}
+
+
+
+
+
+
+
+ `,
+})
+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.
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.