From 8256b1251a78e4483ebf774063b0f81ac64785ff Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 6 Jul 2026 16:30:03 -0700 Subject: [PATCH 1/7] docs: render examples redesign follow-up plan (5 siblings) Co-Authored-By: Claude Opus 4.8 --- ...07-06-render-examples-redesign-followup.md | 409 ++++++++++++++++++ 1 file changed, 409 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-06-render-examples-redesign-followup.md diff --git a/docs/superpowers/plans/2026-07-06-render-examples-redesign-followup.md b/docs/superpowers/plans/2026-07-06-render-examples-redesign-followup.md new file mode 100644 index 000000000..0c031357a --- /dev/null +++ b/docs/superpowers/plans/2026-07-06-render-examples-redesign-followup.md @@ -0,0 +1,409 @@ +# Render Examples Redesign — Follow-up (5 siblings) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax. + +**Goal:** Apply the merged spec-rendering playback redesign to the other 5 `render/*` examples (element-rendering, registry, computed-functions, state-management, repeat-loops), eliminating Tailwind utilities in favor of encapsulated component CSS on `--ds-*` tokens, adding the syntax-colored JSON console, and styling each example's bespoke controls consistently. + +**Architecture:** The merged `cockpit/render/spec-rendering/angular/src/app/spec-rendering.component.ts` is the **exemplar** — reuse its scoped-class approach (`.bar/.tabs/.tab/.status/.cap/.json-pane/.json/.json__foot`, the dark JSON console, render-green accent, `sr-skeleton` loader, status pulse) verbatim in style. The shared `json-highlight` tokenizer moves to `cockpit/render/shared/` so all examples import it. Each example additionally gets a consistent **controls panel** style for its unique widgets. + +**Tech Stack:** Angular standalone + signals + control-flow, `@threadplane/render`, `@threadplane/design-tokens` (`--ds-*`), shared `StreamingSimulator`/`StreamingTimelineComponent`/`highlightJson`, Vitest. Nx build gate (`nx build --configuration=production`), no lint target for example apps. + +**Reference (read before starting):** +- Exemplar component: `cockpit/render/spec-rendering/angular/src/app/spec-rendering.component.ts` +- Exemplar styles.css: `cockpit/render/spec-rendering/angular/src/styles.css` (the `.sr-skeleton` loader + `--ds-*` body) +- Design spec: `docs/superpowers/specs/2026-07-06-spec-rendering-playback-redesign-design.md` + +--- + +## Shared conventions (apply in every example task) + +**Design tokens** — never hardcode canvas/surface/text/border colors; use `--ds-*` (theme-aware). Accent = render-green: `var(--ds-render-green)` for solid fills (light text on top), a local `#35b06a` for on-dark text/glows. The JSON console is pinned to a fixed dark surface (`#0b0b0b`) with a scoped `--code-fg/--code-muted/--code-border` palette, exactly as in the exemplar. + +**Per example, replace the component's `template:` + add `styles:` so that:** +1. `[header]` = `.bar` with `.bar__lbl` "Spec", `.tabs`/`.tab`/`.tab--on` segmented pills (render-green active), and a `.status` pulse (`.status__dot`/`--live`) bound to `simulator.playing()` — copy these rules from the exemplar. +2. `[primary]` = `.cap` "Live Render Output" + `` (unchanged bindings) or the placeholder. +3. `[secondary]` = `.json-pane` (dark console) with `.cap` "Streaming JSON", the syntax-colored `
` using `jsonTokens()` + `.json__cursor`, and `.json__foot` (state + percent). **Plus** the example's controls panel if it has one (see below).
+4. `[footer]` = `` (unchanged).
+5. Wire `protected readonly jsonTokens = computed(() => highlightJson(this.simulator.rawJson()));` and the `#jsonScroll` autoscroll `effect` — copy from the exemplar.
+6. Restyle the example's inline demo components (`demo-text/heading/card/badge/label/value`) with encapsulated `styles:` on `--ds-*`, using the global `.sr-skeleton` class for loaders — copy the exemplar's demo-component styles and adapt per component.
+7. **Zero Tailwind utility classes.** Only plain scoped class names remain.
+
+**The `
` whitespace rule** (critical, from the exemplar): the `
@for (tok of jsonTokens(); track $index) {{{ tok.text }}}
` line MUST stay on a single physical line with no stray whitespace between `>`/`@for`/`{`/``/`}`/cursor. + +**Controls panel CSS** (add to the `styles:` of the 3 examples that have controls — element-rendering, state-management, repeat-loops): + +```css +.controls { + flex-shrink: 0; + padding: 1rem; + border-top: 1px solid var(--ds-border, #2d2d2d); + background: var(--ds-surface, #1c1c1c); +} +.controls .cap { margin-bottom: 0.75rem; } +.control-row { display: flex; align-items: center; gap: 0.6rem; margin-bottom: 0.6rem; } +.control-row:last-child { margin-bottom: 0; } +.control-label { + font-size: 12px; + color: var(--ds-text-secondary, #c8c8c8); + min-width: 3rem; +} +.control-check { + width: 15px; height: 15px; + accent-color: var(--ds-render-green, #1a7a40); + cursor: pointer; +} +.control-input, +.control-select { + flex: 1; + min-width: 0; + padding: 5px 8px; + font-size: 12px; + color: var(--ds-text-primary, #f5f5f5); + background: var(--ds-surface-dim, #0a0a0a); + border: 1px solid var(--ds-border, #2d2d2d); + border-radius: var(--ds-radius-sm, 6px); +} +.control-input:focus, +.control-select:focus { + outline: none; + border-color: #35b06a; + box-shadow: 0 0 0 3px rgba(53, 176, 106, 0.25); +} +.control-btn { + padding: 6px 12px; + font-size: 12px; + font-weight: 600; + color: #eafff2; + background: var(--ds-render-green, #1a7a40); + border: 0; + border-radius: var(--ds-radius-sm, 6px); + cursor: pointer; + transition: box-shadow 0.15s ease, transform 0.1s ease; +} +.control-btn:hover { box-shadow: 0 2px 10px rgba(26, 122, 64, 0.5); } +.control-btn:active { transform: scale(0.97); } +.control-hint { + margin-top: 0.5rem; + font-size: 10.5px; + line-height: 1.5; + color: var(--ds-text-muted, #a0a0a0); +} +.list-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + margin-bottom: 4px; + padding: 5px 10px; + font-size: 12px; + color: var(--ds-text-secondary, #c8c8c8); + background: var(--ds-surface-dim, #0a0a0a); + border: 1px solid var(--ds-border, #2d2d2d); + border-radius: var(--ds-radius-sm, 6px); +} +.list-row__remove { + padding: 0 4px; + font-size: 13px; + line-height: 1; + color: var(--ds-text-muted, #a0a0a0); + background: none; + border: 0; + cursor: pointer; + transition: color 0.12s ease; +} +.list-row__remove:hover { color: #ef4444; } +``` + +Note: the secondary slot in a controls example is `display:flex; flex-direction:column` (from the split layout). Structure it as `.json-pane` (flex:1, scrolls) on top and `.controls` (flex-shrink:0) pinned below — matching the pre-existing structure, just restyled. + +--- + +## Task A: Move `json-highlight` to shared + +**Files:** +- Move: `cockpit/render/spec-rendering/angular/src/app/json-highlight.ts` → `cockpit/render/shared/json-highlight.ts` +- Move: `cockpit/render/spec-rendering/angular/src/app/json-highlight.spec.ts` → `cockpit/render/shared/json-highlight.spec.ts` +- Modify: `cockpit/render/spec-rendering/angular/src/app/spec-rendering.component.ts` (import path) + +- [ ] **Step 1: git mv both files** + +```bash +git mv cockpit/render/spec-rendering/angular/src/app/json-highlight.ts cockpit/render/shared/json-highlight.ts +git mv cockpit/render/spec-rendering/angular/src/app/json-highlight.spec.ts cockpit/render/shared/json-highlight.spec.ts +``` + +- [ ] **Step 2: Update spec-rendering's import** + +In `cockpit/render/spec-rendering/angular/src/app/spec-rendering.component.ts`, change: +`import { highlightJson } from './json-highlight';` +to: +`import { highlightJson } from '../../../../shared/json-highlight';` + +- [ ] **Step 3: Run the tests (new location)** + +Run: `npx vitest run cockpit/render/shared/json-highlight.spec.ts` +Expected: PASS (8 tests). + +- [ ] **Step 4: Rebuild spec-rendering to confirm the moved import resolves** + +Run: `npx nx build cockpit-render-spec-rendering-angular --configuration=production` +Expected: build succeeds. + +- [ ] **Step 5: Commit** + +```bash +git add -A +git commit -m "refactor(cockpit-render): move json-highlight tokenizer to render/shared for reuse" +``` + +--- + +## Task B: Redesign `registry` (no controls — validate the pattern) + +**Files:** +- Modify: `cockpit/render/registry/angular/src/app/registry.component.ts` +- Modify: `cockpit/render/registry/angular/src/styles.css` + +- [ ] **Step 1: Restyle the component** + +Follow "Shared conventions". `registry` has NO controls panel — secondary is JSON-only, structurally identical to the exemplar. Registry set: Text, Heading, Badge, Card. Import `highlightJson` from `../../../../shared/json-highlight`. Replace all Tailwind classes with the exemplar's scoped styles (bar/tabs/status/cap/json-pane/json/json__foot + demo-component styles for Text/Heading/Badge/Card — copy the exemplar's `demo-badge`/`demo-card`/`demo-heading`/`demo-text` styles). Add `jsonTokens` computed + `#jsonScroll` autoscroll effect. Keep the class body logic (specs, activeIndex, selectSpec, store, registry) intact — only template + styles + the jsonTokens/import change. + +- [ ] **Step 2: Update styles.css** + +Replace `cockpit/render/registry/angular/src/styles.css` with the exemplar's styles.css content (the `@import "@threadplane/example-layouts/theme.css";`, `--ds-*` body, `.sr-skeleton` + `@keyframes sr-shimmer`). + +- [ ] **Step 3: Exclude spec files from the app build (defensive; matches spec-rendering fix)** + +If `cockpit/render/registry/angular/tsconfig.app.json` lacks an `exclude` for spec files, add `"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]` after the `"include"` line. (Registry has no colocated specs today, but this keeps it consistent and safe.) + +- [ ] **Step 4: Tailwind-free grep gate** + +Run: `grep -nE 'class="[^"]*(px-|py-|text-\[|bg-\[|rounded-|gap-[0-9]|indigo|space-y-|tracking-w|shrink-|border-\[)' cockpit/render/registry/angular/src/app/registry.component.ts` +Expected: no output (exit 1). + +- [ ] **Step 5: Production build** + +Run: `npx nx build cockpit-render-registry-angular --configuration=production` +Expected: succeeds within budget. + +- [ ] **Step 6: Commit** + +```bash +git add cockpit/render/registry/angular/ +git commit -m "feat(cockpit-render): redesign registry example with encapsulated CSS + syntax-colored JSON" +``` + +--- + +## Task C: Redesign `computed-functions` (no controls) + +**Files:** +- Modify: `cockpit/render/computed-functions/angular/src/app/computed-functions.component.ts` +- Modify: `cockpit/render/computed-functions/angular/src/styles.css` + +- [ ] **Step 1: Restyle the component** + +Same as Task B. `computed-functions` has NO controls panel (JSON-only secondary). Registry set: Value, Heading, Card. Note the inline `demo-value` component renders a `label: value` row — style it as a mono `label / value` row on `--ds-*` (label muted, value `--ds-text-primary` mono). Reuse the exemplar's card/heading styles. Import `highlightJson` from `../../../../shared/json-highlight`. Add `jsonTokens` + autoscroll. Remove all Tailwind. + +- [ ] **Step 2: styles.css** — replace with the exemplar's styles.css (as Task B Step 2). + +- [ ] **Step 3: tsconfig spec-exclude** — as Task B Step 3, for `cockpit/render/computed-functions/angular/tsconfig.app.json`. + +- [ ] **Step 4: grep gate** — as Task B Step 4, for `computed-functions.component.ts`. + +- [ ] **Step 5: Production build** + +Run: `npx nx build cockpit-render-computed-functions-angular --configuration=production` +Expected: succeeds. + +- [ ] **Step 6: Commit** + +```bash +git add cockpit/render/computed-functions/angular/ +git commit -m "feat(cockpit-render): redesign computed-functions example with encapsulated CSS + syntax-colored JSON" +``` + +--- + +## Task D: Redesign `element-rendering` (+ checkbox control) + +**Files:** +- Modify: `cockpit/render/element-rendering/angular/src/app/element-rendering.component.ts` +- Modify: `cockpit/render/element-rendering/angular/src/styles.css` + +- [ ] **Step 1: Restyle the component + the controls panel** + +Follow "Shared conventions" + add the Controls panel CSS. `element-rendering`'s secondary slot has the JSON console **and** a pinned-bottom "Controls" panel containing a **"Show Detail" checkbox** (bound to `/showDetail` in the state store) plus a hint caption. Structure secondary as `.json-pane` (flex:1) + `.controls` (flex-shrink:0) below. Style the checkbox row: + +```html +
+
Controls
+ +

Toggles /showDetail in the state store. Elements with visible bindings react instantly.

+
+``` + +Keep the existing store↔signal bridge logic (the `store.subscribe` → `showDetail` signal and the toggle handler) intact — only restyle. Registry: Text, Heading, Card. Import `highlightJson` from shared, add `jsonTokens` + autoscroll. Remove all Tailwind (including the `bg-indigo-500` tab color → use `.tab--on` render-green). + +- [ ] **Step 2: styles.css** — replace with the exemplar's styles.css. + +- [ ] **Step 3: tsconfig spec-exclude** — for `cockpit/render/element-rendering/angular/tsconfig.app.json`. + +- [ ] **Step 4: grep gate** — as Task B, for `element-rendering.component.ts`. + +- [ ] **Step 5: Production build** + +Run: `npx nx build cockpit-render-element-rendering-angular --configuration=production` +Expected: succeeds. + +- [ ] **Step 6: Commit** + +```bash +git add cockpit/render/element-rendering/angular/ +git commit -m "feat(cockpit-render): redesign element-rendering example (encapsulated CSS, JSON console, styled Show-Detail control)" +``` + +--- + +## Task E: Redesign `state-management` (+ form controls) + +**Files:** +- Modify: `cockpit/render/state-management/angular/src/app/state-management.component.ts` +- Modify: `cockpit/render/state-management/angular/src/styles.css` + +- [ ] **Step 1: Restyle the component + the State Controls form** + +Follow "Shared conventions" + Controls panel CSS. Secondary = `.json-pane` + a pinned `.controls` "State Controls" panel with three rows bound to the store: **Name** (`.control-input` type=text → `/user/name`), **Age** (`.control-input` type=number → `/user/age`), **Theme** (`.control-select` with Dark/Light → `/settings/theme`). Use `.control-row` with a `.control-label` per field: + +```html +
+
State Controls
+
+ Name + +
+
+ Age + +
+
+ Theme + +
+
+``` + +Preserve the existing store read/write logic and method names (`getState`, and whatever setter the file uses — if it writes inline via `store.set(...)`, add a small `setState(path, value)` helper method or keep the inline expression the file already uses; do NOT invent new store APIs). Registry: Text, Heading, Label, Card — restyle `demo-label` as a mono `label: value` row. Import `highlightJson` from shared, add `jsonTokens` + autoscroll. Remove all Tailwind (indigo tab → render-green `.tab--on`). + +- [ ] **Step 2: styles.css** — replace with the exemplar's styles.css. + +- [ ] **Step 3: tsconfig spec-exclude** — for its `tsconfig.app.json`. + +- [ ] **Step 4: grep gate** — for `state-management.component.ts`. + +- [ ] **Step 5: Production build** + +Run: `npx nx build cockpit-render-state-management-angular --configuration=production` +Expected: succeeds. + +- [ ] **Step 6: Commit** + +```bash +git add cockpit/render/state-management/angular/ +git commit -m "feat(cockpit-render): redesign state-management example (encapsulated CSS, JSON console, styled state form)" +``` + +--- + +## Task F: Redesign `repeat-loops` (+ list-mutation controls) + +**Files:** +- Modify: `cockpit/render/repeat-loops/angular/src/app/repeat-loops.component.ts` +- Modify: `cockpit/render/repeat-loops/angular/src/styles.css` + +- [ ] **Step 1: Restyle the component + the List Controls** + +Follow "Shared conventions" + Controls panel CSS. Secondary = `.json-pane` + pinned `.controls` "List Controls" panel with an **"+ Add Item"** `.control-btn` (full-width; `(click)="addItem()"`) and a list of current `/items` rows, each `.list-row` with the item text + a `.list-row__remove` "×" button (`(click)="removeItem($index)"`): + +```html +
+
List Controls
+ +
+ @for (item of items(); track $index) { +
{{ item }}
+ } +
+

Mutates the /items array in the state store; the rendered list reconciles by key.

+
+``` + +Preserve the existing `addItem`/`removeItem`/`counter` logic and the `items()` accessor (however the file currently reads the array from the store — keep that). Registry: Text, Heading, Card. Import `highlightJson` from shared, add `jsonTokens` + autoscroll. Remove all Tailwind (indigo → render-green). + +- [ ] **Step 2: styles.css** — replace with the exemplar's styles.css. + +- [ ] **Step 3: tsconfig spec-exclude** — for its `tsconfig.app.json`. + +- [ ] **Step 4: grep gate** — for `repeat-loops.component.ts`. + +- [ ] **Step 5: Production build** + +Run: `npx nx build cockpit-render-repeat-loops-angular --configuration=production` +Expected: succeeds. + +- [ ] **Step 6: Commit** + +```bash +git add cockpit/render/repeat-loops/angular/ +git commit -m "feat(cockpit-render): redesign repeat-loops example (encapsulated CSS, JSON console, styled list controls)" +``` + +--- + +## Task G: Integration gate — build all + repo-wide grep + tests + +**Files:** none (verification only) + +- [ ] **Step 1: Build all 5 (+ spec-rendering)** + +Run: `npx nx run-many -t build --configuration=production -p cockpit-render-registry-angular cockpit-render-computed-functions-angular cockpit-render-element-rendering-angular cockpit-render-state-management-angular cockpit-render-repeat-loops-angular cockpit-render-spec-rendering-angular` +Expected: all succeed. + +- [ ] **Step 2: Repo-wide Tailwind-free assertion across all render example components** + +Run: +```bash +grep -REn 'class="[^"]*(px-[0-9]|py-[0-9]|text-\[|bg-\[|rounded-|gap-[0-9]|indigo|space-y-|tracking-w)' \ + cockpit/render/*/angular/src/app/*.component.ts +``` +Expected: no output (exit 1). + +- [ ] **Step 3: Highlighter tests (shared location)** + +Run: `npx vitest run cockpit/render/shared/json-highlight.spec.ts` +Expected: PASS. + +--- + +## Task H: Visual verification (orchestrator, Chrome MCP) + +**Files:** none. Serve each example (`npx nx serve --port `; ports in `cockpit/ports.mjs`: registry 4404, computed-functions 4406, element-rendering 4402, state-management 4403, repeat-loops 4405) and verify with Chrome MCP: +- Styled tabs (render-green active), syntax-colored JSON console, status pulse, demo components render, skeleton→content. +- **Controls** render and function: element-rendering checkbox toggles detail; state-management form writes to the store (JSON updates); repeat-loops add/remove mutates the list. +- Light + dark theme both legible (spot-check one). +- Capture screenshots of the 3 controls examples for user sign-off before PR. + +--- + +## Self-review + +- **Coverage:** all 5 examples restyled (B–F); shared highlighter move (A); controls styling for the 3 that need it (D/E/F); registry + computed-functions no-controls (B/C); build+grep+test gate (G); Chrome verify incl. controls + themes (H). +- **Consistency:** tab active-state unified to render-green `.tab--on` across all (was indigo in 3, themed-primary in 2); JSON console + `highlightJson` now uniform; controls CSS shared across D/E/F. +- **No placeholders:** control markup + CSS are concrete; per-example logic preserved (no invented store APIs). +- **Risk:** each example's store-wiring logic differs slightly — tasks say preserve existing method names/accessors and only restyle. Subagents must read the file first and keep behavior. From 8e76c327f1b7225f1ba57fc3cbcb6af868dabb41 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 6 Jul 2026 16:30:51 -0700 Subject: [PATCH 2/7] refactor(cockpit-render): move json-highlight tokenizer to render/shared for reuse --- .../angular/src/app => shared}/json-highlight.spec.ts | 0 .../angular/src/app => shared}/json-highlight.ts | 0 .../spec-rendering/angular/src/app/spec-rendering.component.ts | 2 +- 3 files changed, 1 insertion(+), 1 deletion(-) rename cockpit/render/{spec-rendering/angular/src/app => shared}/json-highlight.spec.ts (100%) rename cockpit/render/{spec-rendering/angular/src/app => shared}/json-highlight.ts (100%) diff --git a/cockpit/render/spec-rendering/angular/src/app/json-highlight.spec.ts b/cockpit/render/shared/json-highlight.spec.ts similarity index 100% rename from cockpit/render/spec-rendering/angular/src/app/json-highlight.spec.ts rename to cockpit/render/shared/json-highlight.spec.ts diff --git a/cockpit/render/spec-rendering/angular/src/app/json-highlight.ts b/cockpit/render/shared/json-highlight.ts similarity index 100% rename from cockpit/render/spec-rendering/angular/src/app/json-highlight.ts rename to cockpit/render/shared/json-highlight.ts 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 dbe9024a3..11a4afb02 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,7 +11,7 @@ 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'; +import { highlightJson } from '../../../../shared/json-highlight'; // --- Inline view components registered in the demo registry --- From c14139a9ba77e94d2be4f02d7c7a6955c70b34b1 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 6 Jul 2026 16:32:48 -0700 Subject: [PATCH 3/7] feat(cockpit-render): redesign registry example with encapsulated CSS + syntax-colored JSON --- .../angular/src/app/registry.component.ts | 247 ++++++++++++++---- .../render/registry/angular/src/styles.css | 31 ++- .../render/registry/angular/tsconfig.app.json | 1 + 3 files changed, 214 insertions(+), 65 deletions(-) diff --git a/cockpit/render/registry/angular/src/app/registry.component.ts b/cockpit/render/registry/angular/src/app/registry.component.ts index 7ee59d8e5..44b6b2941 100644 --- a/cockpit/render/registry/angular/src/app/registry.component.ts +++ b/cockpit/render/registry/angular/src/app/registry.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 { REGISTRY_SPECS } from './specs'; +import { highlightJson } from '../../../../shared/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,13 +73,20 @@ class DemoHeadingComponent { @Component({ selector: 'demo-badge', standalone: true, - template: ` - @if (label()) { - {{ label() }} - } @else if (loading()) { -
+ 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(''); @@ -99,23 +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()) { -
-
-
-
+
+
}
`, @@ -133,46 +148,160 @@ class DemoCardComponent { selector: 'app-registry', 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 }}}
+
+ {{ streamStateLabel() }} + {{ percent() }}%
- - + + `, }) @@ -182,12 +311,14 @@ export class RegistryComponent 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; @@ -209,6 +340,16 @@ export class RegistryComponent 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/registry/angular/src/styles.css b/cockpit/render/registry/angular/src/styles.css index 2485ff8d0..46d364b7f 100644 --- a/cockpit/render/registry/angular/src/styles.css +++ b/cockpit/render/registry/angular/src/styles.css @@ -1,21 +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); } -/* Skeleton shimmer for streaming render skeletons */ -@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/registry/angular/tsconfig.app.json b/cockpit/render/registry/angular/tsconfig.app.json index c3eee2239..77c8da2a1 100644 --- a/cockpit/render/registry/angular/tsconfig.app.json +++ b/cockpit/render/registry/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 fff45a7ea91fe1e7cd3cd40d6e9c6f3dfaaa5d52 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 6 Jul 2026 16:34:38 -0700 Subject: [PATCH 4/7] feat(cockpit-render): redesign computed-functions example with encapsulated CSS + syntax-colored JSON --- .../src/app/computed-functions.component.ts | 250 ++++++++++++++---- .../computed-functions/angular/src/styles.css | 31 ++- .../angular/tsconfig.app.json | 1 + 3 files changed, 217 insertions(+), 65 deletions(-) diff --git a/cockpit/render/computed-functions/angular/src/app/computed-functions.component.ts b/cockpit/render/computed-functions/angular/src/app/computed-functions.component.ts index c3c1cf9e0..3df88f7e3 100644 --- a/cockpit/render/computed-functions/angular/src/app/computed-functions.component.ts +++ b/cockpit/render/computed-functions/angular/src/app/computed-functions.component.ts @@ -11,23 +11,37 @@ import { StreamingSimulator } from '../../../../shared/streaming-simulator'; import { StreamingTimelineComponent } from '../../../../shared/streaming-timeline.component'; import { ExampleSplitLayoutComponent } from '@threadplane/example-layouts'; import { COMPUTED_FUNCTIONS_SPECS } from './specs'; +import { highlightJson } from '../../../../shared/json-highlight'; -// --- Inline view components --- +// --- Inline view components registered in the demo registry --- @Component({ selector: 'demo-value', standalone: true, + styles: ` + .row { display: flex; align-items: center; gap: 0.5rem; padding: 4px 0; } + .row__label { + min-width: 120px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + color: var(--ds-text-muted, #a0a0a0); + } + .row__value { + font-family: var(--ds-font-mono, ui-monospace, monospace); + font-size: 13px; + color: var(--ds-text-primary, #f5f5f5); + } + `, template: ` @if (label() || value()) { -
- {{ label() }}: - {{ value() }} +
+ {{ label() }}: + {{ value() }}
} @else if (loading()) { -
-
-
-
+
+
} `, }) @@ -45,21 +59,16 @@ class DemoValueComponent { 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 { @@ -79,23 +88,36 @@ class DemoHeadingComponent { 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()) { -
-
-
-
+
+
}
`, @@ -113,46 +135,160 @@ class DemoCardComponent { selector: 'app-computed-functions', 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 }}}
+
+ {{ streamStateLabel() }} + {{ percent() }}%
- - + + `, }) @@ -162,12 +298,14 @@ export class ComputedFunctionsComponent 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; @@ -188,6 +326,16 @@ export class ComputedFunctionsComponent 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/computed-functions/angular/src/styles.css b/cockpit/render/computed-functions/angular/src/styles.css index fe9890c13..46d364b7f 100644 --- a/cockpit/render/computed-functions/angular/src/styles.css +++ b/cockpit/render/computed-functions/angular/src/styles.css @@ -1,25 +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; } -} - -.skeleton-shimmer { +/* Shared skeleton loader for streamed-but-incomplete demo elements. */ +.sr-skeleton { + border-radius: 5px; background: linear-gradient( 90deg, - transparent 0%, - var(--tplane-chat-surface-alt) 50%, - transparent 100% + var(--ds-surface-tinted, #2c2c2c) 0%, + rgba(90, 90, 90, 0.55) 50%, + var(--ds-surface-tinted, #2c2c2c) 100% ); background-size: 200% 100%; - animation: shimmer 1.5s ease-in-out infinite; + animation: sr-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/computed-functions/angular/tsconfig.app.json b/cockpit/render/computed-functions/angular/tsconfig.app.json index c3eee2239..77c8da2a1 100644 --- a/cockpit/render/computed-functions/angular/tsconfig.app.json +++ b/cockpit/render/computed-functions/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 1710adc91025b335bb25192e968076ee866b8c81 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 6 Jul 2026 16:36:38 -0700 Subject: [PATCH 5/7] feat(cockpit-render): redesign element-rendering example (encapsulated CSS, JSON console, styled Show-Detail control) --- .../src/app/element-rendering.component.ts | 331 ++++++++++++++---- .../element-rendering/angular/src/styles.css | 30 +- .../angular/tsconfig.app.json | 1 + 3 files changed, 288 insertions(+), 74 deletions(-) diff --git a/cockpit/render/element-rendering/angular/src/app/element-rendering.component.ts b/cockpit/render/element-rendering/angular/src/app/element-rendering.component.ts index d774ee5ec..d1d7079de 100644 --- a/cockpit/render/element-rendering/angular/src/app/element-rendering.component.ts +++ b/cockpit/render/element-rendering/angular/src/app/element-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 { ELEMENT_RENDERING_SPECS } from './specs'; +import { highlightJson } from '../../../../shared/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 { @@ -79,23 +74,36 @@ class DemoHeadingComponent { 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()) { -
-
-
-
+
+
}
`, @@ -113,63 +121,248 @@ class DemoCardComponent { selector: 'app-element-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; } + .controls { + flex-shrink: 0; + padding: 1rem; + border-top: 1px solid var(--ds-border, #2d2d2d); + background: var(--ds-surface, #1c1c1c); + } + .controls .cap { margin-bottom: 0.75rem; } + .control-row { display: flex; align-items: center; gap: 0.6rem; margin-bottom: 0.6rem; } + .control-row:last-child { margin-bottom: 0; } + .control-label { + font-size: 12px; + color: var(--ds-text-secondary, #c8c8c8); + min-width: 3rem; + } + .control-check { + width: 15px; height: 15px; + accent-color: var(--ds-render-green, #1a7a40); + cursor: pointer; + } + .control-input, + .control-select { + flex: 1; + min-width: 0; + padding: 5px 8px; + font-size: 12px; + color: var(--ds-text-primary, #f5f5f5); + background: var(--ds-surface-dim, #0a0a0a); + border: 1px solid var(--ds-border, #2d2d2d); + border-radius: var(--ds-radius-sm, 6px); + } + .control-input:focus, + .control-select:focus { + outline: none; + border-color: #35b06a; + box-shadow: 0 0 0 3px rgba(53, 176, 106, 0.25); + } + .control-btn { + padding: 6px 12px; + font-size: 12px; + font-weight: 600; + color: #eafff2; + background: var(--ds-render-green, #1a7a40); + border: 0; + border-radius: var(--ds-radius-sm, 6px); + cursor: pointer; + transition: box-shadow 0.15s ease, transform 0.1s ease; + } + .control-btn:hover { box-shadow: 0 2px 10px rgba(26, 122, 64, 0.5); } + .control-btn:active { transform: scale(0.97); } + .control-hint { + margin-top: 0.5rem; + font-size: 10.5px; + line-height: 1.5; + color: var(--ds-text-muted, #a0a0a0); + } + .list-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + margin-bottom: 4px; + padding: 5px 10px; + font-size: 12px; + color: var(--ds-text-secondary, #c8c8c8); + background: var(--ds-surface-dim, #0a0a0a); + border: 1px solid var(--ds-border, #2d2d2d); + border-radius: var(--ds-radius-sm, 6px); + } + .list-row__remove { + padding: 0 4px; + font-size: 13px; + line-height: 1; + color: var(--ds-text-muted, #a0a0a0); + background: none; + border: 0; + cursor: pointer; + transition: color 0.12s ease; + } + .list-row__remove:hover { color: #ef4444; } + `, 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() }}%
- -
-
Controls
-
- -

- Toggles /showDetail in the state store. Elements with visible: bind react instantly. -

-
+
+
Controls
+ +

Toggles /showDetail in the state store. Elements with visible bindings react instantly.

- - + + `, }) @@ -179,7 +372,9 @@ export class ElementRenderingComponent 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'); protected readonly registry = defineAngularRegistry({ Text: DemoTextComponent, @@ -201,7 +396,7 @@ export class ElementRenderingComponent implements OnDestroy { // Auto-scroll JSON pane effect(() => { this.simulator.rawJson(); - const el = this.jsonPane()?.nativeElement; + const el = this.jsonScroll()?.nativeElement; if (el) { requestAnimationFrame(() => { el.scrollTop = el.scrollHeight; @@ -210,7 +405,7 @@ export class ElementRenderingComponent implements OnDestroy { }); } - protected toggleShowDetail(): void { + protected onToggleDetail(_event: Event): void { const current = this.showDetail(); this.store.set('/showDetail', !current); this.showDetail.set(!current); @@ -220,6 +415,16 @@ export class ElementRenderingComponent 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/element-rendering/angular/src/styles.css b/cockpit/render/element-rendering/angular/src/styles.css index f578f9fae..46d364b7f 100644 --- a/cockpit/render/element-rendering/angular/src/styles.css +++ b/cockpit/render/element-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/element-rendering/angular/tsconfig.app.json b/cockpit/render/element-rendering/angular/tsconfig.app.json index c3eee2239..77c8da2a1 100644 --- a/cockpit/render/element-rendering/angular/tsconfig.app.json +++ b/cockpit/render/element-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 bf489ca9fb9d3e6866a433fb444e2c520c96e990 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 6 Jul 2026 16:38:33 -0700 Subject: [PATCH 6/7] feat(cockpit-render): redesign state-management example (encapsulated CSS, JSON console, styled state form) Co-Authored-By: Claude Opus 4.8 --- .../src/app/state-management.component.ts | 364 +++++++++++++----- .../state-management/angular/src/styles.css | 30 +- .../angular/tsconfig.app.json | 1 + 3 files changed, 298 insertions(+), 97 deletions(-) diff --git a/cockpit/render/state-management/angular/src/app/state-management.component.ts b/cockpit/render/state-management/angular/src/app/state-management.component.ts index 227b868a1..2f455aebe 100644 --- a/cockpit/render/state-management/angular/src/app/state-management.component.ts +++ b/cockpit/render/state-management/angular/src/app/state-management.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 { STATE_MANAGEMENT_SPECS } from './specs'; +import { highlightJson } from '../../../../shared/json-highlight'; -// --- Inline view components --- +// --- 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,17 +73,29 @@ class DemoHeadingComponent { @Component({ selector: 'demo-label', standalone: true, + styles: ` + .row { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; } + .row__label { + font-size: 11px; + text-transform: uppercase; + font-weight: 600; + color: var(--ds-text-muted, #a0a0a0); + } + .row__value { + font-size: 13px; + font-family: var(--ds-font-mono, ui-monospace, monospace); + color: var(--ds-text-primary, #f5f5f5); + } + `, template: ` @if (label() || value()) { -
- {{ label() }}: - {{ value() }} +
+ {{ label() }}: + {{ value() }}
} @else if (loading()) { -
-
-
-
+
+
} `, }) @@ -106,23 +113,36 @@ class DemoLabelComponent { 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()) { -
-
-
-
+
+
}
`, @@ -140,79 +160,235 @@ class DemoCardComponent { selector: 'app-state-management', 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; } + .controls { + flex-shrink: 0; + padding: 1rem; + border-top: 1px solid var(--ds-border, #2d2d2d); + background: var(--ds-surface, #1c1c1c); + } + .controls .cap { margin-bottom: 0.75rem; } + .control-row { display: flex; align-items: center; gap: 0.6rem; margin-bottom: 0.6rem; } + .control-row:last-child { margin-bottom: 0; } + .control-label { + font-size: 12px; + color: var(--ds-text-secondary, #c8c8c8); + min-width: 3rem; + } + .control-check { + width: 15px; height: 15px; + accent-color: var(--ds-render-green, #1a7a40); + cursor: pointer; + } + .control-input, + .control-select { + flex: 1; + min-width: 0; + padding: 5px 8px; + font-size: 12px; + color: var(--ds-text-primary, #f5f5f5); + background: var(--ds-surface-dim, #0a0a0a); + border: 1px solid var(--ds-border, #2d2d2d); + border-radius: var(--ds-radius-sm, 6px); + } + .control-input:focus, + .control-select:focus { + outline: none; + border-color: #35b06a; + box-shadow: 0 0 0 3px rgba(53, 176, 106, 0.25); + } + .control-btn { + padding: 6px 12px; + font-size: 12px; + font-weight: 600; + color: #eafff2; + background: var(--ds-render-green, #1a7a40); + border: 0; + border-radius: var(--ds-radius-sm, 6px); + cursor: pointer; + transition: box-shadow 0.15s ease, transform 0.1s ease; + } + .control-btn:hover { box-shadow: 0 2px 10px rgba(26, 122, 64, 0.5); } + .control-btn:active { transform: scale(0.97); } + .control-hint { + margin-top: 0.5rem; + font-size: 10.5px; + line-height: 1.5; + color: var(--ds-text-muted, #a0a0a0); + } + `, 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() }}%
- -
-
State Controls
-
-
- - -
-
- - -
-
- - -
-

- Edit values to update the state store. Rendered elements with $state bindings react. -

+
+
State Controls
+
+ Name + +
+
+ Age + +
+
+ Theme +
+

Edits update the state store live. Elements bound via $state react instantly.

- - + + `, }) @@ -222,12 +398,14 @@ export class StateManagementComponent 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; @@ -249,10 +427,24 @@ export class StateManagementComponent implements OnDestroy { return this.store.get(path); } + protected setState(path: string, value: unknown): void { + this.store.set(path, value); + } + 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); diff --git a/cockpit/render/state-management/angular/src/styles.css b/cockpit/render/state-management/angular/src/styles.css index f578f9fae..46d364b7f 100644 --- a/cockpit/render/state-management/angular/src/styles.css +++ b/cockpit/render/state-management/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/state-management/angular/tsconfig.app.json b/cockpit/render/state-management/angular/tsconfig.app.json index c3eee2239..77c8da2a1 100644 --- a/cockpit/render/state-management/angular/tsconfig.app.json +++ b/cockpit/render/state-management/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 fd2d9e4dfbcfcdfdda66afcd94783da2dda856f9 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 6 Jul 2026 16:42:48 -0700 Subject: [PATCH 7/7] feat(cockpit-render): redesign repeat-loops example (encapsulated CSS, JSON console, styled list controls) --- .../angular/src/app/repeat-loops.component.ts | 338 ++++++++++++++---- .../repeat-loops/angular/src/styles.css | 30 +- .../repeat-loops/angular/tsconfig.app.json | 1 + 3 files changed, 288 insertions(+), 81 deletions(-) diff --git a/cockpit/render/repeat-loops/angular/src/app/repeat-loops.component.ts b/cockpit/render/repeat-loops/angular/src/app/repeat-loops.component.ts index 155b4a530..e589655ea 100644 --- a/cockpit/render/repeat-loops/angular/src/app/repeat-loops.component.ts +++ b/cockpit/render/repeat-loops/angular/src/app/repeat-loops.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 { REPEAT_LOOPS_SPECS } from './specs'; +import { highlightJson } from '../../../../shared/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 { @@ -79,23 +74,36 @@ class DemoHeadingComponent { 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()) { -
-
-
-
+
+
}
`, @@ -113,72 +121,250 @@ class DemoCardComponent { selector: 'app-repeat-loops', 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; } + .controls { + flex-shrink: 0; + padding: 1rem; + border-top: 1px solid var(--ds-border, #2d2d2d); + background: var(--ds-surface, #1c1c1c); + } + .controls .cap { margin-bottom: 0.75rem; } + .control-row { display: flex; align-items: center; gap: 0.6rem; margin-bottom: 0.6rem; } + .control-row:last-child { margin-bottom: 0; } + .control-label { + font-size: 12px; + color: var(--ds-text-secondary, #c8c8c8); + min-width: 3rem; + } + .control-check { + width: 15px; height: 15px; + accent-color: var(--ds-render-green, #1a7a40); + cursor: pointer; + } + .control-input, + .control-select { + flex: 1; + min-width: 0; + padding: 5px 8px; + font-size: 12px; + color: var(--ds-text-primary, #f5f5f5); + background: var(--ds-surface-dim, #0a0a0a); + border: 1px solid var(--ds-border, #2d2d2d); + border-radius: var(--ds-radius-sm, 6px); + } + .control-input:focus, + .control-select:focus { + outline: none; + border-color: #35b06a; + box-shadow: 0 0 0 3px rgba(53, 176, 106, 0.25); + } + .control-btn { + padding: 6px 12px; + font-size: 12px; + font-weight: 600; + color: #eafff2; + background: var(--ds-render-green, #1a7a40); + border: 0; + border-radius: var(--ds-radius-sm, 6px); + cursor: pointer; + transition: box-shadow 0.15s ease, transform 0.1s ease; + } + .control-btn:hover { box-shadow: 0 2px 10px rgba(26, 122, 64, 0.5); } + .control-btn:active { transform: scale(0.97); } + .control-hint { + margin-top: 0.5rem; + font-size: 10.5px; + line-height: 1.5; + color: var(--ds-text-muted, #a0a0a0); + } + .list-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + margin-bottom: 4px; + padding: 5px 10px; + font-size: 12px; + color: var(--ds-text-secondary, #c8c8c8); + background: var(--ds-surface-dim, #0a0a0a); + border: 1px solid var(--ds-border, #2d2d2d); + border-radius: var(--ds-radius-sm, 6px); + } + .list-row__remove { + padding: 0 4px; + font-size: 13px; + line-height: 1; + color: var(--ds-text-muted, #a0a0a0); + background: none; + border: 0; + cursor: pointer; + transition: color 0.12s ease; + } + .list-row__remove:hover { color: #ef4444; } + `, 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() }}%
- -
-
List Controls
-
- - @if (getItems().length) { -
- @for (item of getItems(); track $index) { -
- {{ item }} - -
- } -
+
+
List Controls
+ +
+ @for (item of getItems(); track $index) { +
{{ item }}
} -

- Modify /items array in the store. -

+

Mutates the /items array in the state store; the rendered list reconciles by key.

- - + + `, }) @@ -188,12 +374,14 @@ export class RepeatLoopsComponent 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; @@ -231,6 +419,16 @@ export class RepeatLoopsComponent 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/repeat-loops/angular/src/styles.css b/cockpit/render/repeat-loops/angular/src/styles.css index f578f9fae..46d364b7f 100644 --- a/cockpit/render/repeat-loops/angular/src/styles.css +++ b/cockpit/render/repeat-loops/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/repeat-loops/angular/tsconfig.app.json b/cockpit/render/repeat-loops/angular/tsconfig.app.json index c3eee2239..77c8da2a1 100644 --- a/cockpit/render/repeat-loops/angular/tsconfig.app.json +++ b/cockpit/render/repeat-loops/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"