From 8783741a6ea50eb31627ca693b19a8438ba9d539 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 6 Jul 2026 22:34:46 -0700 Subject: [PATCH 1/3] fix(cockpit-render): render non-string element content (numeric $state bindings) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Demo Text/Heading components coerced content with `typeof c === 'string' ? c : ''`, silently dropping numbers — e.g. state-management's User Profile bound /user/age (30) to a Text and rendered blank at completion. Add a shared, tested toDisplayText() helper that stringifies primitives (number/boolean/bigint), keeps '' for null/objects, and use it across all render example demo components. Co-Authored-By: Claude Opus 4.8 --- .../src/app/computed-functions.component.ts | 6 ++-- .../src/app/element-rendering.component.ts | 11 ++----- .../angular/src/app/registry.component.ts | 11 ++----- .../angular/src/app/repeat-loops.component.ts | 11 ++----- cockpit/render/shared/to-display-text.spec.ts | 33 +++++++++++++++++++ cockpit/render/shared/to-display-text.ts | 19 +++++++++++ .../src/app/spec-rendering.component.ts | 11 ++----- .../src/app/state-management.component.ts | 11 ++----- 8 files changed, 69 insertions(+), 44 deletions(-) create mode 100644 cockpit/render/shared/to-display-text.spec.ts create mode 100644 cockpit/render/shared/to-display-text.ts 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 3df88f7e3..df2dc6254 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 @@ -12,6 +12,7 @@ import { StreamingTimelineComponent } from '../../../../shared/streaming-timelin import { ExampleSplitLayoutComponent } from '@threadplane/example-layouts'; import { COMPUTED_FUNCTIONS_SPECS } from './specs'; import { highlightJson } from '../../../../shared/json-highlight'; +import { toDisplayText } from '../../../../shared/to-display-text'; // --- Inline view components registered in the demo registry --- @@ -73,10 +74,7 @@ class DemoValueComponent { }) class DemoHeadingComponent { readonly content = input(''); - readonly displayContent = computed(() => { - const c = this.content(); - return typeof c === 'string' ? c : ''; - }); + readonly displayContent = computed(() => toDisplayText(this.content())); readonly childKeys = input([]); readonly spec = input(null); readonly bindings = input>({}); 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 d1d7079de..687d8c25d 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 @@ -12,6 +12,7 @@ import { StreamingTimelineComponent } from '../../../../shared/streaming-timelin import { ExampleSplitLayoutComponent } from '@threadplane/example-layouts'; import { ELEMENT_RENDERING_SPECS } from './specs'; import { highlightJson } from '../../../../shared/json-highlight'; +import { toDisplayText } from '../../../../shared/to-display-text'; // --- Inline view components registered in the demo registry --- @@ -30,10 +31,7 @@ import { highlightJson } from '../../../../shared/json-highlight'; }) class DemoTextComponent { readonly content = input(''); - readonly displayContent = computed(() => { - const c = this.content(); - return typeof c === 'string' ? c : ''; - }); + readonly displayContent = computed(() => toDisplayText(this.content())); readonly childKeys = input([]); readonly spec = input(null); readonly bindings = input>({}); @@ -59,10 +57,7 @@ class DemoTextComponent { }) class DemoHeadingComponent { readonly content = input(''); - readonly displayContent = computed(() => { - const c = this.content(); - return typeof c === 'string' ? c : ''; - }); + readonly displayContent = computed(() => toDisplayText(this.content())); readonly childKeys = input([]); readonly spec = input(null); readonly bindings = input>({}); diff --git a/cockpit/render/registry/angular/src/app/registry.component.ts b/cockpit/render/registry/angular/src/app/registry.component.ts index 44b6b2941..9b589fabf 100644 --- a/cockpit/render/registry/angular/src/app/registry.component.ts +++ b/cockpit/render/registry/angular/src/app/registry.component.ts @@ -12,6 +12,7 @@ import { StreamingTimelineComponent } from '../../../../shared/streaming-timelin import { ExampleSplitLayoutComponent } from '@threadplane/example-layouts'; import { REGISTRY_SPECS } from './specs'; import { highlightJson } from '../../../../shared/json-highlight'; +import { toDisplayText } from '../../../../shared/to-display-text'; // --- Inline view components registered in the demo registry --- @@ -30,10 +31,7 @@ import { highlightJson } from '../../../../shared/json-highlight'; }) class DemoTextComponent { readonly content = input(''); - readonly displayContent = computed(() => { - const c = this.content(); - return typeof c === 'string' ? c : ''; - }); + readonly displayContent = computed(() => toDisplayText(this.content())); readonly childKeys = input([]); readonly spec = input(null); readonly bindings = input>({}); @@ -59,10 +57,7 @@ class DemoTextComponent { }) class DemoHeadingComponent { readonly content = input(''); - readonly displayContent = computed(() => { - const c = this.content(); - return typeof c === 'string' ? c : ''; - }); + readonly displayContent = computed(() => toDisplayText(this.content())); readonly childKeys = input([]); readonly spec = input(null); readonly bindings = input>({}); 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 e589655ea..836b1f150 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 @@ -12,6 +12,7 @@ import { StreamingTimelineComponent } from '../../../../shared/streaming-timelin import { ExampleSplitLayoutComponent } from '@threadplane/example-layouts'; import { REPEAT_LOOPS_SPECS } from './specs'; import { highlightJson } from '../../../../shared/json-highlight'; +import { toDisplayText } from '../../../../shared/to-display-text'; // --- Inline view components registered in the demo registry --- @@ -30,10 +31,7 @@ import { highlightJson } from '../../../../shared/json-highlight'; }) class DemoTextComponent { readonly content = input(''); - readonly displayContent = computed(() => { - const c = this.content(); - return typeof c === 'string' ? c : ''; - }); + readonly displayContent = computed(() => toDisplayText(this.content())); readonly childKeys = input([]); readonly spec = input(null); readonly bindings = input>({}); @@ -59,10 +57,7 @@ class DemoTextComponent { }) class DemoHeadingComponent { readonly content = input(''); - readonly displayContent = computed(() => { - const c = this.content(); - return typeof c === 'string' ? c : ''; - }); + readonly displayContent = computed(() => toDisplayText(this.content())); readonly childKeys = input([]); readonly spec = input(null); readonly bindings = input>({}); diff --git a/cockpit/render/shared/to-display-text.spec.ts b/cockpit/render/shared/to-display-text.spec.ts new file mode 100644 index 000000000..089642890 --- /dev/null +++ b/cockpit/render/shared/to-display-text.spec.ts @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MIT +import { toDisplayText } from './to-display-text'; + +describe('toDisplayText', () => { + it('returns strings unchanged', () => { + expect(toDisplayText('Alice')).toBe('Alice'); + expect(toDisplayText('')).toBe(''); + }); + + it('stringifies numbers (the bug: numeric $state bindings were dropped)', () => { + expect(toDisplayText(30)).toBe('30'); + expect(toDisplayText(42.5)).toBe('42.5'); + }); + + it('preserves zero (falsy but must display)', () => { + expect(toDisplayText(0)).toBe('0'); + }); + + it('stringifies booleans', () => { + expect(toDisplayText(true)).toBe('true'); + expect(toDisplayText(false)).toBe('false'); + }); + + it('returns empty string for null / undefined', () => { + expect(toDisplayText(null)).toBe(''); + expect(toDisplayText(undefined)).toBe(''); + }); + + it('returns empty string for objects and arrays (not display text)', () => { + expect(toDisplayText({ a: 1 })).toBe(''); + expect(toDisplayText([1, 2])).toBe(''); + }); +}); diff --git a/cockpit/render/shared/to-display-text.ts b/cockpit/render/shared/to-display-text.ts new file mode 100644 index 000000000..fca7b5a1f --- /dev/null +++ b/cockpit/render/shared/to-display-text.ts @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: MIT + +/** + * Coerce a resolved element prop (which may be a string, number, boolean, or — + * for an unresolved/object binding — something else) into display text. + * + * Demo view components bind props like `content`/`value` that can resolve to + * non-string primitives via `$state`/`$fn` bindings (e.g. a numeric + * `/user/age`). Returning `''` only for strings silently dropped those values; + * this renders any primitive and treats objects/null as "no text". + */ +export function toDisplayText(value: unknown): string { + if (value == null) return ''; + const t = typeof value; + if (t === 'string' || t === 'number' || t === 'boolean' || t === 'bigint') { + return String(value); + } + return ''; +} 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 11a4afb02..18c4845b4 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 @@ -12,6 +12,7 @@ import { StreamingTimelineComponent } from '../../../../shared/streaming-timelin import { ExampleSplitLayoutComponent } from '@threadplane/example-layouts'; import { SPEC_RENDERING_SPECS } from './specs'; import { highlightJson } from '../../../../shared/json-highlight'; +import { toDisplayText } from '../../../../shared/to-display-text'; // --- Inline view components registered in the demo registry --- @@ -30,10 +31,7 @@ import { highlightJson } from '../../../../shared/json-highlight'; }) class DemoTextComponent { readonly content = input(''); - readonly displayContent = computed(() => { - const c = this.content(); - return typeof c === 'string' ? c : ''; - }); + readonly displayContent = computed(() => toDisplayText(this.content())); readonly childKeys = input([]); readonly spec = input(null); readonly bindings = input>({}); @@ -59,10 +57,7 @@ class DemoTextComponent { }) class DemoHeadingComponent { readonly content = input(''); - readonly displayContent = computed(() => { - const c = this.content(); - return typeof c === 'string' ? c : ''; - }); + readonly displayContent = computed(() => toDisplayText(this.content())); readonly childKeys = input([]); readonly spec = input(null); readonly bindings = input>({}); 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 2f455aebe..333d513b0 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 @@ -12,6 +12,7 @@ import { StreamingTimelineComponent } from '../../../../shared/streaming-timelin import { ExampleSplitLayoutComponent } from '@threadplane/example-layouts'; import { STATE_MANAGEMENT_SPECS } from './specs'; import { highlightJson } from '../../../../shared/json-highlight'; +import { toDisplayText } from '../../../../shared/to-display-text'; // --- Inline view components registered in the demo registry --- @@ -30,10 +31,7 @@ import { highlightJson } from '../../../../shared/json-highlight'; }) class DemoTextComponent { readonly content = input(''); - readonly displayContent = computed(() => { - const c = this.content(); - return typeof c === 'string' ? c : ''; - }); + readonly displayContent = computed(() => toDisplayText(this.content())); readonly childKeys = input([]); readonly spec = input(null); readonly bindings = input>({}); @@ -59,10 +57,7 @@ class DemoTextComponent { }) class DemoHeadingComponent { readonly content = input(''); - readonly displayContent = computed(() => { - const c = this.content(); - return typeof c === 'string' ? c : ''; - }); + readonly displayContent = computed(() => toDisplayText(this.content())); readonly childKeys = input([]); readonly spec = input(null); readonly bindings = input>({}); From 612740b4bf7868897bb8ebb0aa6e53109f5f6c90 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 6 Jul 2026 22:34:47 -0700 Subject: [PATCH 2/3] fix(cockpit-render): remove scrubber handle transition so it tracks progress exactly The timeline handle had `transition: left 0.075s linear` while the fill had none, so the handle continuously lagged the fill during rAF playback and visibly animated on seek/spec-switch jumps. Drop the transition; the handle now snaps to position each frame, matching the fill. Co-Authored-By: Claude Opus 4.8 --- cockpit/render/shared/streaming-timeline.component.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/cockpit/render/shared/streaming-timeline.component.ts b/cockpit/render/shared/streaming-timeline.component.ts index 09ed7a18d..933a9fde7 100644 --- a/cockpit/render/shared/streaming-timeline.component.ts +++ b/cockpit/render/shared/streaming-timeline.component.ts @@ -60,7 +60,6 @@ import { StreamingSimulator } from './streaming-simulator'; 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; From 561859d7565bc15d8f8d0f7bf6c8f1ca49844f31 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 6 Jul 2026 22:47:02 -0700 Subject: [PATCH 3/3] fix(cockpit-render): computed-functions specs use $computed, not $fn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The specs bound values with { $fn: 'uppercase', args: {...} }, but the render library's computed-function binding key is $computed ({ $computed: name, args }). $fn is unrecognized, so the raw binding object was passed through and rendered as [object Object]. Rename $fn → $computed across the three specs; the registered functions (provideRender) and args shape were already correct. Co-Authored-By: Claude Opus 4.8 --- .../computed-functions/angular/src/app/specs.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/cockpit/render/computed-functions/angular/src/app/specs.ts b/cockpit/render/computed-functions/angular/src/app/specs.ts index 2049e89de..af3216fe9 100644 --- a/cockpit/render/computed-functions/angular/src/app/specs.ts +++ b/cockpit/render/computed-functions/angular/src/app/specs.ts @@ -16,14 +16,14 @@ export const COMPUTED_FUNCTIONS_SPECS: DemoSpec[] = [ type: 'Value', props: { label: 'Uppercase', - value: { $fn: 'uppercase', args: { value: 'hello world' } }, + value: { $computed: 'uppercase', args: { value: 'hello world' } }, }, }, reversed: { type: 'Value', props: { label: 'Reversed', - value: { $fn: 'reverse', args: { value: 'streaming' } }, + value: { $computed: 'reverse', args: { value: 'streaming' } }, }, }, }, @@ -43,14 +43,14 @@ export const COMPUTED_FUNCTIONS_SPECS: DemoSpec[] = [ type: 'Value', props: { label: 'Formatted Date', - value: { $fn: 'formatDate', args: { value: '2024-06-15T12:00:00Z' } }, + value: { $computed: 'formatDate', args: { value: '2024-06-15T12:00:00Z' } }, }, }, product: { type: 'Value', props: { label: 'Multiply 7 x 6', - value: { $fn: 'multiply', args: { a: 7, b: 6 } }, + value: { $computed: 'multiply', args: { a: 7, b: 6 } }, }, }, }, @@ -70,21 +70,21 @@ export const COMPUTED_FUNCTIONS_SPECS: DemoSpec[] = [ type: 'Value', props: { label: 'Multiply 12 x 5', - value: { $fn: 'multiply', args: { a: 12, b: 5 } }, + value: { $computed: 'multiply', args: { a: 12, b: 5 } }, }, }, transform: { type: 'Value', props: { label: 'Uppercase', - value: { $fn: 'uppercase', args: { value: 'computed functions' } }, + value: { $computed: 'uppercase', args: { value: 'computed functions' } }, }, }, format: { type: 'Value', props: { label: 'Date', - value: { $fn: 'formatDate', args: { value: '2025-01-01T00:00:00Z' } }, + value: { $computed: 'formatDate', args: { value: '2025-01-01T00:00:00Z' } }, }, }, },