From 53bf77682c5dead16427d570bf5a669f1df0efce Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Mon, 12 Oct 2026 03:00:31 +0000 Subject: [PATCH] fix(native-chat): one set of rules for chat text size, colour, hover and focus (#26685) * fix(native-chat): put chat text on one type scale that follows Text size Sizes were picked per component, so the same kind of row came out 12, 13 or 14px and the Text size setting reached only a few of them. * fix(native-chat): scale chat spacing and icons with Text size Icons and gaps stayed fixed while text grew, so at larger sizes they looked undersized and sat off-centre. * fix(native-chat): implement PR #26685 review fixes - Scale row gap with text size so controls don't overlap - Clamp 'Match terminal interface' to 12-20 for labels/controls - Use existing --status-warning token for warning color - Rewrite stale test for scaled typography tokens - Use app colors for in-progress tasks in composer dock * fix(native-chat): leave the task list's dock colour as it was * fix(native-chat): message text follows the terminal size; controls and spacing stay in 12-20 * test(native-chat): labels and spacing follow the bounded chat size; main's new notice test uses the chat colour --------- Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> --- .oxlintrc.json | 17 ++++- AGENTS.md | 2 +- .../native-chat-design-tokens.mjs | 49 ++++++++++++++ .../native-chat-design-tokens-plugin.test.mjs | 66 +++++++++++++++++++ docs/STYLEGUIDE.md | 23 +++++++ src/renderer/src/assets/main.css | 26 ++++++++ .../NativeChatAgentMessageSenders.tsx | 2 +- .../native-chat/NativeChatApprovalCard.tsx | 10 +-- .../NativeChatAssistantMessageRow.tsx | 3 +- .../NativeChatAutocompleteMenus.tsx | 10 +-- .../NativeChatAwaitingInputRow.tsx | 17 +++-- .../NativeChatBackgroundTaskRun.tsx | 2 +- .../NativeChatBackgroundTasksStatus.tsx | 8 +-- .../native-chat/NativeChatCodeBlock.tsx | 2 +- .../native-chat/NativeChatComposerField.tsx | 2 +- .../native-chat/NativeChatComposerNotices.tsx | 2 +- .../NativeChatContextUsageRing.tsx | 2 +- .../native-chat/NativeChatDiffCard.tsx | 10 +-- .../native-chat/NativeChatDiffView.tsx | 8 +-- .../native-chat/NativeChatEmptyState.tsx | 4 +- .../native-chat/NativeChatInlineVisual.tsx | 2 +- .../native-chat/NativeChatLoadingCue.tsx | 2 +- ...iveChatMessageList.live-reasoning.test.tsx | 2 +- .../native-chat/NativeChatMessageList.tsx | 3 +- .../native-chat/NativeChatNoticeRow.test.tsx | 14 ++-- .../native-chat/NativeChatNoticeRow.tsx | 12 ++-- .../native-chat/NativeChatOlderHistoryRow.tsx | 2 +- .../NativeChatReasoningDisclosure.tsx | 6 +- .../native-chat/NativeChatReasoningRow.tsx | 6 +- .../NativeChatResolutionReceipt.tsx | 2 +- .../native-chat/NativeChatSubagentRun.tsx | 6 +- .../NativeChatSubagentSectionHead.tsx | 4 +- .../native-chat/NativeChatTaskList.test.tsx | 2 +- .../native-chat/NativeChatTaskList.tsx | 29 ++++---- .../native-chat/NativeChatToolLine.test.tsx | 2 +- .../native-chat/NativeChatToolLine.tsx | 6 +- .../NativeChatToolRun.appearance.test.tsx | 7 +- .../native-chat/NativeChatToolRun.test.tsx | 2 +- .../native-chat/NativeChatToolRun.tsx | 4 +- .../native-chat/NativeChatToolRunMembers.tsx | 2 +- .../NativeChatTranscriptChrome.tsx | 10 +-- .../native-chat/NativeChatTranscriptRow.tsx | 2 +- .../NativeChatTurnActivityLine.tsx | 6 +- .../native-chat/NativeChatUserMessageRow.tsx | 8 +-- .../native-chat/NativeChatWorkingStatus.tsx | 2 +- .../native-chat-appearance-style.test.ts | 30 +++++++-- .../native-chat-appearance-style.ts | 6 ++ .../native-chat/native-chat-appearance.css | 26 +++++--- .../native-chat-row-height-estimate.test.ts | 12 ++-- .../native-chat-row-height-estimate.ts | 40 +++++------ .../native-chat-type-scale.test.ts | 43 ++++++++++++ .../use-native-chat-row-typography.ts | 6 +- .../use-native-chat-transcript-window.ts | 14 +++- ...nt-child-row-fallback-pixels.test.tsx.snap | 4 +- src/renderer/src/lib/utils.ts | 7 +- 55 files changed, 438 insertions(+), 158 deletions(-) create mode 100644 config/oxlint-plugins/native-chat-design-tokens.mjs create mode 100644 config/scripts/native-chat-design-tokens-plugin.test.mjs create mode 100644 src/renderer/src/components/native-chat/native-chat-type-scale.test.ts diff --git a/.oxlintrc.json b/.oxlintrc.json index 78dee1acd90..200213e83e3 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -21,6 +21,10 @@ { "name": "renderer-scrollbar-style", "specifier": "./config/oxlint-plugins/renderer-scrollbar-style.mjs" + }, + { + "name": "native-chat-design-tokens", + "specifier": "./config/oxlint-plugins/native-chat-design-tokens.mjs" } ], "categories": { @@ -181,6 +185,14 @@ ] } }, + { + "files": ["src/renderer/src/components/native-chat/**/*.{ts,tsx}"], + "rules": { + "native-chat-design-tokens/no-arbitrary-font-size": "error", + "native-chat-design-tokens/no-softened-foreground": "error", + "native-chat-design-tokens/no-weakened-focus-ring": "error" + } + }, { "files": ["src/shared/**/*.ts"], "rules": { @@ -198,7 +210,10 @@ "rules": { "react/globals": "off", "quadratic-buffer-concat/no-loop-carried-concat": "off", - "renderer-scrollbar-style/require-styled-vertical-scrollbar": "off" + "renderer-scrollbar-style/require-styled-vertical-scrollbar": "off", + "native-chat-design-tokens/no-arbitrary-font-size": "off", + "native-chat-design-tokens/no-softened-foreground": "off", + "native-chat-design-tokens/no-weakened-focus-ring": "off" } }, { diff --git a/AGENTS.md b/AGENTS.md index d30faf95d34..2451994d21a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # Design System -All UI work — layout, color, typography, spacing, component selection, UX behavior — must follow [`docs/STYLEGUIDE.md`](./docs/STYLEGUIDE.md). Most of it is linted: `pnpm run check:code-quality:changed` fails on new restyles of a `components/ui/` primitive, raw palette colors, and computed `className` strings; `pnpm lint` fails on any class Tailwind cannot generate. See the Enforcement section of the style guide before suppressing either. Use the tokens defined in `src/renderer/src/assets/main.css` (the canonical source) and the shadcn primitives in `src/renderer/src/components/ui/`. Don't invent new color values, font sizes, or shadow tiers when a documented one already covers the role. When STYLEGUIDE.md is silent, follow the resolution order in its final section. +All UI work — layout, color, typography, spacing, component selection, UX behavior — must follow [`docs/STYLEGUIDE.md`](./docs/STYLEGUIDE.md). Most of it is linted: `pnpm run check:code-quality:changed` fails on new restyles of a `components/ui/` primitive, raw palette colors, and computed `className` strings; `pnpm lint` fails on any class Tailwind cannot generate. See the Enforcement section of the style guide before suppressing either. Use the tokens defined in `src/renderer/src/assets/main.css` (the canonical source) and the shadcn primitives in `src/renderer/src/components/ui/`. Don't invent new color values, font sizes, or shadow tiers when a documented one already covers the role. When STYLEGUIDE.md is silent, follow the resolution order in its final section. In `components/native-chat/`, the "Chat type scale" and "Chat text colour, hover and focus" sections are binding, and `pnpm lint` fails on arbitrary font sizes, softened `text-foreground`, and weakened focus rings there. ## Electron UI Validation diff --git a/config/oxlint-plugins/native-chat-design-tokens.mjs b/config/oxlint-plugins/native-chat-design-tokens.mjs new file mode 100644 index 00000000000..26c0e56a622 --- /dev/null +++ b/config/oxlint-plugins/native-chat-design-tokens.mjs @@ -0,0 +1,49 @@ +import { plainClassName } from './renderer-scrollbar-style.mjs' + +const CHECKS = { + 'no-arbitrary-font-size': { + pattern: /^text-\[(?:length:)?[\d.]+(?:px|rem|em)\]$/, + message: + 'Use a chat type step (text-sm, text-xs, text-2xs, text-3xs, text-chat-code), not an arbitrary size. See "Chat type scale" in docs/STYLEGUIDE.md.' + }, + 'no-softened-foreground': { + pattern: /^text-foreground\/\d+$/, + message: + "Don't soften text-foreground by hand: use text-chat-foreground on the chat canvas or text-muted-foreground on a lifted surface. See docs/STYLEGUIDE.md." + }, + 'no-weakened-focus-ring': { + pattern: /^ring-ring\/\d+$/, + message: + 'A focus ring at reduced opacity fails 3:1 contrast on the chat canvas: use ring-ring. See docs/STYLEGUIDE.md.' + } +} + +function literalTexts(node) { + if (node.type === 'Literal') { + return typeof node.value === 'string' ? [node.value] : [] + } + return node.quasis.map((quasi) => quasi.value.cooked ?? quasi.value.raw) +} + +function createRule({ pattern, message }) { + return { + create(context) { + const check = (node) => { + const offending = literalTexts(node) + .flatMap((text) => text.split(/\s+/)) + .find((token) => pattern.test(plainClassName(token))) + if (offending) { + context.report({ node, message: `${offending}: ${message}` }) + } + } + return { Literal: check, TemplateLiteral: check } + } + } +} + +export default { + meta: { name: 'native-chat-design-tokens' }, + rules: Object.fromEntries( + Object.entries(CHECKS).map(([name, check]) => [name, createRule(check)]) + ) +} diff --git a/config/scripts/native-chat-design-tokens-plugin.test.mjs b/config/scripts/native-chat-design-tokens-plugin.test.mjs new file mode 100644 index 00000000000..718390c5b93 --- /dev/null +++ b/config/scripts/native-chat-design-tokens-plugin.test.mjs @@ -0,0 +1,66 @@ +import path from 'node:path' +import { describe, expect, it } from 'vitest' +import { runOxlintPluginOnSource } from './oxlint-plugin-test-runner.mjs' + +const pluginPath = path.resolve('config/oxlint-plugins/native-chat-design-tokens.mjs') + +function lintSource(source) { + return runOxlintPluginOnSource({ + pluginName: 'native-chat-design-tokens', + pluginPath, + source, + rules: { + 'native-chat-design-tokens/no-arbitrary-font-size': 'warn', + 'native-chat-design-tokens/no-softened-foreground': 'warn', + 'native-chat-design-tokens/no-weakened-focus-ring': 'warn' + } + }) +} + +const violations = [ + ['arbitrary px size', 'export const X = () =>
'], + ['arbitrary rem size', 'export const X = () => '], + ['arbitrary size behind a variant', 'export const X = () => '], + ['arbitrary size in a class constant', "const ROW = 'flex text-[10px]'"], + [ + 'arbitrary size in a composer', + "export const X = () => " + ], + ['softened foreground', 'export const X = () => '], + [ + 'weakened focus ring', + 'export const X = () => ' + ] +] + +const accepted = [ + ['named steps', 'export const X = () => '], + ['code step', 'export const X = () => '], + [ + 'arbitrary colour', + 'export const X = () => ' + ], + [ + 'arbitrary non-font length', + 'export const X = () => ' + ], + [ + 'chat and surface text colours', + 'export const X = () => ' + ], + ['softened state colour', 'export const X = () => '], + [ + 'full focus ring', + 'export const X = () => ' + ] +] + +describe('native-chat-design-tokens oxlint plugin', () => { + it.each(violations)('reports %s', (_name, source) => { + expect(lintSource(source)).toHaveLength(1) + }) + + it.each(accepted)('accepts %s', (_name, source) => { + expect(lintSource(source)).toEqual([]) + }) +}) diff --git a/docs/STYLEGUIDE.md b/docs/STYLEGUIDE.md index 52d0684af93..733ef99402e 100644 --- a/docs/STYLEGUIDE.md +++ b/docs/STYLEGUIDE.md @@ -91,6 +91,29 @@ This keeps light/dark parity automatic. - 12px (sub-text, paths, secondary content) - 13px (sidebar items, dense list rows) - 14px (default body, button text in `default` size) +- **Named small steps:** `text-2xs` (11px) and `text-3xs` (10px) are theme steps in `main.css`. Use them instead of `text-[11px]` / `text-[10px]`. + +### Chat type scale + +The native chat uses four steps and no others. Never write an arbitrary size (`text-[13px]`) inside `components/native-chat/`. `pnpm lint` enforces this, along with the softened-foreground and focus-ring rules below (`config/oxlint-plugins/native-chat-design-tokens.mjs`). + +| Step | Default | Use it for | +| ---------------- | ------- | ---------------------------------------------------------------------------------------------------------- | +| `text-sm` | 14px | Anything the user reads: message prose, tool and agent activity rows, card titles and bodies, the composer | +| `text-xs` | 12px | Labels, timestamps, counters, secondary lines under a row | +| `text-2xs` | 11px | Captions, attribution, uppercase category labels | +| `text-3xs` | 10px | Badges and mono pills | +| `text-chat-code` | 12px | Code blocks, diffs, commands and tool output | + +The chat root (`.native-chat-appearance`) redefines these steps as ratios of the Text size setting, so every tier scales together; `text-chat-code` follows the Code text size setting. The chat root also ties the spacing unit to the text size (4px at the default 14px), so icons, gaps, padding and row heights grow with the text. Use spacing-scale utilities (`size-3.5`, `min-h-6.5`, `pl-3.25`), not pixel lengths (`min-h-[26px]`), or the element stays fixed while the rest scales. Size an element with one of these utilities only: a CSS rule that sets `font-size` outside a layer silently overrides them. + +### Chat text colour, hover and focus + +- **Primary text on the chat canvas** uses `text-chat-foreground`, and `text-chat-foreground-strong` for titles and emphasis; both follow the Contrast setting. Don't soften `text-foreground` by hand (`text-foreground/85`). `text-foreground` is for text on a lifted surface inside the chat. +- **Secondary text on the chat canvas** (transcript rows, notices, labels between messages) uses `text-chat-foreground-faint`. `text-muted-foreground` is for text on a lifted surface inside the chat: cards, menus, popovers and the docks above the composer. +- **Row hover** in the transcript is `hover:bg-accent/20`. Buttons, menu items and card controls use `hover:bg-accent`. +- **Focus rings:** full-width transcript rows use the inset ring (`focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring`) so it isn't clipped by the scroll container; standalone controls use `focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring`. Inside the chat `--ring` is `--chat-focus-ring`, which clears 3:1 on the canvas; don't lower its opacity. +- **State colours in the chat:** warnings use `text-chat-warning`; diff lines use `text-chat-diff-added` / `text-chat-diff-removed`. They are mixed toward the foreground so they pass text contrast in light and dark. Don't use raw palette colours (`text-yellow-500`) or the git decoration tokens for text on a tinted row. ## Radius diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 924119669fb..f43bbdc6313 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -41,6 +41,16 @@ transition: none !important; } +/* Not inline: the chat root rescales these, so utilities must read the variables. */ +@theme { + --text-2xs: 0.6875rem; + --text-2xs--line-height: calc(1 / 0.6875); + --text-3xs: 0.625rem; + --text-3xs--line-height: calc(0.875 / 0.625); + --text-chat-code: 0.75rem; + --text-chat-code--line-height: calc(1 / 0.75); +} + @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); @@ -57,6 +67,9 @@ --color-chat-inline-code-border: var(--chat-inline-code-border); --color-chat-composer-surface: var(--chat-composer-surface); --color-chat-composer-border: var(--chat-composer-border); + --color-chat-warning: var(--chat-warning); + --color-chat-diff-added: var(--chat-diff-added); + --color-chat-diff-removed: var(--chat-diff-removed); --color-card: var(--card); --color-card-foreground: var(--card-foreground); --color-popover: var(--popover); @@ -611,6 +624,19 @@ body:has(> [data-slot='dialog-overlay'], > [data-slot='sheet-overlay']) { var(--chat-source-foreground) var(--chat-composer-border-mix), transparent ); + /* Mixed toward the foreground so each clears text contrast on the canvas in either scheme. */ + --chat-focus-ring: color-mix(in srgb, var(--chat-source-foreground) 50%, var(--chat-canvas)); + --chat-warning: color-mix(in srgb, var(--status-warning) 70%, var(--chat-source-foreground)); + --chat-diff-added: color-mix( + in srgb, + var(--git-decoration-added) 70%, + var(--chat-source-foreground) + ); + --chat-diff-removed: color-mix( + in srgb, + var(--git-decoration-deleted) 70%, + var(--chat-source-foreground) + ); } @layer base { diff --git a/src/renderer/src/components/native-chat/NativeChatAgentMessageSenders.tsx b/src/renderer/src/components/native-chat/NativeChatAgentMessageSenders.tsx index 4bf83f137eb..7dfc3973d98 100644 --- a/src/renderer/src/components/native-chat/NativeChatAgentMessageSenders.tsx +++ b/src/renderer/src/components/native-chat/NativeChatAgentMessageSenders.tsx @@ -30,7 +30,7 @@ export function NativeChatAgentMessageSenders({ const owner = useNativeChatVisualOwner() const worktreeId = owner?.worktreeId ?? chatWorktreeId return ( -{approval.description}
) : null} {approval.decisionReason ? (
-
+
{translate('components.native-chat.approval.reason', 'Reason')}:{' '}
{approval.decisionReason}
@@ -121,7 +121,7 @@ export function NativeChatApprovalCard({
) : null}
{neededPath ? (
-
+
{translate('components.native-chat.approval.needsAccess', 'Needs access to')}
:{' '}
@@ -141,7 +141,7 @@ export function NativeChatApprovalCard({
/>
{approval.subject.filePath ? (
-
+
{translate('components.native-chat.approval.plan.file', 'Plan file')}:{' '}
{approval.subject.filePath}
@@ -152,7 +152,7 @@ export function NativeChatApprovalCard({
+
{question}
+
{translate(
'components.native-chat.contextUsage.estimated',
'Estimated from the last response.'
diff --git a/src/renderer/src/components/native-chat/NativeChatDiffCard.tsx b/src/renderer/src/components/native-chat/NativeChatDiffCard.tsx
index 238e0907c56..c23744cceba 100644
--- a/src/renderer/src/components/native-chat/NativeChatDiffCard.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatDiffCard.tsx
@@ -94,8 +94,8 @@ function DiffRow({ line, gutterWidth }: { line: NativeChatEditLine; gutterWidth:
@@ -122,7 +122,7 @@ function DiffCardRows({
{copy.title} {copy.title} {copy.subtitle} {copy.subtitle}
+
{translate('components.native-chat.visualUnavailable', 'Visualization unavailable')}
+
{HOST_STATUS_WORDS[block.presentation]()}
+
{questions.map((entry) => (
+
{notice.errorText}
+
{block.text}
)
@@ -82,7 +82,7 @@ export function NativeChatNoticeRow({
if (isAgentSessionHostStatusPresentation(block.presentation)) {
// The look of any other host status line; only the words are the reader's.
return (
-