diff --git a/apps/website/content/docs/chat/api/api-docs.json b/apps/website/content/docs/chat/api/api-docs.json index 69626bcf3..0f4904792 100644 --- a/apps/website/content/docs/chat/api/api-docs.json +++ b/apps/website/content/docs/chat/api/api-docs.json @@ -30,6 +30,12 @@ "description": "", "optional": false }, + { + "name": "description", + "type": "InputSignal", + "description": "v1 canonical prop: short description / title rendered above the player.", + "optional": false + }, { "name": "emit", "type": "InputSignal", @@ -178,7 +184,7 @@ { "name": "checked", "type": "InputSignal", - "description": "", + "description": "Pre-v1 alias retained for back-compat.", "optional": false }, { @@ -187,6 +193,12 @@ "description": "", "optional": false }, + { + "name": "effectiveValue", + "type": "Signal", + "description": "", + "optional": false + }, { "name": "emit", "type": "InputSignal", @@ -210,6 +222,12 @@ "type": "InputSignal", "description": "", "optional": false + }, + { + "name": "value", + "type": "InputSignal", + "description": "v1 canonical prop: boolean checked state.", + "optional": false } ], "methods": [ @@ -471,6 +489,12 @@ "description": "", "optional": false }, + { + "name": "effectiveName", + "type": "Signal", + "description": "", + "optional": false + }, { "name": "emit", "type": "InputSignal", @@ -480,7 +504,7 @@ { "name": "icon", "type": "InputSignal", - "description": "v1 prop name: icon (resolved string, e.g. a Unicode symbol or ligature name).", + "description": "Pre-v1 alias retained for back-compat.", "optional": false }, { @@ -489,6 +513,12 @@ "description": "", "optional": false }, + { + "name": "name", + "type": "InputSignal", + "description": "v1 canonical prop.", + "optional": false + }, { "name": "size", "type": "InputSignal", @@ -535,6 +565,12 @@ "description": "", "optional": false }, + { + "name": "fit", + "type": "InputSignal", + "description": "v1 prop: CSS object-fit equivalent.", + "optional": false + }, { "name": "height", "type": "InputSignal", @@ -559,6 +595,12 @@ "description": "", "optional": false }, + { + "name": "usageHint", + "type": "InputSignal", + "description": "v1 prop: sizing preset.", + "optional": false + }, { "name": "width", "type": "InputSignal", @@ -566,7 +608,26 @@ "optional": false } ], - "methods": [] + "methods": [ + { + "name": "explicitHeight", + "signature": "explicitHeight()", + "description": "", + "params": [] + }, + { + "name": "explicitWidth", + "signature": "explicitWidth()", + "description": "", + "params": [] + }, + { + "name": "hintStyle", + "signature": "hintStyle()", + "description": "", + "params": [] + } + ] }, { "name": "A2uiListComponent", @@ -575,6 +636,18 @@ "params": [], "examples": [], "properties": [ + { + "name": "alignment", + "type": "InputSignal<\"center\" | \"start\" | \"end\" | \"stretch\" | undefined>", + "description": "v1 canonical prop: cross-axis alignment.", + "optional": false + }, + { + "name": "alignmentCss", + "type": "Signal", + "description": "", + "optional": false + }, { "name": "bindings", "type": "InputSignal>", diff --git a/examples/chat/angular/src/app/modes/welcome-suggestions.ts b/examples/chat/angular/src/app/modes/welcome-suggestions.ts index c8412a32c..14eecd6a4 100644 --- a/examples/chat/angular/src/app/modes/welcome-suggestions.ts +++ b/examples/chat/angular/src/app/modes/welcome-suggestions.ts @@ -74,4 +74,14 @@ export const WELCOME_SUGGESTIONS: readonly WelcomeSuggestion[] = [ value: 'Render a booking surface: a heading "Book your trip", a DateTimeInput for travel date, a horizontal divider, then a Row containing two Cards (one for departure city, one for return city) each with a TextField. Below the Row add a primary "Continue" Button whose action opens a Modal containing a confirmation Column with a summary Text and Confirm / Cancel Buttons.', }, + { + label: 'Smoke: media + layout kitchen sink', + value: + 'Render a Card containing a Tabs component with two tabs labeled "Media" and "Layout". Under the Media tab show a Column containing: a header Image (use https://placehold.co/600x300/4f8df5/ffffff.png as the URL), an Icon (any icon name from the canonical set, e.g. star), a short Text caption, an AudioPlayer (use https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3 as the URL), and a Video (use https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4 as the URL). Under the Layout tab show: a Row containing two Text components separated by a vertical Divider, then a horizontal Divider, then a List of three Text bullet items, then a Column containing two Text components.', + }, + { + label: 'Smoke: interactive form kitchen sink', + value: + 'Render a Card titled "Profile setup" containing a Column with: a TextField for display name, a Slider for "experience years" (range 0-30), a CheckBox for "subscribe to newsletter", a DateTimeInput for birthday (date only), a MultipleChoice for "favorite frameworks" with options Angular, React, Vue, Svelte and maxAllowedSelections of 3 (multi-select), a horizontal Divider, a Row containing a primary "Save" Button and a secondary "Open details" Button whose action opens a Modal with a Column containing a Text summary and a Close Button.', + }, ]; diff --git a/libs/chat/src/lib/a2ui/catalog/audio-player.component.ts b/libs/chat/src/lib/a2ui/catalog/audio-player.component.ts index 9c1ab5ae7..4b4647d7e 100644 --- a/libs/chat/src/lib/a2ui/catalog/audio-player.component.ts +++ b/libs/chat/src/lib/a2ui/catalog/audio-player.component.ts @@ -6,14 +6,28 @@ import type { Spec } from '@json-render/core'; selector: 'a2ui-audio-player', standalone: true, template: ` - +
+ @if (description()) { + {{ description() }} + } + +
`, styles: [` + .a2ui-audio-wrap { + display: flex; + flex-direction: column; + gap: var(--a2ui-spacing-1); + } + .a2ui-audio-description { + font-size: var(--a2ui-typography-caption-size); + color: var(--a2ui-on-surface-variant); + } .a2ui-audio { display: block; width: 100%; @@ -22,6 +36,8 @@ import type { Spec } from '@json-render/core'; }) export class A2uiAudioPlayerComponent { readonly url = input(''); + /** v1 canonical prop: short description / title rendered above the player. */ + readonly description = input(''); /** v1 prop name: autoPlay (camelCase). */ readonly autoPlay = input(false); readonly controls = input(true); diff --git a/libs/chat/src/lib/a2ui/catalog/check-box.component.ts b/libs/chat/src/lib/a2ui/catalog/check-box.component.ts index 3df6c9774..879b6f6fd 100644 --- a/libs/chat/src/lib/a2ui/catalog/check-box.component.ts +++ b/libs/chat/src/lib/a2ui/catalog/check-box.component.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -import { Component, input, ChangeDetectionStrategy } from '@angular/core'; +import { Component, computed, input, ChangeDetectionStrategy } from '@angular/core'; import type { Spec } from '@json-render/core'; import { emitBinding } from './emit-binding'; @@ -9,7 +9,7 @@ import { emitBinding } from './emit-binding'; changeDetection: ChangeDetectionStrategy.OnPush, template: ` `, @@ -32,6 +32,9 @@ import { emitBinding } from './emit-binding'; }) export class A2uiCheckBoxComponent { readonly label = input(''); + /** v1 canonical prop: boolean checked state. */ + readonly value = input(undefined); + /** Pre-v1 alias retained for back-compat. */ readonly checked = input(false); readonly _bindings = input>({}); readonly emit = input<(event: string) => void>(() => { /* noop */ }); @@ -41,8 +44,17 @@ export class A2uiCheckBoxComponent { readonly childKeys = input([]); readonly spec = input(undefined); + protected readonly effectiveValue = computed(() => this.value() ?? this.checked()); + onChange(event: Event): void { const val = (event.target as HTMLInputElement).checked; - emitBinding(this.emit(), this._bindings(), 'checked', val); + // Emit on whichever binding the surface declared. v1 surfaces use + // `value`; pre-v1 used `checked`. + const bound = this._bindings(); + if (bound['value']) { + emitBinding(this.emit(), bound, 'value', val); + } else { + emitBinding(this.emit(), bound, 'checked', val); + } } } diff --git a/libs/chat/src/lib/a2ui/catalog/icon.component.ts b/libs/chat/src/lib/a2ui/catalog/icon.component.ts index 0a1b5e263..5beb417e7 100644 --- a/libs/chat/src/lib/a2ui/catalog/icon.component.ts +++ b/libs/chat/src/lib/a2ui/catalog/icon.component.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -import { Component, input } from '@angular/core'; +import { Component, computed, input } from '@angular/core'; import type { Spec } from '@json-render/core'; @Component({ @@ -9,7 +9,8 @@ import type { Spec } from '@json-render/core'; {{ icon() }} + [attr.aria-label]="effectiveName()" + >{{ effectiveName() }} `, styles: [` .a2ui-icon { @@ -21,7 +22,9 @@ import type { Spec } from '@json-render/core'; `], }) export class A2uiIconComponent { - /** v1 prop name: icon (resolved string, e.g. a Unicode symbol or ligature name). */ + /** v1 canonical prop. */ + readonly name = input(undefined); + /** Pre-v1 alias retained for back-compat. */ readonly icon = input(''); readonly size = input(null); // Framework inputs required by the render harness. @@ -30,4 +33,6 @@ export class A2uiIconComponent { readonly loading = input(false); readonly childKeys = input([]); readonly spec = input(undefined); + + protected readonly effectiveName = computed(() => this.name() ?? this.icon()); } diff --git a/libs/chat/src/lib/a2ui/catalog/image.component.ts b/libs/chat/src/lib/a2ui/catalog/image.component.ts index a5f906a64..c4aab0662 100644 --- a/libs/chat/src/lib/a2ui/catalog/image.component.ts +++ b/libs/chat/src/lib/a2ui/catalog/image.component.ts @@ -2,6 +2,23 @@ import { Component, input } from '@angular/core'; import type { Spec } from '@json-render/core'; +/** v1 fit values mapped 1:1 to CSS object-fit. */ +type ImageFit = 'contain' | 'cover' | 'fill' | 'none' | 'scale-down'; + +/** v1 usageHint maps to a sizing preset. The component renders fluid by + * default; usageHint sets a max-width / aspect-ratio to match common + * intents. */ +type ImageUsageHint = 'icon' | 'avatar' | 'smallFeature' | 'mediumFeature' | 'largeFeature' | 'header'; + +const USAGE_HINT_STYLE: Record = { + icon: { maxWidth: '24px', aspectRatio: '1 / 1' }, + avatar: { maxWidth: '48px', aspectRatio: '1 / 1', borderRadius: '50%' }, + smallFeature: { maxWidth: '160px' }, + mediumFeature: { maxWidth: '320px' }, + largeFeature: { maxWidth: '480px' }, + header: { maxWidth: '100%', aspectRatio: '16 / 5' }, +}; + @Component({ selector: 'a2ui-image', standalone: true, @@ -10,8 +27,12 @@ import type { Spec } from '@json-render/core'; class="a2ui-img" [src]="url()" [alt]="alt()" - [style.width]="width() ? width() + 'px' : null" - [style.height]="height() ? height() + 'px' : null" + [style.width]="explicitWidth()" + [style.height]="explicitHeight()" + [style.object-fit]="fit()" + [style.max-width]="hintStyle()?.maxWidth" + [style.aspect-ratio]="hintStyle()?.aspectRatio || null" + [style.border-radius]="hintStyle()?.borderRadius || null" /> `, styles: [` @@ -27,10 +48,25 @@ export class A2uiImageComponent { readonly alt = input(''); readonly width = input(null); readonly height = input(null); + /** v1 prop: CSS object-fit equivalent. */ + readonly fit = input(undefined); + /** v1 prop: sizing preset. */ + readonly usageHint = input(undefined); // Framework inputs required by the render harness. readonly bindings = input>({}); readonly emit = input<(event: string) => void>(() => { /* noop */ }); readonly loading = input(false); readonly childKeys = input([]); readonly spec = input(undefined); + + protected explicitWidth(): string | null { + return this.width() != null ? this.width() + 'px' : null; + } + protected explicitHeight(): string | null { + return this.height() != null ? this.height() + 'px' : null; + } + protected hintStyle(): { maxWidth: string; aspectRatio?: string; borderRadius?: string } | null { + const h = this.usageHint(); + return h ? USAGE_HINT_STYLE[h] : null; + } } diff --git a/libs/chat/src/lib/a2ui/catalog/list.component.ts b/libs/chat/src/lib/a2ui/catalog/list.component.ts index cb9043d88..0cec43308 100644 --- a/libs/chat/src/lib/a2ui/catalog/list.component.ts +++ b/libs/chat/src/lib/a2ui/catalog/list.component.ts @@ -8,7 +8,7 @@ import { RenderElementComponent } from '@ngaf/render'; standalone: true, imports: [RenderElementComponent], template: ` -
+
@for (key of childKeys(); track key) { } @@ -34,6 +34,8 @@ export class A2uiListComponent { readonly childKeys = input([]); readonly spec = input.required(); readonly direction = input<'vertical' | 'horizontal'>('vertical'); + /** v1 canonical prop: cross-axis alignment. */ + readonly alignment = input<'start' | 'center' | 'end' | 'stretch' | undefined>(undefined); // Framework inputs required by the render harness. readonly bindings = input>({}); readonly emit = input<(event: string) => void>(() => { /* noop */ }); @@ -44,4 +46,12 @@ export class A2uiListComponent { ? 'a2ui-list--horizontal' : 'a2ui-list--vertical'; }); + + protected readonly alignmentCss = computed(() => { + const a = this.alignment(); + if (!a) return null; + return a === 'start' ? 'flex-start' + : a === 'end' ? 'flex-end' + : a; // center / stretch are valid CSS values as-is + }); } diff --git a/libs/chat/src/lib/a2ui/surface.component.ts b/libs/chat/src/lib/a2ui/surface.component.ts index b18934524..c2bf0c059 100644 --- a/libs/chat/src/lib/a2ui/surface.component.ts +++ b/libs/chat/src/lib/a2ui/surface.component.ts @@ -21,102 +21,12 @@ import { buildA2uiActionMessage } from './build-action-message'; '[style.--a2ui-primary]': 'primaryColor()', '[style.font-family]': 'fontFamily()', }, - styles: [` - :host { - /* === Spacing scale (4px base) === */ - --a2ui-spacing-1: 4px; - --a2ui-spacing-2: 8px; - --a2ui-spacing-3: 12px; - --a2ui-spacing-4: 16px; - --a2ui-spacing-5: 24px; - --a2ui-spacing-6: 32px; - --a2ui-spacing-7: 40px; - - /* === Typography (per Text usageHint) === */ - /* h1 — display heading */ - --a2ui-typography-h1-size: 32px; - --a2ui-typography-h1-weight: 700; - --a2ui-typography-h1-line-height: 1.2; - /* h2 — section heading */ - --a2ui-typography-h2-size: 24px; - --a2ui-typography-h2-weight: 600; - --a2ui-typography-h2-line-height: 1.3; - /* h3 — subsection heading */ - --a2ui-typography-h3-size: 20px; - --a2ui-typography-h3-weight: 600; - --a2ui-typography-h3-line-height: 1.3; - /* h4 */ - --a2ui-typography-h4-size: 18px; - --a2ui-typography-h4-weight: 500; - --a2ui-typography-h4-line-height: 1.4; - /* h5 */ - --a2ui-typography-h5-size: 16px; - --a2ui-typography-h5-weight: 500; - --a2ui-typography-h5-line-height: 1.4; - /* body */ - --a2ui-typography-body-size: 14px; - --a2ui-typography-body-weight: 400; - --a2ui-typography-body-line-height: 1.5; - /* caption */ - --a2ui-typography-caption-size: 12px; - --a2ui-typography-caption-weight: 400; - --a2ui-typography-caption-line-height: 1.4; - /* label (used by TextField/Slider/etc. labels) */ - --a2ui-typography-label-size: 12px; - --a2ui-typography-label-weight: 500; - - /* === Shape radius === */ - --a2ui-shape-extra-small: 4px; - --a2ui-shape-small: 8px; - --a2ui-shape-medium: 12px; - --a2ui-shape-large: 16px; - --a2ui-shape-extra-large: 28px; - - /* === Focus ring === */ - --a2ui-focus-ring-color: var(--a2ui-primary); - --a2ui-focus-ring-width: 2px; - - /* === Motion === */ - --a2ui-motion-duration-short: 100ms; - --a2ui-motion-duration-medium: 200ms; - --a2ui-motion-duration-long: 300ms; - --a2ui-motion-easing-standard: cubic-bezier(0.2, 0, 0, 1); - --a2ui-motion-easing-emphasized: cubic-bezier(0.2, 0, 0, 1.4); - - /* === Elevation (box-shadow) === */ - --a2ui-elevation-0: none; - --a2ui-elevation-1: 0 1px 2px rgba(0, 0, 0, 0.3); - --a2ui-elevation-2: 0 2px 4px rgba(0, 0, 0, 0.35); - --a2ui-elevation-3: 0 4px 8px rgba(0, 0, 0, 0.4); - --a2ui-elevation-4: 0 8px 16px rgba(0, 0, 0, 0.45); - --a2ui-elevation-5: 0 16px 32px rgba(0, 0, 0, 0.5); - - /* === Color === */ - /* (--a2ui-primary is set by host binding from beginRendering.styles, default below) */ - --a2ui-primary: #4f8df5; - --a2ui-on-primary: #ffffff; - --a2ui-primary-hover: #6699f7; - --a2ui-secondary: #8a92a3; - --a2ui-on-secondary: #ffffff; - --a2ui-surface: #1a1d23; - --a2ui-on-surface: #ffffff; - --a2ui-surface-variant: rgba(255, 255, 255, 0.05); - --a2ui-on-surface-variant: rgba(255, 255, 255, 0.7); - --a2ui-outline: rgba(255, 255, 255, 0.1); - --a2ui-outline-variant: rgba(255, 255, 255, 0.05); - --a2ui-error: #f5524f; - --a2ui-on-error: #ffffff; - --a2ui-scrim: rgba(0, 0, 0, 0.6); - - /* === Carry-over from existing tokens (kept for back-compat) === */ - --a2ui-card-bg: var(--a2ui-surface); - --a2ui-input-bg: var(--a2ui-surface-variant); - --a2ui-input-text: var(--a2ui-on-surface); - --a2ui-label: var(--a2ui-on-surface-variant); - --a2ui-caption: var(--a2ui-on-surface-variant); - --a2ui-border: var(--a2ui-outline); - } - `], + // Token defaults live in chat.css at :root so :root[data-theme=...] + // preset overrides take precedence (specificity: same selector + a + // matching attribute beats bare :root by ~1 specificity unit). The + // host bindings above override --a2ui-primary per-surface for the + // agent's beginRendering.styles.primaryColor (highest specificity: + // inline style). template: ` @if (spec(); as s) { . The surface component's + * agent-binding still wins per-surface (inline style on the host + * element from beginRendering.styles.primaryColor). + */ + + /* Spacing scale (4px base) */ + --a2ui-spacing-1: 4px; + --a2ui-spacing-2: 8px; + --a2ui-spacing-3: 12px; + --a2ui-spacing-4: 16px; + --a2ui-spacing-5: 24px; + --a2ui-spacing-6: 32px; + --a2ui-spacing-7: 40px; + + /* Typography (per Text usageHint) */ + --a2ui-typography-h1-size: 32px; + --a2ui-typography-h1-weight: 700; + --a2ui-typography-h1-line-height: 1.2; + --a2ui-typography-h2-size: 24px; + --a2ui-typography-h2-weight: 600; + --a2ui-typography-h2-line-height: 1.3; + --a2ui-typography-h3-size: 20px; + --a2ui-typography-h3-weight: 600; + --a2ui-typography-h3-line-height: 1.3; + --a2ui-typography-h4-size: 18px; + --a2ui-typography-h4-weight: 500; + --a2ui-typography-h4-line-height: 1.4; + --a2ui-typography-h5-size: 16px; + --a2ui-typography-h5-weight: 500; + --a2ui-typography-h5-line-height: 1.4; + --a2ui-typography-body-size: 14px; + --a2ui-typography-body-weight: 400; + --a2ui-typography-body-line-height: 1.5; + --a2ui-typography-caption-size: 12px; + --a2ui-typography-caption-weight: 400; + --a2ui-typography-caption-line-height: 1.4; + --a2ui-typography-label-size: 12px; + --a2ui-typography-label-weight: 500; + + /* Shape radius */ + --a2ui-shape-extra-small: 4px; + --a2ui-shape-small: 8px; + --a2ui-shape-medium: 12px; + --a2ui-shape-large: 16px; + --a2ui-shape-extra-large: 28px; + + /* Focus ring */ + --a2ui-focus-ring-color: var(--a2ui-primary); + --a2ui-focus-ring-width: 2px; + + /* Motion */ + --a2ui-motion-duration-short: 100ms; + --a2ui-motion-duration-medium: 200ms; + --a2ui-motion-duration-long: 300ms; + --a2ui-motion-easing-standard: cubic-bezier(0.2, 0, 0, 1); + --a2ui-motion-easing-emphasized: cubic-bezier(0.2, 0, 0, 1.4); + + /* Elevation (box-shadow) */ + --a2ui-elevation-0: none; + --a2ui-elevation-1: 0 1px 2px rgba(0, 0, 0, 0.3); + --a2ui-elevation-2: 0 2px 4px rgba(0, 0, 0, 0.35); + --a2ui-elevation-3: 0 4px 8px rgba(0, 0, 0, 0.4); + --a2ui-elevation-4: 0 8px 16px rgba(0, 0, 0, 0.45); + --a2ui-elevation-5: 0 16px 32px rgba(0, 0, 0, 0.5); + + /* Color (--a2ui-primary is also set by host binding from + * beginRendering.styles.primaryColor for per-surface override) */ + --a2ui-primary: #4f8df5; + --a2ui-on-primary: #ffffff; + --a2ui-primary-hover: #6699f7; + --a2ui-secondary: #8a92a3; + --a2ui-on-secondary: #ffffff; + --a2ui-surface: #1a1d23; + --a2ui-on-surface: #ffffff; + --a2ui-surface-variant: rgba(255, 255, 255, 0.05); + --a2ui-on-surface-variant: rgba(255, 255, 255, 0.7); + --a2ui-outline: rgba(255, 255, 255, 0.1); + --a2ui-outline-variant: rgba(255, 255, 255, 0.05); + --a2ui-error: #f5524f; + --a2ui-on-error: #ffffff; + --a2ui-scrim: rgba(0, 0, 0, 0.6); + + /* Aliases (kept for back-compat) */ + --a2ui-card-bg: var(--a2ui-surface); + --a2ui-input-bg: var(--a2ui-surface-variant); + --a2ui-input-text: var(--a2ui-on-surface); + --a2ui-label: var(--a2ui-on-surface-variant); + --a2ui-caption: var(--a2ui-on-surface-variant); + --a2ui-border: var(--a2ui-outline); } @media (prefers-color-scheme: dark) {