Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
681 changes: 681 additions & 0 deletions docs/superpowers/plans/2026-05-13-chat-lib-polish.md

Large diffs are not rendered by default.

156 changes: 156 additions & 0 deletions docs/superpowers/specs/2026-05-13-chat-lib-polish-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
# `@ngaf/chat` Library Polish — Design

**Date:** 2026-05-13
**Status:** Spec — pending implementation plan
**Spec series:** Second in a three-PR sequence (cockpit polish → chat lib polish → cockpit ↔ website style alignment)

## Goal

Tighten `@ngaf/chat`: fix the user-message-bubble text-wrap bug, eliminate the drift-prone duplicate token system (`chat.css` vs `chat-tokens.ts`), patch the a2ui surface namespace so it works in light mode, and close two small accessibility holes surfaced during the audit.

The audit pass for this PR turned up more than the user-visible bug — fixing them now gets the chat lib into the best state to receive PR 3's visual unification with the cockpit design tokens.

Out of scope:
- Visual palette unification with `--ds-*` design tokens — PR 3
- `image.component.ts` chained `[style.*]` bindings refactor — not a bug, deferred
- Any chat lib feature work beyond the listed fixes
- Migration of the broader chat lib API surface

## Decisions

| # | Decision | Choice |
|---|---|---|
| 1 | Text-wrap bug fix mechanism | `width: fit-content` on `.chat-message__bubble` to prevent flex-shrink below intrinsic content width |
| 2 | Regression test | None. Bubble width is a one-shot CSS fix, not a regression-prone surface. E2E coverage budget targets high-value scenarios elsewhere. |
| 3 | Token de-duplication direction | Drop `libs/chat/src/lib/styles/chat.css` entirely. `ensureChatRootStyles()` is the single source of truth. No backwards compat. |
| 4 | `--a2ui-*` namespace handling | Migrate the entire `--a2ui-*` block from `chat.css` into `chat-tokens.ts` so it's auto-injected. Add a `prefers-color-scheme: light` / `[data-theme="light"]` variant. |
| 5 | Hardcoded fallback color | Drop the `, #16a34a` fallback at `chat-message-actions.styles.ts:69`. `ensureChatRootStyles()` guarantees the var is defined. |
| 6 | `.chat-message__control-btn` focus | Add `:focus-visible` ring matching the existing `chat-message-actions.styles.ts:52` focus pattern (`outline: 2px solid var(--ngaf-chat-primary); outline-offset: 2px`) |
| 7 | `modal.component.ts` inline style | Move `style="display:contents"` to the styles array. Add explicit `aria-label` to the button-role element. |

## Architecture

**Token system after this PR:**

- **Sole source of truth: `chat-tokens.ts`** — exports CSS string constants and `ensureChatRootStyles()`, which appends `<style id="ngaf-chat-root-tokens">` to `<head>` on first chat-component construction, wrapped in `@layer ngaf-chat`. Consumer overrides at `:root` win via the `@layer` cascade.
- **`chat.css` deleted.** Examples that imported it remove the import line; tokens still resolve via the TS auto-injection path.

**`--a2ui-*` cascade (added to `chat-tokens.ts`):**

```css
@layer ngaf-chat {
:root {
/* dark-skewed defaults — preserve current production behavior */
--a2ui-surface: #1a1d23;
--a2ui-on-surface: #ffffff;
--a2ui-outline: rgba(255, 255, 255, 0.1);
/* ...rest of the block from chat.css... */
}

@media (prefers-color-scheme: light) {
:root:not([data-theme="dark"]) {
/* light counterpart */
--a2ui-surface: #ffffff;
--a2ui-on-surface: #1a1d23;
--a2ui-outline: rgba(0, 0, 0, 0.12);
/* ...rest of light values... */
}
}

[data-theme="light"] {
/* same light values — programmatic override */
}
}
```

The exact light hex values are picked to be readable counterparts of the dark values (not connected to `--ds-*` — that's PR 3). For surfaces: black on white; for elevations: black-with-low-alpha shadows; for outlines: black-with-low-alpha. Specifics in the implementation plan.

**Text wrap fix:**

```css
/* before */
.chat-message__bubble {
max-width: 80%;
/* ... */
overflow-wrap: break-word;
}

/* after */
.chat-message__bubble {
width: fit-content;
max-width: 80%;
/* ... */
overflow-wrap: break-word;
}
```

`width: fit-content` keeps the bubble sized to content up to `max-width`. `overflow-wrap: break-word` still handles overflow at word boundaries for long messages.

## Package changes

**`@ngaf/chat`:** patch bump
- Delete: `libs/chat/src/lib/styles/chat.css`
- Modify: `libs/chat/src/lib/styles/chat-tokens.ts` — absorb `--a2ui-*` token block from `chat.css`; add light variant
- Modify: `libs/chat/src/lib/styles/chat-message.styles.ts` — add `width: fit-content` to `.chat-message__bubble`; add `:focus-visible` ring to `.chat-message__control-btn`
- Modify: `libs/chat/src/lib/styles/chat-message-actions.styles.ts` — drop `#16a34a` fallback
- Modify: `libs/chat/src/lib/a2ui/catalog/modal.component.ts` — move inline `style="display:contents"` to styles array; add `aria-label`
- Modify: `libs/chat/src/lib/primitives/chat-message/chat-message.component.spec.ts` (or equivalent) — new regression test for single-word bubble width
- Modify: `libs/chat/package.json` — patch bump

**Consumers to migrate** (drop `@import '@ngaf/chat/chat.css'`):
- `examples/chat/angular/src/styles.css`
- `examples/chat/smoke/template/src/styles.css`

## `--a2ui-*` light variant values

Light counterpart to current dark values. Symmetric inversion of brightness, preserving brand-blue primary:

| Token | Dark (current) | Light (new) |
|---|---|---|
| `--a2ui-primary` | `#4f8df5` | `#4f8df5` (unchanged — brand blue works on both) |
| `--a2ui-on-primary` | `#ffffff` | `#ffffff` |
| `--a2ui-primary-hover` | `#6699f7` | `#3a78e0` |
| `--a2ui-secondary` | `#8a92a3` | `#5f6470` |
| `--a2ui-on-secondary` | `#ffffff` | `#ffffff` |
| `--a2ui-surface` | `#1a1d23` | `#ffffff` |
| `--a2ui-on-surface` | `#ffffff` | `#1a1d23` |
| `--a2ui-surface-variant` | `rgba(255,255,255,0.05)` | `rgba(0,0,0,0.04)` |
| `--a2ui-on-surface-variant` | `rgba(255,255,255,0.7)` | `rgba(0,0,0,0.6)` |
| `--a2ui-outline` | `rgba(255,255,255,0.1)` | `rgba(0,0,0,0.12)` |
| `--a2ui-outline-variant` | `rgba(255,255,255,0.05)` | `rgba(0,0,0,0.06)` |
| `--a2ui-error` | `#f5524f` | `#dc2626` |
| `--a2ui-on-error` | `#ffffff` | `#ffffff` |
| `--a2ui-scrim` | `rgba(0,0,0,0.6)` | `rgba(0,0,0,0.4)` |
| `--a2ui-elevation-1..5` | `0 1-16px rgba(0,0,0,0.3-0.5)` | `0 1-16px rgba(0,0,0,0.06-0.18)` (lighter shadows for light bg) |

Spacing, typography, shape, motion, focus-ring stay the same (theme-invariant).

## Testing

**Unit:**
- Existing chat lib tests run unchanged (92 spec files) — no behavior changes outside CSS
- No new regression test for the bubble width fix (one-shot CSS change, low future-regression surface; E2E budget reserved for higher-value scenarios)

**Visual smoke (chrome MCP):**
- Cockpit on 3000, timeline pilot on 4507
- Submit "hello" — bubble width should match text, not wrap
- Submit a long message — bubble respects `max-width: 80%`, wraps at word boundaries
- Toggle to light — chat lib palette flips; verify a2ui surfaces (in `chat/a2ui` capability examples) are not white-on-near-black
- Toggle back to dark — symmetry verified

**Consumer build verification:**
- `examples/chat/angular` and `examples/chat/smoke/template` build clean after removing the `@import`

## Risks and mitigations

- **Dropping `chat.css` is a breaking API change.** Mitigated by 0.0.x patch bump signal + we control all known consumers. Anyone importing `@ngaf/chat/chat.css` externally will get a resolve error; the fix is to drop the import (tokens resolve via TS auto-injection).
- **`width: fit-content` browser support.** Safari ≥14, Chrome ≥57, Firefox ≥94 (after `-moz-fit-content` fallback). All modern targets covered.
- **a2ui-* light variant changes visual** for anyone running cockpit/chat/a2ui examples in light mode. Currently broken (white-on-near-black); fix is strict improvement.
- **The `@layer ngaf-chat` priority** depends on consumers not using `@layer` higher in the cascade. If a consumer has their own unnamed-layer rule overriding `--ngaf-chat-*`, the named layer (`ngaf-chat`) loses. Acceptable — consumers who define their own layers know what they're doing.

## Out-of-scope follow-ups (track but defer)

- Visual unification with `--ds-*` palette — PR 3
- `image.component.ts` chained `[style.*]` bindings → single binding refactor
- `chat-message.component.spec.ts` broader layout test coverage (only adding single-word regression here)
- ARIA polish on other a2ui catalog components beyond modal (audit pass to be done if needed)
8 changes: 0 additions & 8 deletions examples/chat/angular/src/styles.css
Original file line number Diff line number Diff line change
@@ -1,11 +1,3 @@
/*
* Workspace-linked dev path into the @ngaf/chat library source.
* The published consumer (smoke template) imports `@ngaf/chat/chat.css`
* directly; here we point at the source CSS because the workspace
* doesn't pre-build the library to a dist folder during `nx serve`.
*/
@import '../../../../libs/chat/src/lib/styles/chat.css';

html, body {
margin: 0;
padding: 0;
Expand Down
7 changes: 0 additions & 7 deletions examples/chat/smoke/template/src/styles.css
Original file line number Diff line number Diff line change
@@ -1,10 +1,3 @@
/*
* Smoke consumer imports the published @ngaf/chat stylesheet directly.
* The workspace-linked demo (examples/chat/angular/) uses a relative
* path into the library source instead.
*/
@import '@ngaf/chat/chat.css';

html, body {
margin: 0;
padding: 0;
Expand Down
2 changes: 1 addition & 1 deletion libs/chat/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@ngaf/chat",
"version": "0.0.30",
"version": "0.0.31",
"exports": {
".": {
"types": "./index.d.ts",
Expand Down
14 changes: 12 additions & 2 deletions libs/chat/src/lib/a2ui/catalog/modal.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,15 @@ import { RenderElementComponent } from '@ngaf/render';
template: `
<!-- Entry point (trigger): always rendered inline, e.g. a button. -->
@if (entryPointKey(); as epKey) {
<div (click)="open.set(true)" (keydown.enter)="open.set(true)" (keydown.space)="open.set(true)"
role="button" tabindex="0" style="display:contents">
<div
class="a2ui-modal__trigger"
role="button"
tabindex="0"
aria-label="Open modal"
(click)="open.set(true)"
(keydown.enter)="open.set(true)"
(keydown.space)="open.set(true)"
>
<render-element [elementKey]="epKey" [spec]="spec()" />
</div>
}
Expand Down Expand Up @@ -44,6 +51,9 @@ import { RenderElementComponent } from '@ngaf/render';
}
`,
styles: [`
.a2ui-modal__trigger {
display: contents;
}
.a2ui-modal__overlay {
position: fixed;
inset: 0;
Expand Down
2 changes: 1 addition & 1 deletion libs/chat/src/lib/styles/chat-message-actions.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,6 @@ export const CHAT_MESSAGE_ACTIONS_STYLES = `
font-size: 14px;
font-weight: 700;
line-height: 1;
color: var(--ngaf-chat-success, #16a34a);
color: var(--ngaf-chat-success);
}
`;
6 changes: 6 additions & 0 deletions libs/chat/src/lib/styles/chat-message.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export const CHAT_MESSAGE_STYLES = `
:host([data-role="assistant"]):first-child { margin-top: 0; }

.chat-message__bubble {
width: fit-content;
max-width: 80%;
padding: 8px 12px;
border-radius: var(--ngaf-chat-radius-bubble);
Expand Down Expand Up @@ -92,5 +93,10 @@ export const CHAT_MESSAGE_STYLES = `
transition: transform 200ms ease;
}
.chat-message__control-btn:hover { transform: scale(1.05); }
.chat-message__control-btn:focus-visible {
outline: 2px solid var(--ngaf-chat-primary);
outline-offset: 2px;
border-radius: 4px;
}
.chat-message__control-btn svg { width: 16px; height: 16px; pointer-events: none; }
`;
109 changes: 109 additions & 0 deletions libs/chat/src/lib/styles/chat-tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,28 @@ const LIGHT_TOKENS = `
--ngaf-chat-shadow-sm: 0 1px 2px rgba(0,0,0,.05);
--ngaf-chat-shadow-md: 0 4px 6px -1px rgba(0,0,0,.10), 0 2px 4px -1px rgba(0,0,0,.06);
--ngaf-chat-shadow-lg: 0 10px 15px -3px rgba(0,0,0,.10), 0 4px 6px -2px rgba(0,0,0,.05);

/* --a2ui-* light variant */
--a2ui-primary: #4f8df5;
--a2ui-on-primary: #ffffff;
--a2ui-primary-hover: #3a78e0;
--a2ui-secondary: #5f6470;
--a2ui-on-secondary: #ffffff;
--a2ui-surface: #ffffff;
--a2ui-on-surface: #1a1d23;
--a2ui-surface-variant: rgba(0, 0, 0, 0.04);
--a2ui-on-surface-variant: rgba(0, 0, 0, 0.6);
--a2ui-outline: rgba(0, 0, 0, 0.12);
--a2ui-outline-variant: rgba(0, 0, 0, 0.06);
--a2ui-error: #dc2626;
--a2ui-on-error: #ffffff;
--a2ui-scrim: rgba(0, 0, 0, 0.4);
--a2ui-elevation-0: none;
--a2ui-elevation-1: 0 1px 2px rgba(0, 0, 0, 0.06);
--a2ui-elevation-2: 0 2px 4px rgba(0, 0, 0, 0.08);
--a2ui-elevation-3: 0 4px 8px rgba(0, 0, 0, 0.10);
--a2ui-elevation-4: 0 8px 16px rgba(0, 0, 0, 0.14);
--a2ui-elevation-5: 0 16px 32px rgba(0, 0, 0, 0.18);
`;

const DARK_TOKENS = `
Expand All @@ -40,6 +62,28 @@ const DARK_TOKENS = `
--ngaf-chat-warning-bg: rgb(45, 35, 21);
--ngaf-chat-warning-text: #fbbf24;
--ngaf-chat-success: #4ade80;

/* --a2ui-* dark variant (preserves current chat.css values) */
--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);
--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);
`;

const GEOMETRY_TOKENS = `
Expand Down Expand Up @@ -176,13 +220,78 @@ const REDUCED_MOTION_STYLES = `
* forces dark.
* - `[data-theme="light"]` forces light.
*/
const A2UI_INVARIANT_TOKENS = `
/* --a2ui-* theme-invariant tokens (spacing, typography, shape, motion, focus, aliases) */

/* 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);

/* 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);
`;

export const ROOT_TOKEN_STYLES = `
@layer ngaf-chat {
:root {
${LIGHT_TOKENS}
${GEOMETRY_TOKENS}
${TYPOGRAPHY_TOKENS}
${SPACING_TOKENS}
${A2UI_INVARIANT_TOKENS}
}
@media (prefers-color-scheme: dark) {
:root { ${DARK_TOKENS} }
Expand Down
Loading
Loading