From da5a440c213e746e00e3c8f4d1b4bf816bd7bacd Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 5 Jul 2026 21:45:00 +0800 Subject: [PATCH 1/5] feat(ui): add Card/Table primitives, retire settings card/table classes (#520 PR9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Card (packages/ui/src/primitives/card.tsx) — thin surface container (data-slot="card" + radius-surface), following maka's ChoiceCard philosophy: each call site keeps its own layout/visual CSS. settingsRows, settingsMetricCard, and maka-error-card now route through Card; their CSS drops the border-radius line (Card owns it), layout is byte-identical. Table (packages/ui/src/primitives/table.tsx) — shadcn-style family (Table/TableHeader/TableBody/TableRow/TableHead/TableCell) with data-slot. settingsStatsTable retires entirely; the table chrome (border + radius + caption font-size) and the cell chrome (tabular-nums + hairline row separators + caption-tone color + semibold head) move into the primitive. maka-error-card stays on Card (not Alert): it is a large crash surface with shadow-modal + stack
, not a small inline callout.

card-table-converge-contract locks the migration. Updated four existing
contracts whose selectors pinned the retired classes/structure:
radius-converge (drop .settingsRows tier — Card owns it now),
tabular-nums (drop .settingsStatsTable th/td — Table owns it now),
settings-usage (regex now matches the Table family + scoped heads/cells),
web-search-boundary (regex now matches ).

Token values verified equivalent: --font-weight-semibold=600 (font-semibold),
--space-1/2, --radius-surface=8px. Screenshot manifest passes (32/32, 0 fail).
---
 .../card-table-converge-contract.test.ts      | 86 ++++++++++++++++
 .../radius-converge-contract.test.ts          |  1 -
 .../__tests__/settings-usage-contract.test.ts |  6 +-
 .../tabular-nums-converge-contract.test.ts    |  2 -
 .../__tests__/web-search-boundary.test.ts     |  4 +-
 apps/desktop/src/renderer/error-boundary.tsx  |  6 +-
 .../settings/daily-review-settings-page.tsx   |  5 +-
 .../settings/memory-settings-page.tsx         |  5 +-
 .../settings/settings-metric-card.tsx         |  6 +-
 .../src/renderer/settings/settings-rows.tsx   |  9 +-
 .../renderer/settings/usage-settings-page.tsx | 26 ++---
 .../settings/web-search-settings-page.tsx     |  9 +-
 .../src/renderer/styles/chat-header.css       |  1 -
 .../src/renderer/styles/settings/bot.css      | 28 +-----
 packages/ui/src/index.ts                      |  2 +
 packages/ui/src/primitives/card.tsx           | 35 +++++++
 packages/ui/src/primitives/table.tsx          | 98 +++++++++++++++++++
 17 files changed, 268 insertions(+), 61 deletions(-)
 create mode 100644 apps/desktop/src/main/__tests__/card-table-converge-contract.test.ts
 create mode 100644 packages/ui/src/primitives/card.tsx
 create mode 100644 packages/ui/src/primitives/table.tsx

diff --git a/apps/desktop/src/main/__tests__/card-table-converge-contract.test.ts b/apps/desktop/src/main/__tests__/card-table-converge-contract.test.ts
new file mode 100644
index 0000000000..fc2d7dbb94
--- /dev/null
+++ b/apps/desktop/src/main/__tests__/card-table-converge-contract.test.ts
@@ -0,0 +1,86 @@
+/**
+ * CARD-TABLE-CONVERGE-0 (issue #520 PR9): the four hand-written card/table
+ * surfaces migrate onto shared Card/Table primitives so the container is the
+ * primitive (with `data-slot`), not a bare `
`/`` carrying a + * hand-rolled class. + * + * - settingsRows (row-list container) + settingsMetricCard (metric tile) + + * maka-error-card (crash surface) → Card + * - settingsStatsTable (usage stats) → Table (shadcn-style family) + * + * Card is intentionally thin (`data-slot="card"` + radius-surface): each site + * keeps its own layout/visual CSS, but the radius now comes from Card and the + * element carries `data-slot="card"`. maka-error-card stays on Card (not Alert) + * because it is a large crash surface with shadow-modal + stack
, not a
+ * small inline callout.
+ */
+
+import { strict as assert } from 'node:assert';
+import { readFile } from 'node:fs/promises';
+import { resolve } from 'node:path';
+import { describe, it } from 'node:test';
+import { REPO_ROOT } from './css-test-helpers.js';
+
+/** Sites whose top-level container becomes . */
+const CARD_SITES = [
+  'apps/desktop/src/renderer/settings/settings-rows.tsx',
+  'apps/desktop/src/renderer/settings/settings-metric-card.tsx',
+  'apps/desktop/src/renderer/error-boundary.tsx',
+];
+
+/** Pages that used 
directly — must route through SettingsRows (Card-backed) now. */ +const SETTINGS_ROWS_CONSUMERS = [ + 'apps/desktop/src/renderer/settings/daily-review-settings-page.tsx', + 'apps/desktop/src/renderer/settings/web-search-settings-page.tsx', + 'apps/desktop/src/renderer/settings/memory-settings-page.tsx', +]; + +/** Site whose table becomes the Table family. */ +const TABLE_SITES = ['apps/desktop/src/renderer/settings/usage-settings-page.tsx']; + +const CARD_PRIMITIVE = 'packages/ui/src/primitives/card.tsx'; +const TABLE_PRIMITIVE = 'packages/ui/src/primitives/table.tsx'; + +const CARD_IMPORT_RE = + /import\s+\{[^}]*\bCard\b[^}]*\}\s+from\s+['"][^'"]*(?:@maka\/ui|primitives\/card)['"]/; +const TABLE_IMPORT_RE = + /import\s+\{[^}]*\bTable\w*\b[^}]*\}\s+from\s+['"][^'"]*(?:@maka\/ui|primitives\/table)['"]/; + +describe('card/table converge (#520 PR9)', () => { + it('ships Card and Table primitives with data-slot', async () => { + const card = await readFile(resolve(REPO_ROOT, CARD_PRIMITIVE), 'utf8'); + assert.match(card, /data-slot=["']card["']/, 'Card primitive must carry data-slot="card"'); + + const table = await readFile(resolve(REPO_ROOT, TABLE_PRIMITIVE), 'utf8'); + assert.match(table, /data-slot=["']table["']/, 'Table primitive must carry data-slot="table"'); + assert.match(table, /TableHeader|TableBody|TableRow|TableHead|TableCell/, 'Table family must ship subcomponents'); + }); + + it('card sites import Card', async () => { + for (const rel of CARD_SITES) { + const src = await readFile(resolve(REPO_ROOT, rel), 'utf8'); + assert.ok(CARD_IMPORT_RE.test(src), `${rel} must import Card from @maka/ui`); + } + }); + + it('settingsRows consumer pages no longer use a bare
', async () => { + for (const rel of SETTINGS_ROWS_CONSUMERS) { + const src = await readFile(resolve(REPO_ROOT, rel), 'utf8'); + assert.ok( + !/', async () => { + for (const rel of TABLE_SITES) { + const src = await readFile(resolve(REPO_ROOT, rel), 'utf8'); + assert.ok(TABLE_IMPORT_RE.test(src), `${rel} must import Table from @maka/ui`); + assert.ok( + !/ { '.settingsHealthError': '--radius-surface', '.settingsHealthRefresh': '--radius-control', '.settingsBotHero': '--radius-surface', - '.settingsRows': '--radius-surface', '.settingsNotice': '--radius-surface', '.settingsAboutLogo': '--radius-surface', '.settingsAboutPrivacy': '--radius-surface', diff --git a/apps/desktop/src/main/__tests__/settings-usage-contract.test.ts b/apps/desktop/src/main/__tests__/settings-usage-contract.test.ts index 73055514d8..b04e4d8d15 100644 --- a/apps/desktop/src/main/__tests__/settings-usage-contract.test.ts +++ b/apps/desktop/src/main/__tests__/settings-usage-contract.test.ts @@ -129,17 +129,17 @@ describe('Settings usage dashboard contract', () => { ); assert.match( simpleStatsTable, - /
/, + /
/, 'Usage stats table must expose its caller-provided name', ); assert.match( simpleStatsTable, - /\{props\.headers\.map\(\(header\) =>
\{header\}<\/th>\)\}<\/tr>/, + /\{props\.headers\.map\(\(header\) => \{header\}<\/TableHead>\)\}<\/TableRow>/, 'Usage stats table column headers must expose column scope', ); assert.match( simpleStatsTable, - /cellIndex === 0 \? \(\s*\{cell\}<\/th>\s*\) : \(\s*\{cell\}<\/td>\s*\)/, + /cellIndex === 0 \? \(\s*\{cell\}<\/TableHead>\s*\) : \(\s*\{cell\}<\/TableCell>\s*\)/, 'Usage stats table rows must expose the first data cell as a scoped row header', ); assert.doesNotMatch( diff --git a/apps/desktop/src/main/__tests__/tabular-nums-converge-contract.test.ts b/apps/desktop/src/main/__tests__/tabular-nums-converge-contract.test.ts index c8e4aace9e..968f8d5d0a 100644 --- a/apps/desktop/src/main/__tests__/tabular-nums-converge-contract.test.ts +++ b/apps/desktop/src/main/__tests__/tabular-nums-converge-contract.test.ts @@ -53,8 +53,6 @@ const TABULAR_NUMS_SELECTORS = [ '.maka-first-run-checklist-count', '.settingsQuotaRow', // settings numeric surfaces - '.settingsStatsTable th', - '.settingsStatsTable td', '.settingsMetricCard', '.settingsUsageRecordCount', '.settingsHealthSummaryTile strong', diff --git a/apps/desktop/src/main/__tests__/web-search-boundary.test.ts b/apps/desktop/src/main/__tests__/web-search-boundary.test.ts index 03dc5f4473..f2a07d4351 100644 --- a/apps/desktop/src/main/__tests__/web-search-boundary.test.ts +++ b/apps/desktop/src/main/__tests__/web-search-boundary.test.ts @@ -329,12 +329,12 @@ describe('web-search renderer boundary (PR-WEB-SEARCH-TAVILY-0)', () => { assert.ok(page, 'Web search settings page block must exist'); assert.match( page![0], - /
/, + //, 'Web search credential controls should sit in the shared grouped Settings card primitive', ); assert.match( page![0], - /
/, + //, 'Web search live-query controls should sit in the shared grouped Settings card primitive', ); for (const rowClass of [ diff --git a/apps/desktop/src/renderer/error-boundary.tsx b/apps/desktop/src/renderer/error-boundary.tsx index 3c47bf6a12..f17e48abf0 100644 --- a/apps/desktop/src/renderer/error-boundary.tsx +++ b/apps/desktop/src/renderer/error-boundary.tsx @@ -8,7 +8,7 @@ import { Component, type ErrorInfo, type ReactNode } from 'react'; import { AlertTriangle, Check, Clipboard, RotateCw } from '@maka/ui/icons'; -import { Button as UiButton, redactSecrets } from '@maka/ui'; +import { Button as UiButton, Card, redactSecrets } from '@maka/ui'; type State = { error: Error | null; @@ -101,7 +101,7 @@ export class ErrorBoundary extends Component<{ children: ReactNode }, State> { return (
-
+ @@ -148,7 +148,7 @@ export class ErrorBoundary extends Component<{ children: ReactNode }, State> {

剪贴板不可用或被系统拒绝;可以手动选择上面的错误摘要。

)}
-
+
); } diff --git a/apps/desktop/src/renderer/settings/daily-review-settings-page.tsx b/apps/desktop/src/renderer/settings/daily-review-settings-page.tsx index 832af9dd4c..61d6a3f853 100644 --- a/apps/desktop/src/renderer/settings/daily-review-settings-page.tsx +++ b/apps/desktop/src/renderer/settings/daily-review-settings-page.tsx @@ -3,6 +3,7 @@ import type { DailyReviewConfig, DailyReviewMode, LlmConnection } from '@maka/co import { Alert, AlertDescription, Button, Input, SettingsSelect, SettingsSwitch as Switch, useToast } from '@maka/ui'; import { buildCatalogDailyReviewModelOptions } from '../model-catalog-choices'; import { settingsActionErrorMessage } from './settings-error-copy'; +import { SettingsRows } from './settings-rows'; /** * PR-DAILY-REVIEW-MVP-0 follow-up: Settings → 每日回顾 is no longer @@ -157,7 +158,7 @@ export function DailyReviewSettingsPage(props: { connections: readonly LlmConnec ) : null} -
+
启用每日回顾 @@ -275,7 +276,7 @@ export function DailyReviewSettingsPage(props: { connections: readonly LlmConnec onChange={() => undefined} />
-
+
{(props.onOpenDailyReview || hasRunOnceIpc) && (
diff --git a/apps/desktop/src/renderer/settings/memory-settings-page.tsx b/apps/desktop/src/renderer/settings/memory-settings-page.tsx index 993951322c..770053e5d0 100644 --- a/apps/desktop/src/renderer/settings/memory-settings-page.tsx +++ b/apps/desktop/src/renderer/settings/memory-settings-page.tsx @@ -11,6 +11,7 @@ import { import { Button, Input, RelativeTime, SettingsSwitch as Switch, Textarea, redactSecrets, useToast } from '@maka/ui'; import { openPathFailureCopy, openPathActionLabel } from '../open-path'; import { settingsActionErrorMessage } from './settings-error-copy'; +import { SettingsRows } from './settings-rows'; export function MemorySettingsPage(props: { settings: AppSettings; @@ -586,7 +587,7 @@ export function MemorySettingsPage(props: { return (
-
+
本地 MEMORY.md @@ -628,7 +629,7 @@ export function MemorySettingsPage(props: { onChange={(enabled) => void setWorkspaceInstructionsEnabled(enabled)} />
-
+
{workspaceInstructionState && (
diff --git a/apps/desktop/src/renderer/settings/settings-metric-card.tsx b/apps/desktop/src/renderer/settings/settings-metric-card.tsx index b242f49e6b..a8e81cb1d8 100644 --- a/apps/desktop/src/renderer/settings/settings-metric-card.tsx +++ b/apps/desktop/src/renderer/settings/settings-metric-card.tsx @@ -1,10 +1,12 @@ +import { Card } from '@maka/ui'; + export function MetricCard(props: { title: string; value: string; detail?: string }) { return ( -
+ {props.title} {props.value} {props.detail && {props.detail}} -
+ ); } diff --git a/apps/desktop/src/renderer/settings/settings-rows.tsx b/apps/desktop/src/renderer/settings/settings-rows.tsx index bfd53de494..b191d2f3e6 100644 --- a/apps/desktop/src/renderer/settings/settings-rows.tsx +++ b/apps/desktop/src/renderer/settings/settings-rows.tsx @@ -1,7 +1,12 @@ import type { ReactNode } from 'react'; +import { Card } from '@maka/ui'; -export function SettingsRows(props: { children: ReactNode }) { - return
{props.children}
; +export function SettingsRows({ className, children }: { className?: string; children: ReactNode }) { + return ( + + {children} + + ); } export function SettingRow(props: { title: string; detail: string; value: string; mono?: boolean }) { diff --git a/apps/desktop/src/renderer/settings/usage-settings-page.tsx b/apps/desktop/src/renderer/settings/usage-settings-page.tsx index c5dd7c654b..e071f08bd0 100644 --- a/apps/desktop/src/renderer/settings/usage-settings-page.tsx +++ b/apps/desktop/src/renderer/settings/usage-settings-page.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import type { AppSettings, UpdateAppSettingsResult, UsageRange, UsageStats } from '@maka/core'; -import { Button, Input, SettingsSegmented as Segmented, SettingsSelect, SettingsSwitch as Switch, useToast } from '@maka/ui'; +import { Button, Input, SettingsSegmented as Segmented, SettingsSelect, SettingsSwitch as Switch, Table, TableBody, TableCell, TableHead, TableHeader, TableRow, useToast } from '@maka/ui'; import { MetricCard } from './settings-metric-card'; import { settingsActionErrorMessage } from './settings-error-copy'; @@ -259,25 +259,25 @@ function usageRequestStatusLabel(status: UsageStats['logs'][number]['status']) { function SimpleStatsTable(props: { ariaLabel: string; headers: string[]; rows: Array>; empty?: string }) { return ( - - - {props.headers.map((header) => )} - - +
{header}
+ + {props.headers.map((header) => {header})} + + {props.rows.length === 0 ? ( - + {props.empty ?? '暂无请求记录'} ) : props.rows.map((row, rowIndex) => ( - + {row.map((cell, cellIndex) => ( cellIndex === 0 ? ( - + {cell} ) : ( - + {cell} ) ))} - + ))} - -
{props.empty ?? '暂无请求记录'}
{cell}{cell}
+ +
); } diff --git a/apps/desktop/src/renderer/settings/web-search-settings-page.tsx b/apps/desktop/src/renderer/settings/web-search-settings-page.tsx index 260c6ae670..6422229eb1 100644 --- a/apps/desktop/src/renderer/settings/web-search-settings-page.tsx +++ b/apps/desktop/src/renderer/settings/web-search-settings-page.tsx @@ -4,6 +4,7 @@ import { normalizeSearchUrl, webSearchCredentialStatusFromResponse } from '@maka import { Button, Input, RelativeTime, SettingsSwitch as Switch, redactSecrets, useToast } from '@maka/ui'; import { PasswordInput } from './password-input'; import { settingsActionErrorMessage } from './settings-error-copy'; +import { SettingsRows } from './settings-rows'; /** * PR-WEB-SEARCH-TAVILY-0: Settings → 联网搜索. @@ -236,7 +237,7 @@ export function WebSearchSettingsPage(props: { return (
-
+
启用联网搜索 @@ -314,9 +315,9 @@ export function WebSearchSettingsPage(props: { )}
-
+ -
+
真实查询验证 @@ -361,7 +362,7 @@ export function WebSearchSettingsPage(props: { )}
-
+ {liveQueryError && (
diff --git a/apps/desktop/src/renderer/styles/chat-header.css b/apps/desktop/src/renderer/styles/chat-header.css index 7c27299b63..8b1c1e640f 100644 --- a/apps/desktop/src/renderer/styles/chat-header.css +++ b/apps/desktop/src/renderer/styles/chat-header.css @@ -290,7 +290,6 @@ max-width: 520px; padding: var(--space-3) var(--space-4); border: var(--border-width-hairline) solid oklch(from var(--destructive) l c h / 0.3); - border-radius: var(--radius-surface); background: var(--background-elevated); box-shadow: var(--shadow-modal); } diff --git a/apps/desktop/src/renderer/styles/settings/bot.css b/apps/desktop/src/renderer/styles/settings/bot.css index 26e737c1d5..2e193884c0 100644 --- a/apps/desktop/src/renderer/styles/settings/bot.css +++ b/apps/desktop/src/renderer/styles/settings/bot.css @@ -359,7 +359,6 @@ display: grid; align-content: center; gap: var(--space-0-5); - border-radius: var(--radius-surface); background: var(--foreground-5); padding: var(--space-1-5) var(--space-2-5); } @@ -374,28 +373,10 @@ font-size: var(--font-size-ui); } -.settingsStatsTable { - width: 100%; - border-collapse: collapse; - overflow: hidden; - border: var(--border-width-hairline) solid var(--border); - border-radius: var(--radius-surface); - font-size: var(--font-size-caption); -} - -.settingsStatsTable th, -.settingsStatsTable td { - font-variant-numeric: tabular-nums; - border-bottom: var(--border-width-hairline) solid var(--border); - color: var(--foreground-secondary); - padding: var(--space-1) var(--space-2); - text-align: left; -} - -.settingsStatsTable th { - color: var(--foreground-secondary); - font-weight: var(--font-weight-semibold); -} +/* #520 PR9: .settingsStatsTable retired onto the Table primitive family + (packages/ui/src/primitives/table.tsx). Table carries the surface chrome + (border + radius + caption font); TableHead/TableCell carry the + tabular-nums + hairline row separators + caption-tone color. */ .settingsCloseButton { width: 24px; @@ -450,7 +431,6 @@ display: grid; gap: 0; border: var(--border-width-hairline) solid oklch(from var(--foreground) l c h / 0.08); - border-radius: var(--radius-surface); overflow: hidden; } diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 9ae2653163..5754b16243 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -30,6 +30,8 @@ export * from './utils.js'; export * from './bot-brand.js'; export * from './bot-brand-logo.js'; export * from './primitives/alert.js'; +export * from './primitives/card.js'; +export * from './primitives/table.js'; // `markerVariants` / `streamVariants` / `toolVariants` / `LiveIndicator` are // deliberately NOT re-exported here: they are internal styling tables / a // single-consumer dot that the chat call sites apply via relative import, so diff --git a/packages/ui/src/primitives/card.tsx b/packages/ui/src/primitives/card.tsx new file mode 100644 index 0000000000..1cfa4f7ec9 --- /dev/null +++ b/packages/ui/src/primitives/card.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { forwardRef } from "react"; +import type { ComponentPropsWithoutRef } from "react"; +import { cn } from "../utils.js"; + +/** + * Card — shared surface container for settings card surfaces (row-list + * containers, metric tiles, the renderer crash surface). Intentionally thin + * (maka's ChoiceCard philosophy): it contributes `data-slot="card"` plus the + * surface radius, and each call site keeps its own layout/visual CSS (grid, + * padding, border, background) via `className`. + * + * Why thin and not shadcn-heavy: settingsRows (row-list), settingsMetricCard + * (metric tile), and maka-error-card (crash surface) share only the surface + * radius; their border / background / padding all differ, so a heavy default + * (`border bg-card shadow`) would have to be overridden at every site. Thin + * keeps each site byte-identical while unifying on the `data-slot` hook that + * the style-hook convention (#520 PR5 item 23) and converge contracts key on. + */ +export type CardProps = ComponentPropsWithoutRef<"div">; + +export const Card = forwardRef(function Card( + { className, ...props }, + ref, +) { + return ( +
+ ); +}); diff --git a/packages/ui/src/primitives/table.tsx b/packages/ui/src/primitives/table.tsx new file mode 100644 index 0000000000..025d98deb7 --- /dev/null +++ b/packages/ui/src/primitives/table.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { forwardRef } from "react"; +import type { ComponentPropsWithoutRef } from "react"; +import { cn } from "../utils.js"; + +/** + * Table — shadcn-style table family for the settings stats surface, so the + * table container carries `data-slot="table"` instead of a hand-rolled + * `.settingsStatsTable` class. The table itself owns the surface chrome + * (border + radius + caption font-size); the row/cell members own the + * tabular-nums + hairline row separators + caption-tone color that the old + * `.settingsStatsTable th, .settingsStatsTable td` rules supplied. + * + * `scope` is left to the caller: the usage-stats table has both column + * headers (`scope="col"` in ``) and a row header (`scope="row"` on + * the first `` of each body row), so baking in a default would lie. + */ +export type TableProps = ComponentPropsWithoutRef<"table">; + +export const Table = forwardRef(function Table( + { className, ...props }, + ref, +) { + return ( + + ); +}); + +export const TableHeader = forwardRef< + HTMLTableSectionElement, + ComponentPropsWithoutRef<"thead"> +>(function TableHeader({ className, ...props }, ref) { + return ; +}); + +export const TableBody = forwardRef< + HTMLTableSectionElement, + ComponentPropsWithoutRef<"tbody"> +>(function TableBody({ className, ...props }, ref) { + return ; +}); + +export const TableRow = forwardRef< + HTMLTableRowElement, + ComponentPropsWithoutRef<"tr"> +>(function TableRow({ className, ...props }, ref) { + return ( + + ); +}); + +export const TableHead = forwardRef< + HTMLTableCellElement, + ComponentPropsWithoutRef<"th"> +>(function TableHead({ className, ...props }, ref) { + return ( +
+ ); +}); + +export const TableCell = forwardRef< + HTMLTableCellElement, + ComponentPropsWithoutRef<"td"> +>(function TableCell({ className, ...props }, ref) { + return ( + + ); +}); From 27779cf28447d5765607742c90955a07a7401dd0 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 5 Jul 2026 23:09:27 +0800 Subject: [PATCH 2/5] feat(ui): converge badge surfaces onto pill Badge + squared Chip #520 PR9 commit 2: collapse the four coexisting badge surfaces onto two canonical primitives, split by UI role. - pill Badge (primitives/badge.tsx): emphasis markers. Retire the PrimitiveBadge alias + the legacy ui.tsx Badge (raw emerald/amber variants). health/permission center, artifact-pane, plan-reminder, and permission-dialog route through . - squared Chip (primitives/chip.tsx): dense status rows. Retire the .settingsBadge + .settingsConnectionBadge CSS chips. settings connection status / default / category markers route through ; variants mirror StatusTone, so settings callers pass the tone directly instead of going through statusBadgeVariant (which stays for the health/permission pill Badge sites). - contracts: badge-converge (pill track), chip-converge (squared track, locks radius-control not pill), settings-form-a11y (lock point moved from the CSS class to the Chip primitive), radius-converge (drop the stale ui.tsx badgeVariants entry). - visual zero-change: Chip cva reproduces the retired CSS oklch alphas (success/12, info/14, warning/18, destructive/15) and the neutral foreground-5 base. screenshot pixel diff vs main: settings-bots light/dark 1280 AE=0. --- .../__tests__/badge-converge-contract.test.ts | 76 +++++++++++++++++++ .../__tests__/chip-converge-contract.test.ts | 55 ++++++++++++++ .../radius-converge-contract.test.ts | 2 +- .../settings-form-a11y-contract.test.ts | 16 ++-- .../settings/account-settings-page.tsx | 6 +- .../settings/bot-chat-settings-page.tsx | 1 - .../renderer/settings/health-center-page.tsx | 14 ++-- .../settings/memory-settings-page.tsx | 6 +- .../settings/permission-center-page.tsx | 6 +- .../renderer/settings/provider-add-form.tsx | 4 +- .../settings/provider-connection-detail.tsx | 6 +- .../settings/provider-oauth-section.tsx | 16 ++-- .../settings/web-search-settings-page.tsx | 6 +- .../src/renderer/styles/settings/bot.css | 12 +-- .../renderer/styles/settings/connection.css | 34 +-------- packages/ui/src/index.ts | 23 +++--- packages/ui/src/permission-dialog.tsx | 3 +- packages/ui/src/plan-reminder-panel.tsx | 2 +- packages/ui/src/primitives/chip.tsx | 64 ++++++++++++++++ packages/ui/src/ui.tsx | 27 ------- packages/ui/stories/badge.stories.tsx | 37 +++------ 21 files changed, 272 insertions(+), 144 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/badge-converge-contract.test.ts create mode 100644 apps/desktop/src/main/__tests__/chip-converge-contract.test.ts create mode 100644 packages/ui/src/primitives/chip.tsx diff --git a/apps/desktop/src/main/__tests__/badge-converge-contract.test.ts b/apps/desktop/src/main/__tests__/badge-converge-contract.test.ts new file mode 100644 index 0000000000..011472f9d9 --- /dev/null +++ b/apps/desktop/src/main/__tests__/badge-converge-contract.test.ts @@ -0,0 +1,76 @@ +/** + * BADGE-CONVERGE-0 (issue #520 PR9): collapse the coexisting badge surfaces + * onto two canonical primitives, split by UI role: + * - pill `Badge` (packages/ui/src/primitives/badge.tsx) — emphasis markers + * - squared `Chip` (packages/ui/src/primitives/chip.tsx) — dense status rows + * + * Before: four tracks — + * 1. `PrimitiveBadge` (the Base UI primitive, aliased to avoid colliding + * with the legacy) + * 2. legacy `Badge` in `ui.tsx` (hand-written, raw emerald/amber colors) + * 3. `.settingsBadge` span (neutral label chip, CSS class) + * 4. `.settingsConnectionBadge` span (data-tone status chip, CSS class) + * + * After: `PrimitiveBadge` alias and the legacy `ui.tsx` Badge are gone; pill + * badge sites route through ``. The two settings CSS chips (3, 4) route + * through the squared `` primitive instead — see chip-converge-contract. + * Badge and Chip stay separate because settings rows need compact squared + * chips (radius-control), not pill emphasis markers. + */ + +import { strict as assert } from 'node:assert'; +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { describe, it } from 'node:test'; +import { REPO_ROOT } from './css-test-helpers.js'; + +/** Sites that now route through the pill Badge primitive (was PrimitiveBadge or legacy ui.tsx Badge). */ +const MIGRATED_FILES = [ + 'apps/desktop/src/renderer/settings/health-center-page.tsx', + 'apps/desktop/src/renderer/settings/permission-center-page.tsx', + 'apps/desktop/src/renderer/artifact-pane.tsx', + 'packages/ui/src/plan-reminder-panel.tsx', + 'packages/ui/src/permission-dialog.tsx', +]; + +const BADGE_PRIMITIVE = 'packages/ui/src/primitives/badge.tsx'; +const UI_BARREL = 'packages/ui/src/ui.tsx'; + +const BADGE_IMPORT_RE = + /import\s+\{[^}]*\bBadge\b[^}]*\}\s+from\s+['"][^'"]*?(?:@maka\/ui|primitives\/badge\.js)['"]/; + +describe('badge converge (#520 PR9)', () => { + it('canonical Badge primitive carries data-slot', async () => { + const src = await readFile(resolve(REPO_ROOT, BADGE_PRIMITIVE), 'utf8'); + assert.match(src, /["']?data-slot["']?\s*[:=]\s*["']badge["']/, 'Badge primitive must carry data-slot="badge"'); + assert.match(src, /export function Badge/, 'Badge primitive must export function Badge'); + }); + + it('legacy Badge + badgeVariants are gone from ui.tsx', async () => { + const src = await readFile(resolve(REPO_ROOT, UI_BARREL), 'utf8'); + assert.ok( + !/export function Badge\b/.test(src), + 'ui.tsx must not export the legacy hand-written Badge', + ); + // the legacy badgeVariants (raw emerald/amber cva) must be gone too + assert.ok( + !/emerald-500|amber-500/.test(src), + 'ui.tsx must not carry the legacy raw-color badgeVariants', + ); + }); + + it('migrated sites import Badge from @maka/ui', async () => { + for (const rel of MIGRATED_FILES) { + const src = await readFile(resolve(REPO_ROOT, rel), 'utf8'); + assert.ok(BADGE_IMPORT_RE.test(src), `${rel} must import Badge from @maka/ui`); + } + }); + + it('no remains (aliased name retired)', async () => { + for (const rel of MIGRATED_FILES) { + const src = await readFile(resolve(REPO_ROOT, rel), 'utf8'); + assert.ok(!/, not `); + } + }); + +}); diff --git a/apps/desktop/src/main/__tests__/chip-converge-contract.test.ts b/apps/desktop/src/main/__tests__/chip-converge-contract.test.ts new file mode 100644 index 0000000000..a494dd2e51 --- /dev/null +++ b/apps/desktop/src/main/__tests__/chip-converge-contract.test.ts @@ -0,0 +1,55 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { REPO_ROOT } from './css-test-helpers.js'; + +const read = (rel: string) => readFileSync(join(REPO_ROOT, rel), 'utf8'); + +// #520 PR9 commit 2: settings status chips converge onto a dedicated Chip +// primitive (squared, compact, status-tone), NOT the pill Badge primitive. +// Badge and Chip are two distinct UI roles — pill Badge for emphasis markers +// (health/permission center), squared Chip for dense settings status rows. +// This contract keeps the two tracks apart and locks Chip to radius-control. +test('chip converge (#520 PR9)', async () => { + // 1. Chip primitive exists + carries data-slot + const chipSrc = read('packages/ui/src/primitives/chip.tsx'); + assert.match(chipSrc, /export function Chip/, 'Chip primitive must be exported'); + assert.match(chipSrc, /["']?data-slot["']?\s*[:=]\s*["']chip["']/, 'Chip must carry data-slot="chip"'); + + // 2. Chip locks radius-control (squared), never pill + assert.match(chipSrc, /rounded-\[var\(--radius-control\)\]/, 'Chip must use radius-control (squared, not pill)'); + assert.doesNotMatch(chipSrc, /rounded-\[var\(--radius-pill\)\]/, 'Chip must not regress to pill'); + + // 3. index.ts re-exports Chip + const indexSrc = read('packages/ui/src/index.ts'); + assert.match(indexSrc, /export (?:\*|\{[^}]*\bChip\b[^}]*\}) from ['"]\.\/primitives\/chip\.js['"]/, 'index.ts must re-export Chip'); + + // 4. settings CSS chips retired (.settingsBadge / .settingsConnectionBadge) + const botCss = read('apps/desktop/src/renderer/styles/settings/bot.css'); + assert.doesNotMatch(botCss, /\.settingsBadge\s*\{/, '.settingsBadge CSS rule must be retired'); + const connCss = read('apps/desktop/src/renderer/styles/settings/connection.css'); + assert.doesNotMatch(connCss, /\.settingsConnectionBadge\s*[\{[,]/, '.settingsConnectionBadge CSS rule must be retired'); + + // 5. settings chip sites import and use Chip primitive, not the CSS spans + const CHIP_IMPORT_RE = + /import\s+\{[^}]*\bChip\b[^}]*\}\s+from\s+['"][^'"]*?(?:@maka\/ui|primitives\/chip\.js)['"]/; + const settingsChipFiles = [ + 'apps/desktop/src/renderer/settings/provider-connection-detail.tsx', + 'apps/desktop/src/renderer/settings/provider-add-form.tsx', + 'apps/desktop/src/renderer/settings/web-search-settings-page.tsx', + 'apps/desktop/src/renderer/settings/memory-settings-page.tsx', + 'apps/desktop/src/renderer/settings/account-settings-page.tsx', + 'apps/desktop/src/renderer/settings/provider-oauth-section.tsx', + ]; + for (const rel of settingsChipFiles) { + const src = read(rel); + assert.match(src, CHIP_IMPORT_RE, `${rel} must import Chip`); + assert.doesNotMatch(src, /className=["'][^"']*settingsBadge/, `${rel} must not use .settingsBadge span`); + assert.doesNotMatch(src, /className=["'][^"']*settingsConnectionBadge/, `${rel} must not use .settingsConnectionBadge span`); + } + + // 6. Badge primitive stays pill — dual-track Badge (pill) + Chip (squared) preserved + const badgeSrc = read('packages/ui/src/primitives/badge.tsx'); + assert.match(badgeSrc, /rounded-\[var\(--radius-pill\)\]/, 'Badge stays pill (dual-track with Chip)'); +}); \ No newline at end of file diff --git a/apps/desktop/src/main/__tests__/radius-converge-contract.test.ts b/apps/desktop/src/main/__tests__/radius-converge-contract.test.ts index d233fb63e7..564e9f7b9f 100644 --- a/apps/desktop/src/main/__tests__/radius-converge-contract.test.ts +++ b/apps/desktop/src/main/__tests__/radius-converge-contract.test.ts @@ -174,7 +174,7 @@ const COMPONENT_RADIUS: ComponentRadiusCheck[] = [ { file: 'packages/ui/src/ui.tsx', name: 'inputClasses', tier: 'control' }, { file: 'packages/ui/src/ui.tsx', name: 'SelectItem', tier: 'control' }, { file: 'packages/ui/src/ui.tsx', name: 'Toggle', tier: 'control' }, - { file: 'packages/ui/src/ui.tsx', name: 'badgeVariants', tier: 'pill' }, + // #520 PR9: legacy ui.tsx badgeVariants retired onto primitives/badge.tsx. // DialogPopup/AlertDialogPopup were merged into createModalContent (PR6 // review P3.1); the modal popup class now lives in MODAL_POPUP_CLASS. { file: 'packages/ui/src/ui.tsx', name: 'MODAL_POPUP_CLASS', tier: 'modal' }, diff --git a/apps/desktop/src/main/__tests__/settings-form-a11y-contract.test.ts b/apps/desktop/src/main/__tests__/settings-form-a11y-contract.test.ts index 363df01358..ee01e38188 100644 --- a/apps/desktop/src/main/__tests__/settings-form-a11y-contract.test.ts +++ b/apps/desktop/src/main/__tests__/settings-form-a11y-contract.test.ts @@ -42,8 +42,12 @@ describe('Settings form accessibility labels', () => { it('keeps Settings secondary surfaces close to reference implementation card geometry', async () => { const styles = await readRendererContractCss(); const connectionRow = styles.match(/\.settingsConnectionRow\s*\{[\s\S]*?\}/)?.[0] ?? ''; - const connectionBadge = styles.match(/\.settingsConnectionBadge\s*\{[\s\S]*?\}/)?.[0] ?? ''; - const settingsBadge = styles.match(/\.settingsBadge\s*\{[\s\S]*?\}/)?.[0] ?? ''; + // #520 PR9: .settingsConnectionBadge / .settingsBadge CSS chips retired + // onto the squared Chip primitive. The "compact squared, not pill" intent + // now lives on Chip's cva base (rounded-[var(--radius-control)]). + const chipPrimitive = await readRepo('packages/ui/src/primitives/chip.tsx'); + const connectionBadge = chipPrimitive; + const settingsBadge = chipPrimitive; const authContract = styles.match(/\.settingsAuthContract\s*\{[\s\S]*?\}/)?.[0] ?? ''; // PR-DELETE-ORPHAN-CSS: `.providerEmpty` / `.providerCard` were // orphan classes (no TSX consumer); the comma-grouped rule @@ -92,13 +96,13 @@ describe('Settings form accessibility labels', () => { assert.match(providerCatalogBadge, /border-radius:\s*var\(--radius-control\);/, 'Provider catalog badges (category / preview / login) should use compact squared target-layout style corners, not pills'); assert.match(modelTableChip, /border-radius:\s*var\(--radius-control\);/, 'Settings model capability chips should use compact squared target-layout style corners, not pills'); assert.match(modelTableDefaultBadge, /border-radius:\s*var\(--radius-control\);/, 'Settings model default badge should use compact squared target-layout style corners, not pills'); - assert.match(connectionBadge, /border-radius:\s*var\(--radius-control\);/, 'Settings status badges should use compact squared target-layout style corners, not pills'); - assert.match(settingsBadge, /border-radius:\s*var\(--radius-control\);/, 'Generic Settings badges should use compact squared target-layout style corners, not pills'); + assert.match(connectionBadge, /rounded-\[var\(--radius-control\)\]/, 'Settings status badges (Chip primitive) should use compact squared target-layout style corners, not pills'); + assert.match(settingsBadge, /rounded-\[var\(--radius-control\)\]/, 'Generic Settings badges (Chip primitive) should use compact squared target-layout style corners, not pills'); assert.doesNotMatch(providerCatalogBadge, /border-radius:\s*var\(--radius-pill\);/, 'Provider catalog badges must not regress to pill-shaped chrome'); assert.doesNotMatch(modelTableChip, /border-radius:\s*var\(--radius-pill\);/, 'Settings model capability chips must not regress to pill-shaped chrome'); assert.doesNotMatch(modelTableDefaultBadge, /border-radius:\s*var\(--radius-pill\);/, 'Settings model default badge must not regress to pill-shaped chrome'); - assert.doesNotMatch(connectionBadge, /border-radius:\s*var\(--radius-pill\);/, 'Settings connection badges must not regress to pill-shaped chrome'); - assert.doesNotMatch(settingsBadge, /border-radius:\s*var\(--radius-pill\);/, 'Generic Settings badges must not regress to pill-shaped chrome'); + assert.doesNotMatch(connectionBadge, /rounded-\[var\(--radius-pill\)\]/, 'Settings connection badges (Chip primitive) must not regress to pill-shaped chrome'); + assert.doesNotMatch(settingsBadge, /rounded-\[var\(--radius-pill\)\]/, 'Generic Settings badges (Chip primitive) must not regress to pill-shaped chrome'); assert.match(settingsRow, /display:\s*grid;/, 'Settings rows should use a stable label/value grid instead of flex auto sizing'); assert.match(settingsRow, /grid-template-columns:\s*minmax\(150px,\s*0\.36fr\)\s+minmax\(0,\s*1fr\);/, 'Settings rows need a protected label column and shrinkable value column'); assert.match(settingsRowValue, /overflow-wrap:\s*anywhere;/, 'Long Settings values such as workspace paths should wrap in the value column'); diff --git a/apps/desktop/src/renderer/settings/account-settings-page.tsx b/apps/desktop/src/renderer/settings/account-settings-page.tsx index 770a400862..78d1303755 100644 --- a/apps/desktop/src/renderer/settings/account-settings-page.tsx +++ b/apps/desktop/src/renderer/settings/account-settings-page.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react'; import type { ConnectionTestResult, LlmConnection } from '@maka/core'; import { deriveProviderAuthContractFromConnection, generalizedErrorMessageChinese } from '@maka/core'; import { PROVIDER_DEFAULTS } from '@maka/core/llm-connections'; -import { Button, RelativeTime, useToast } from '@maka/ui'; +import { Button, Chip, RelativeTime, useToast } from '@maka/ui'; import { deriveAccountAuthActions, presentAccountAuthState, @@ -277,9 +277,9 @@ function AccountConnectionRow(props: { {subtitle} - + {presentation.label} - +

{presentation.detail}

diff --git a/apps/desktop/src/renderer/settings/bot-chat-settings-page.tsx b/apps/desktop/src/renderer/settings/bot-chat-settings-page.tsx index a3735c85e0..9169b43669 100644 --- a/apps/desktop/src/renderer/settings/bot-chat-settings-page.tsx +++ b/apps/desktop/src/renderer/settings/bot-chat-settings-page.tsx @@ -21,7 +21,6 @@ import { DialogContent, DialogRoot, Input, - PrimitiveBadge, RelativeTime, SettingsSelect, SettingsSwitch as Switch, diff --git a/apps/desktop/src/renderer/settings/health-center-page.tsx b/apps/desktop/src/renderer/settings/health-center-page.tsx index 92b2e88913..0c993e7302 100644 --- a/apps/desktop/src/renderer/settings/health-center-page.tsx +++ b/apps/desktop/src/renderer/settings/health-center-page.tsx @@ -7,7 +7,7 @@ import type { HealthSnapshot, } from '@maka/core'; import { HEALTH_SIGNAL_LAYERS } from '@maka/core'; -import { Button, PrimitiveBadge, RelativeTime } from '@maka/ui'; +import { Button, Badge, RelativeTime } from '@maka/ui'; import { settingsActionErrorMessage } from './settings-error-copy'; import { statusBadgeVariant } from './settings-status-badge'; @@ -130,7 +130,7 @@ export function HealthCenterPage() {

- 只读快照 + 只读快照 最近一次读取: @@ -163,14 +163,14 @@ export function HealthCenterPage() { {(blocksSendCount > 0 || blocksCapabilityCount > 0) && (
{blocksSendCount > 0 && ( - + {blocksSendCount} 条健康信号会阻塞发送 - + )} {blocksCapabilityCount > 0 && ( - + {blocksCapabilityCount} 条健康信号会阻塞能力 - + )}
)} @@ -225,7 +225,7 @@ function HealthSignalRow(props: { signal: HealthSignal }) { {signal.label} {HEALTH_SCOPE_LABEL[signal.scope]}
- {statusCopy.label} + {statusCopy.label}

{signal.message}

{signal.detail && {signal.detail}} diff --git a/apps/desktop/src/renderer/settings/memory-settings-page.tsx b/apps/desktop/src/renderer/settings/memory-settings-page.tsx index 770053e5d0..6e3a280afe 100644 --- a/apps/desktop/src/renderer/settings/memory-settings-page.tsx +++ b/apps/desktop/src/renderer/settings/memory-settings-page.tsx @@ -8,7 +8,7 @@ import { parseLocalMemoryMarkdown, setLocalMemoryEntryStatusDraft, } from '@maka/core'; -import { Button, Input, RelativeTime, SettingsSwitch as Switch, Textarea, redactSecrets, useToast } from '@maka/ui'; +import { Button, Chip, Input, RelativeTime, SettingsSwitch as Switch, Textarea, redactSecrets, useToast } from '@maka/ui'; import { openPathFailureCopy, openPathActionLabel } from '../open-path'; import { settingsActionErrorMessage } from './settings-error-copy'; import { SettingsRows } from './settings-rows'; @@ -593,9 +593,9 @@ export function MemorySettingsPage(props: { 本地 MEMORY.md 透明 Markdown 文件,保存在当前本机工作区。这里的内容不会自动从聊天里抽取。 - + {memoryStatusLabel(effective.status)} - +
{capability.label} {prettyCapabilityId(capability.id)} - {readinessCopy.label} + {readinessCopy.label}

{readinessCopy.detail}

@@ -514,7 +514,7 @@ function OsPermissionRow(props: {
{label} - {stateCopy.label} + {stateCopy.label}
{purpose} {impact ? ( diff --git a/apps/desktop/src/renderer/settings/provider-add-form.tsx b/apps/desktop/src/renderer/settings/provider-add-form.tsx index 883311228b..cef69f99a6 100644 --- a/apps/desktop/src/renderer/settings/provider-add-form.tsx +++ b/apps/desktop/src/renderer/settings/provider-add-form.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from 'react'; import { PROVIDER_DEFAULTS, validateSlug, type ProviderType } from '@maka/core'; -import { Button, Input } from '@maka/ui'; +import { Button, Chip, Input } from '@maka/ui'; import { buildCatalogRecommendedDefaultModel } from '../model-catalog-choices'; import { providerDisplay } from './provider-display'; import { @@ -83,7 +83,7 @@ export function AddProviderForm(props: { : isExperimental ? '账号登录暂未接入聊天发送' : `添加 ${display.name}`}

{display.description}

- {categoryLabel(defaults.category)} + {categoryLabel(defaults.category)} {isExperimental && (
diff --git a/apps/desktop/src/renderer/settings/provider-connection-detail.tsx b/apps/desktop/src/renderer/settings/provider-connection-detail.tsx index 80e099d457..49d52114ea 100644 --- a/apps/desktop/src/renderer/settings/provider-connection-detail.tsx +++ b/apps/desktop/src/renderer/settings/provider-connection-detail.tsx @@ -15,7 +15,7 @@ import { type ModelInfo, } from '@maka/core'; import { formatRelativeTimestamp } from '@maka/core'; -import { Button, FieldDescription, FieldRoot, Input, Label, useToast } from '@maka/ui'; +import { Button, Chip, FieldDescription, FieldRoot, Input, Label, useToast } from '@maka/ui'; import { PasswordInput } from './password-input'; import { buildCatalogModelChoices } from '../model-catalog-choices'; import { providerDisplay } from './provider-display'; @@ -375,8 +375,8 @@ export function ConnectionDetail(props: {

{display.name}

- {props.isDefault && 默认} - {categoryLabel(defaults.category)} + {props.isDefault && 默认} + {categoryLabel(defaults.category)} diff --git a/apps/desktop/src/renderer/settings/provider-oauth-section.tsx b/apps/desktop/src/renderer/settings/provider-oauth-section.tsx index ad535965f7..f5276a48b3 100644 --- a/apps/desktop/src/renderer/settings/provider-oauth-section.tsx +++ b/apps/desktop/src/renderer/settings/provider-oauth-section.tsx @@ -7,6 +7,7 @@ import { type SubscriptionAccountState, } from '@maka/core'; import { + Chip, Button, Item, ItemActions, @@ -18,6 +19,7 @@ import { Textarea, useToast, } from '@maka/ui'; +import { type StatusTone } from './settings-status-badge'; import { ProviderLogo } from './provider-display'; import { ProviderSheet } from './provider-config-sheet'; @@ -680,7 +682,7 @@ function ClaudeSubscriptionCard() { 无法确认 Claude OAuth 是否可用。没有登录动作会被执行。 - 读取失败 + 读取失败 Claude 登录开关读取失败:{experimentalGateError} @@ -860,7 +862,7 @@ function ClaudeSubscriptionCard() { } // Closed-state render mapping per the runtime state enum. - const presentation = state ? presentSubscriptionState(state) : { label: '加载中…', tone: 'muted', detail: '' }; + const presentation = state ? presentSubscriptionState(state) : { label: '加载中…', tone: 'neutral' as const, detail: '' }; const canStartClaudeLogin = state?.runtimeState === 'not_logged_in' || state?.runtimeState === 'refresh_failed' || @@ -882,9 +884,9 @@ function ClaudeSubscriptionCard() { {state?.profile?.email ? ` · ${state.profile.email}` : ''} - + {presentation.label} - +

{presentation.detail}

{pasteError && !authRequestId && ( @@ -994,14 +996,14 @@ type ClaudeSubscriptionPendingAction = 'login' | 'submit' | 'cancel' | 'logout' interface SubscriptionStatePresentation { label: string; - tone: string; + tone: StatusTone; detail: string; } function presentSubscriptionState(state: SubscriptionAccountState): SubscriptionStatePresentation { switch (state.runtimeState) { case 'not_logged_in': - return { label: '未登录', tone: 'muted', detail: '使用 Claude 订阅配额前需要先登录。' }; + return { label: '未登录', tone: 'neutral', detail: '使用 Claude 订阅配额前需要先登录。' }; case 'authorizing': return { label: '登录中…', tone: 'info', detail: '请在弹出的浏览器窗口完成登录并粘贴授权码。' }; case 'authenticated': @@ -1037,6 +1039,6 @@ function presentSubscriptionState(state: SubscriptionAccountState): Subscription detail: subscriptionResultMessage(state.errorMessage, '订阅端点拒绝了请求,可能需要重新登录。'), }; default: - return { label: '未知状态', tone: 'muted', detail: '' }; + return { label: '未知状态', tone: 'neutral', detail: '' }; } } diff --git a/apps/desktop/src/renderer/settings/web-search-settings-page.tsx b/apps/desktop/src/renderer/settings/web-search-settings-page.tsx index 6422229eb1..7bbef47a7d 100644 --- a/apps/desktop/src/renderer/settings/web-search-settings-page.tsx +++ b/apps/desktop/src/renderer/settings/web-search-settings-page.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef, useState } from 'react'; import type { AppSettings, UpdateAppSettingsResult, WebSearchCredentialStatus } from '@maka/core'; import { normalizeSearchUrl, webSearchCredentialStatusFromResponse } from '@maka/core'; -import { Button, Input, RelativeTime, SettingsSwitch as Switch, redactSecrets, useToast } from '@maka/ui'; +import { Button, Chip, Input, RelativeTime, SettingsSwitch as Switch, redactSecrets, useToast } from '@maka/ui'; import { PasswordInput } from './password-input'; import { settingsActionErrorMessage } from './settings-error-copy'; import { SettingsRows } from './settings-rows'; @@ -245,9 +245,9 @@ export function WebSearchSettingsPage(props: {
- + {statusCopy.label} - + {hasCheckedAt && ( 最近测试 diff --git a/apps/desktop/src/renderer/styles/settings/bot.css b/apps/desktop/src/renderer/styles/settings/bot.css index 2e193884c0..562a37e541 100644 --- a/apps/desktop/src/renderer/styles/settings/bot.css +++ b/apps/desktop/src/renderer/styles/settings/bot.css @@ -413,16 +413,8 @@ transition: background var(--duration-base) var(--ease-out-strong); } -.settingsBadge { - display: inline-flex; - align-items: center; - min-height: 18px; - border-radius: var(--radius-control); - background: var(--foreground-5); - color: var(--foreground-secondary); - padding: 0 var(--space-1-5); - font-size: var(--font-size-caption); -} +/* #520 PR9: .settingsBadge retired onto the Badge primitive + (packages/ui/src/primitives/badge.tsx, variant="secondary"). */ /* PR-SETTINGS-GROUPED-CARD-0 (WAWQAQ msg `1abecd66`): same grouped- card pattern as `.settingsStructuredPage` — outer card + hairline diff --git a/apps/desktop/src/renderer/styles/settings/connection.css b/apps/desktop/src/renderer/styles/settings/connection.css index eab2000f3c..b0976ad317 100644 --- a/apps/desktop/src/renderer/styles/settings/connection.css +++ b/apps/desktop/src/renderer/styles/settings/connection.css @@ -126,37 +126,9 @@ font-family: var(--font-mono); font-size: var(--font-size-caption); } -.settingsConnectionBadge { - display: inline-flex; - align-items: center; - min-height: 20px; - padding: var(--space-0-5) var(--space-2); - border-radius: var(--radius-control); - font-size: var(--font-size-caption); - font-weight: var(--font-weight-semibold); - letter-spacing: var(--tracking-normal); - background: var(--foreground-5); - color: var(--foreground-secondary); - white-space: nowrap; - } -.settingsConnectionBadge[data-tone="success"] { - background: oklch(from var(--success) l c h / 0.12); - color: var(--success); - } -.settingsConnectionBadge[data-tone="info"] { - background: oklch(from var(--info) l c h / 0.14); - color: var(--info-text); - } -.settingsConnectionBadge[data-tone="warning"] { - background: oklch(from var(--info) l c h / 0.18); - color: var(--info-text); - font-weight: var(--font-weight-bold); - } -.settingsConnectionBadge[data-tone="destructive"] { - background: oklch(from var(--destructive) l c h / 0.15); - color: var(--destructive); - font-weight: var(--font-weight-bold); - } +/* #520 PR9: .settingsConnectionBadge + data-tone retired onto the Badge + primitive + statusBadgeVariant (packages/ui/src/primitives/badge.tsx + + settings-status-badge.ts). The warning tone no longer misuses --info. */ .settingsConnectionDetail { margin: 0; color: var(--foreground-secondary); diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 5754b16243..c869fdb898 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -93,13 +93,16 @@ export { AccordionPanel as PrimitiveAccordionPanel, AccordionPrimitive as PrimitiveAccordionPrimitive, } from './primitives/accordion.js'; -// PR-USE-SHADCN-BASE-UI-BADGE: the canonical shadcn/base-ui Badge primitive -// (variants: default / destructive / error / info / outline / secondary / -// success / warning). Aliased to PrimitiveBadge so it doesn't collide with -// the legacy `Badge` exported from `ui.tsx`; consumers can pick the version -// they want by import name. -export { - Badge as PrimitiveBadge, - badgeVariants as primitiveBadgeVariants, -} from './primitives/badge.js'; -export type { BadgeProps as PrimitiveBadgeProps } from './primitives/badge.js'; +// PR-USE-SHADCN-BASE-UI-BADGE: the canonical pill Badge primitive. #520 PR9 +// collapsed the legacy ui.tsx Badge onto this one. Badge is the pill emphasis +// marker (health/permission center). Variants: default / destructive / error +// / info / outline / secondary / success / warning. +export { Badge, badgeVariants } from './primitives/badge.js'; +export type { BadgeProps } from './primitives/badge.js'; +// PR-USE-SHADCN-BASE-UI-CHIP: squared compact status label. #520 PR9 collapsed +// .settingsBadge + .settingsConnectionBadge CSS chips onto this one. Chip is +// the squared (radius-control) counterpart to pill Badge, for dense settings +// status rows. Variants mirror StatusTone: neutral / info / success / warning +// / destructive. +export { Chip, chipVariants } from './primitives/chip.js'; +export type { ChipProps } from './primitives/chip.js'; diff --git a/packages/ui/src/permission-dialog.tsx b/packages/ui/src/permission-dialog.tsx index a9ce396d7d..7ea05bf2bb 100644 --- a/packages/ui/src/permission-dialog.tsx +++ b/packages/ui/src/permission-dialog.tsx @@ -4,7 +4,8 @@ import { derivePermissionRequestHealth, formatPermissionRequestWait } from '@mak import { AlertOctagon, AlertTriangle, FileEdit, GitMerge, Globe, HelpCircle, ShieldAlert, Terminal, Wifi } from './icons.js'; import { Alert, AlertDescription } from './primitives/alert.js'; import { Collapsible, CollapsibleTrigger, CollapsiblePanel } from './primitives/collapsible.js'; -import { Badge, Button as UiButton, Checkbox, AlertDialogContent, AlertDialogRoot } from './ui.js'; +import { Button as UiButton, Checkbox, AlertDialogContent, AlertDialogRoot } from './ui.js'; +import { Badge } from './primitives/badge.js'; import { redactSecrets } from './redact.js'; import { formatRedactedJson } from './tool-format.js'; diff --git a/packages/ui/src/plan-reminder-panel.tsx b/packages/ui/src/plan-reminder-panel.tsx index dbb37c8213..34fc911d62 100644 --- a/packages/ui/src/plan-reminder-panel.tsx +++ b/packages/ui/src/plan-reminder-panel.tsx @@ -52,7 +52,6 @@ import { toPlanReminderDateTimeInputValue, } from './plan-reminder-helpers.js'; import { - Badge, Button as UiButton, DialogClose, DialogContent, @@ -65,6 +64,7 @@ import { TabsTrigger, Textarea as UiTextarea, } from './ui.js'; +import { Badge } from './primitives/badge.js'; import { Alert, AlertDescription, AlertTitle } from './primitives/alert.js'; import { Menu, MenuItem, MenuPopup, MenuTrigger } from './primitives/menu.js'; import { EmptyState } from './empty-state.js'; diff --git a/packages/ui/src/primitives/chip.tsx b/packages/ui/src/primitives/chip.tsx new file mode 100644 index 0000000000..d352398ef7 --- /dev/null +++ b/packages/ui/src/primitives/chip.tsx @@ -0,0 +1,64 @@ +"use client"; + +import { mergeProps } from "@base-ui/react/merge-props"; +import { useRender } from "@base-ui/react/use-render"; +import { cn } from "../utils.js"; +import { cva, type VariantProps } from "class-variance-authority"; +import type React from "react"; + +// Chip is the squared, compact status label for dense information rows +// (settings connection status, capability chips, default markers). It is the +// squared counterpart to the pill Badge primitive: +// - Badge = emphasis marker (pill, radius-pill) — health/permission center +// - Chip = status label (squared, radius-control) — settings rows +// Variants mirror StatusTone so settings callers pass the tone straight +// through with no mapping function. Visual values reproduce the retired +// .settingsConnectionBadge oklch alphas (success /12, info /14, warning /18, +// destructive /15) and the .settingsBadge neutral (foreground-5) base. +export const chipVariants = cva( + "inline-flex items-center whitespace-nowrap rounded-[var(--radius-control)] text-xs outline-none [&_svg]:pointer-events-none [&_svg]:shrink-0", + { + defaultVariants: { + size: "default", + variant: "neutral", + }, + variants: { + size: { + default: + "min-h-5 px-[var(--space-2)] py-[var(--space-0-5)] font-semibold", + sm: "min-h-4.5 px-[var(--space-1-5)] py-0 font-normal", + }, + variant: { + neutral: "bg-secondary text-[var(--foreground-secondary)]", + info: "bg-info/14 text-info-foreground", + success: "bg-success/12 text-success", + warning: "bg-warning/18 text-warning-foreground font-bold", + destructive: "bg-destructive/15 text-destructive font-bold", + }, + }, + }, +); + +export interface ChipProps extends useRender.ComponentProps<"span"> { + variant?: VariantProps["variant"]; + size?: VariantProps["size"]; +} + +export function Chip({ + className, + variant, + size, + render, + ...props +}: ChipProps): React.ReactElement { + const defaultProps = { + className: cn(chipVariants({ className, size, variant })), + "data-slot": "chip", + }; + + return useRender({ + defaultTagName: "span", + props: mergeProps<"span">(defaultProps, props), + render, + }); +} \ No newline at end of file diff --git a/packages/ui/src/ui.tsx b/packages/ui/src/ui.tsx index 78ee88db97..2df7665851 100644 --- a/packages/ui/src/ui.tsx +++ b/packages/ui/src/ui.tsx @@ -114,33 +114,6 @@ export const Button = forwardRef(function Button( ); }); -export const badgeVariants = cva( - 'inline-flex items-center gap-1 rounded-[var(--radius-pill)] border px-2 py-0.5 text-xs font-medium tabular-nums', - { - variants: { - variant: { - default: 'border-transparent bg-accent/10 text-accent', - secondary: 'border-border bg-secondary text-secondary-foreground', - success: 'border-emerald-500/20 bg-emerald-500/10 text-emerald-700', - warning: 'border-amber-500/25 bg-amber-500/10 text-amber-800', - destructive: 'border-destructive/25 bg-destructive/10 text-destructive', - muted: 'border-border bg-muted text-foreground-secondary', - }, - }, - defaultVariants: { - variant: 'default', - }, - }, -); - -export interface BadgeProps - extends React.HTMLAttributes, - VariantProps {} - -export function Badge({ className, variant, ...props }: BadgeProps) { - return ; -} - export const inputClasses = [ 'flex min-h-9 w-full rounded-sm border border-input bg-[oklch(from_var(--foreground)_l_c_h_/_0.02)] px-3 py-2 text-sm text-foreground shadow-sm', 'placeholder:text-foreground-secondary/70', diff --git a/packages/ui/stories/badge.stories.tsx b/packages/ui/stories/badge.stories.tsx index 6bcfdfbf5c..4f5e6b9727 100644 --- a/packages/ui/stories/badge.stories.tsx +++ b/packages/ui/stories/badge.stories.tsx @@ -1,6 +1,5 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; -import { Badge } from '../src/ui.js'; -import { Badge as PrimitiveBadge } from '../src/primitives/badge.js'; +import { Badge } from '../src/primitives/badge.js'; const meta = { title: 'Primitives/Badge', @@ -13,43 +12,31 @@ export default meta; type Story = StoryObj; -const UI_VARIANTS = ['default', 'secondary', 'success', 'warning', 'destructive', 'muted'] as const; - -export const UiBadgeVariants: Story = { - render: () => ( -
- {UI_VARIANTS.map((variant) => ( -
- {variant} - {variant} - {variant} 12 -
- ))} -
- ), -}; - -const PRIM_VARIANTS = ['default', 'destructive', 'error', 'info', 'outline', 'secondary', 'success', 'warning'] as const; +// #520 PR9: the legacy ui.tsx Badge + .settingsBadge/.settingsConnectionBadge +// CSS chips collapsed onto this one primitive. Variants below cover every +// status tone statusBadgeVariant maps onto (success/warning/destructive/info +// /neutral-as-secondary) plus the rest of the shipped set. +const VARIANTS = ['default', 'destructive', 'error', 'info', 'outline', 'secondary', 'success', 'warning'] as const; const SIZES = ['sm', 'default', 'lg'] as const; -export const PrimitiveBadgeMatrix: Story = { +export const BadgeMatrix: Story = { render: () => (
- {PRIM_VARIANTS.map((v) => ( + {VARIANTS.map((v) => ( {v} ))}
{SIZES.map((size) => (
{size} - {PRIM_VARIANTS.map((variant) => ( - + {VARIANTS.map((variant) => ( + {variant} - + ))}
))}
), -}; \ No newline at end of file +}; From 0085d95efad1d372581576e6daf2f6ec698ced14 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 5 Jul 2026 23:46:25 +0800 Subject: [PATCH 3/5] =?UTF-8?q?fix(ui):=20address=20PR9=20review=20?= =?UTF-8?q?=E2=80=94=20Chip=20size=3Dsm=20for=20settingsBadge,=20token=20c?= =?UTF-8?q?ontract,=20comment=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #520 PR9 review fixes: - P2: the three .settingsBadge migration sites (provider-connection-detail x2, provider-add-form x1) now use Chip size="sm" to reproduce the retired .settingsBadge geometry (18px / font-normal / 0-6px padding). Without size="sm" they defaulted to the larger status-row size (20px / semibold / 2-8px padding) and drifted. The neutral background did not drift: bg-secondary aliases --color-secondary = var(--foreground-5), so only height/weight/padding moved. - P3-2: chip-converge-contract now locks user-visible tokens (neutral bg-secondary + foreground-secondary text, sm/default size geometry, status-tone alphas /12 /14 /18 /15) so a cva class change that keeps the import is still caught. Dropped the "no old span" migration-narrative loop; kept import + role-split + token assertions. - P3-3: bot.css / connection.css retire comments now say Chip variant="neutral" (not Badge variant="secondary"), matching the actual migration and not steering future migrants back to the pill Badge. Verification: 2050/2050 contract pass, typecheck clean, screenshot settings-bots light/dark 1280 + light 990 all AE=0 (pixel-identical to main after the size=sm fix; the 990 variant's earlier 13380 px diff is gone). --- .../__tests__/chip-converge-contract.test.ts | 40 ++++++++++++++----- .../renderer/settings/provider-add-form.tsx | 2 +- .../settings/provider-connection-detail.tsx | 4 +- .../src/renderer/styles/settings/bot.css | 5 ++- .../renderer/styles/settings/connection.css | 7 ++-- 5 files changed, 40 insertions(+), 18 deletions(-) diff --git a/apps/desktop/src/main/__tests__/chip-converge-contract.test.ts b/apps/desktop/src/main/__tests__/chip-converge-contract.test.ts index a494dd2e51..a9aecee532 100644 --- a/apps/desktop/src/main/__tests__/chip-converge-contract.test.ts +++ b/apps/desktop/src/main/__tests__/chip-converge-contract.test.ts @@ -10,14 +10,22 @@ const read = (rel: string) => readFileSync(join(REPO_ROOT, rel), 'utf8'); // primitive (squared, compact, status-tone), NOT the pill Badge primitive. // Badge and Chip are two distinct UI roles — pill Badge for emphasis markers // (health/permission center), squared Chip for dense settings status rows. -// This contract keeps the two tracks apart and locks Chip to radius-control. +// +// This contract locks the role split (Chip radius-control not pill, Badge +// stays pill) AND the user-visible tokens of Chip (neutral bg/text, sm/default +// size geometry, status-tone alphas) so a cva class change that preserves the +// import is still caught. Token values reproduce the retired +// .settingsBadge (sm: 18px/400/0-6px padding/foreground-5) and +// .settingsConnectionBadge (default: 20px/600/2-8px/foreground-5, tone alphas +// /12 /14 /18 /15) CSS so settings visuals do not drift. test('chip converge (#520 PR9)', async () => { - // 1. Chip primitive exists + carries data-slot const chipSrc = read('packages/ui/src/primitives/chip.tsx'); + + // 1. Chip primitive exists + carries data-slot assert.match(chipSrc, /export function Chip/, 'Chip primitive must be exported'); assert.match(chipSrc, /["']?data-slot["']?\s*[:=]\s*["']chip["']/, 'Chip must carry data-slot="chip"'); - // 2. Chip locks radius-control (squared), never pill + // 2. Chip locks radius-control (squared), never pill — role split with Badge assert.match(chipSrc, /rounded-\[var\(--radius-control\)\]/, 'Chip must use radius-control (squared, not pill)'); assert.doesNotMatch(chipSrc, /rounded-\[var\(--radius-pill\)\]/, 'Chip must not regress to pill'); @@ -25,13 +33,13 @@ test('chip converge (#520 PR9)', async () => { const indexSrc = read('packages/ui/src/index.ts'); assert.match(indexSrc, /export (?:\*|\{[^}]*\bChip\b[^}]*\}) from ['"]\.\/primitives\/chip\.js['"]/, 'index.ts must re-export Chip'); - // 4. settings CSS chips retired (.settingsBadge / .settingsConnectionBadge) + // 4. settings CSS chips retired const botCss = read('apps/desktop/src/renderer/styles/settings/bot.css'); assert.doesNotMatch(botCss, /\.settingsBadge\s*\{/, '.settingsBadge CSS rule must be retired'); const connCss = read('apps/desktop/src/renderer/styles/settings/connection.css'); assert.doesNotMatch(connCss, /\.settingsConnectionBadge\s*[\{[,]/, '.settingsConnectionBadge CSS rule must be retired'); - // 5. settings chip sites import and use Chip primitive, not the CSS spans + // 5. settings chip sites use the Chip primitive (squared status role, not pill Badge) const CHIP_IMPORT_RE = /import\s+\{[^}]*\bChip\b[^}]*\}\s+from\s+['"][^'"]*?(?:@maka\/ui|primitives\/chip\.js)['"]/; const settingsChipFiles = [ @@ -43,13 +51,25 @@ test('chip converge (#520 PR9)', async () => { 'apps/desktop/src/renderer/settings/provider-oauth-section.tsx', ]; for (const rel of settingsChipFiles) { - const src = read(rel); - assert.match(src, CHIP_IMPORT_RE, `${rel} must import Chip`); - assert.doesNotMatch(src, /className=["'][^"']*settingsBadge/, `${rel} must not use .settingsBadge span`); - assert.doesNotMatch(src, /className=["'][^"']*settingsConnectionBadge/, `${rel} must not use .settingsConnectionBadge span`); + assert.match(read(rel), CHIP_IMPORT_RE, `${rel} must import Chip`); } - // 6. Badge primitive stays pill — dual-track Badge (pill) + Chip (squared) preserved + // 6. Chip neutral variant tokens — bg = foreground-5 (bg-secondary aliases + // --color-secondary = var(--foreground-5)), text = foreground-secondary + assert.match(chipSrc, /neutral: "bg-secondary text-\[var\(--foreground-secondary\)\]"/, 'Chip neutral bg must be foreground-5 (bg-secondary) and text foreground-secondary'); + + // 7. Chip size tokens — sm reproduces .settingsBadge (18px/400/0-6px), + // default reproduces .settingsConnectionBadge (20px/600/2-8px) + assert.match(chipSrc, /min-h-4\.5 px-\[var\(--space-1-5\)\] py-0 font-normal/, 'Chip sm size must reproduce .settingsBadge (18px / 400 / 0-6px padding)'); + assert.match(chipSrc, /min-h-5 px-\[var\(--space-2\)\] py-\[var\(--space-0-5\)\] font-semibold/, 'Chip default size must reproduce .settingsConnectionBadge (20px / 600 / 2-8px padding)'); + + // 8. Chip status-tone variant tokens — reproduce retired CSS oklch alphas + assert.match(chipSrc, /bg-info\/14/, 'Chip info variant must keep /14 alpha (matches .settingsConnectionBadge info)'); + assert.match(chipSrc, /bg-success\/12/, 'Chip success variant must keep /12 alpha'); + assert.match(chipSrc, /bg-warning\/18/, 'Chip warning variant must keep /18 alpha'); + assert.match(chipSrc, /bg-destructive\/15/, 'Chip destructive variant must keep /15 alpha (not solid red)'); + + // 9. Badge primitive stays pill — dual-track Badge (pill) + Chip (squared) preserved const badgeSrc = read('packages/ui/src/primitives/badge.tsx'); assert.match(badgeSrc, /rounded-\[var\(--radius-pill\)\]/, 'Badge stays pill (dual-track with Chip)'); }); \ No newline at end of file diff --git a/apps/desktop/src/renderer/settings/provider-add-form.tsx b/apps/desktop/src/renderer/settings/provider-add-form.tsx index cef69f99a6..de6f655610 100644 --- a/apps/desktop/src/renderer/settings/provider-add-form.tsx +++ b/apps/desktop/src/renderer/settings/provider-add-form.tsx @@ -83,7 +83,7 @@ export function AddProviderForm(props: { : isExperimental ? '账号登录暂未接入聊天发送' : `添加 ${display.name}`}

{display.description}

- {categoryLabel(defaults.category)} + {categoryLabel(defaults.category)} {isExperimental && (
diff --git a/apps/desktop/src/renderer/settings/provider-connection-detail.tsx b/apps/desktop/src/renderer/settings/provider-connection-detail.tsx index 49d52114ea..f4377797a4 100644 --- a/apps/desktop/src/renderer/settings/provider-connection-detail.tsx +++ b/apps/desktop/src/renderer/settings/provider-connection-detail.tsx @@ -375,8 +375,8 @@ export function ConnectionDetail(props: {

{display.name}

- {props.isDefault && 默认} - {categoryLabel(defaults.category)} + {props.isDefault && 默认} + {categoryLabel(defaults.category)} diff --git a/apps/desktop/src/renderer/styles/settings/bot.css b/apps/desktop/src/renderer/styles/settings/bot.css index 562a37e541..8b7e8868b6 100644 --- a/apps/desktop/src/renderer/styles/settings/bot.css +++ b/apps/desktop/src/renderer/styles/settings/bot.css @@ -413,8 +413,9 @@ transition: background var(--duration-base) var(--ease-out-strong); } -/* #520 PR9: .settingsBadge retired onto the Badge primitive - (packages/ui/src/primitives/badge.tsx, variant="secondary"). */ +/* #520 PR9: .settingsBadge retired onto the Chip primitive variant="neutral" + (packages/ui/src/primitives/chip.tsx, size="sm"). Generic settings labels + stay squared (radius-control), not pill Badge. */ /* PR-SETTINGS-GROUPED-CARD-0 (WAWQAQ msg `1abecd66`): same grouped- card pattern as `.settingsStructuredPage` — outer card + hairline diff --git a/apps/desktop/src/renderer/styles/settings/connection.css b/apps/desktop/src/renderer/styles/settings/connection.css index b0976ad317..0bad1f1bef 100644 --- a/apps/desktop/src/renderer/styles/settings/connection.css +++ b/apps/desktop/src/renderer/styles/settings/connection.css @@ -126,9 +126,10 @@ font-family: var(--font-mono); font-size: var(--font-size-caption); } -/* #520 PR9: .settingsConnectionBadge + data-tone retired onto the Badge - primitive + statusBadgeVariant (packages/ui/src/primitives/badge.tsx + - settings-status-badge.ts). The warning tone no longer misuses --info. */ +/* #520 PR9: .settingsConnectionBadge + data-tone retired onto the Chip + primitive (packages/ui/src/primitives/chip.tsx); variants mirror StatusTone + directly, so settings callers pass the tone without statusBadgeVariant. The + warning tone no longer misuses --info. */ .settingsConnectionDetail { margin: 0; color: var(--foreground-secondary); From 9d9838b8644252d66672ad77086a822cdc54f2e7 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 5 Jul 2026 23:56:43 +0800 Subject: [PATCH 4/5] refactor(ui): retire premature Table primitive per review (#520 PR9 P3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #520 PR9 review P3-1: the Table primitive had a single consumer (usage-settings-page SimpleStatsTable) and no second HTML consumer in sight — the model "table" is a div-based list (.modelTable), not an HTML table, so it will never migrate onto Table. Per Occam's razor, lift the primitive only when a second real consumer appears. - Delete packages/ui/src/primitives/table.tsx and its barrel export. - SimpleStatsTable in usage-settings-page now renders a native HTML
////
/ with the same Tailwind classes the primitive applied (w-full border-collapse rounded-[var(--radius-surface)] border border-border text-caption + cell border-b/px/py/align/ tabular-nums). Styles stay inline so the stats surface is self-contained until a second HTML consumer justifies lifting it back. - Rename card-table-converge-contract → card-converge-contract: drop the Table data-slot / table-sites assertions, keep Card. Note the usage stats table a11y semantics (aria-label + scope) now live in settings-usage-contract, whose assertions are updated to match the native
/`) and a row header (`scope="row"` on - * the first `
shape. - settings-form-a11y and tabular-nums are untouched: they assert on the .modelTable CSS class (a div surface), not on the Table primitive. Verification: 2049/2049 contract pass (one fewer test — the deleted "table sites import Table" case), typecheck clean. Screenshot pixel diff vs main: settings-general light/dark 1280 AE=0; settings-data 1280 shows ~4500 px (RMSE 0.001) in the `data` section, which is main-branch composer/sidebar drift (#510/#511), not this change — the usage stats table lives in the `usage` section and its styles are identical to the retired primitive. --- ...test.ts => card-converge-contract.test.ts} | 40 +++----- .../__tests__/settings-usage-contract.test.ts | 6 +- .../renderer/settings/usage-settings-page.tsx | 35 ++++--- packages/ui/src/index.ts | 1 - packages/ui/src/primitives/table.tsx | 98 ------------------- 5 files changed, 36 insertions(+), 144 deletions(-) rename apps/desktop/src/main/__tests__/{card-table-converge-contract.test.ts => card-converge-contract.test.ts} (60%) delete mode 100644 packages/ui/src/primitives/table.tsx diff --git a/apps/desktop/src/main/__tests__/card-table-converge-contract.test.ts b/apps/desktop/src/main/__tests__/card-converge-contract.test.ts similarity index 60% rename from apps/desktop/src/main/__tests__/card-table-converge-contract.test.ts rename to apps/desktop/src/main/__tests__/card-converge-contract.test.ts index fc2d7dbb94..482390cbdf 100644 --- a/apps/desktop/src/main/__tests__/card-table-converge-contract.test.ts +++ b/apps/desktop/src/main/__tests__/card-converge-contract.test.ts @@ -1,18 +1,21 @@ /** - * CARD-TABLE-CONVERGE-0 (issue #520 PR9): the four hand-written card/table - * surfaces migrate onto shared Card/Table primitives so the container is the - * primitive (with `data-slot`), not a bare `
`/`` carrying a - * hand-rolled class. + * CARD-CONVERGE-0 (issue #520 PR9): the hand-written settings card surfaces + * migrate onto a shared Card primitive so the container is the primitive (with + * `data-slot`), not a bare `
` carrying a hand-rolled class. * * - settingsRows (row-list container) + settingsMetricCard (metric tile) + * maka-error-card (crash surface) → Card - * - settingsStatsTable (usage stats) → Table (shadcn-style family) * * Card is intentionally thin (`data-slot="card"` + radius-surface): each site * keeps its own layout/visual CSS, but the radius now comes from Card and the * element carries `data-slot="card"`. maka-error-card stays on Card (not Alert) * because it is a large crash surface with shadow-modal + stack
, not a
  * small inline callout.
+ *
+ * The usage stats table is NOT on a public Table primitive: with only one HTML
+ * 
consumer it was premature abstraction (PR9 review P3), so + * SimpleStatsTable keeps its styles inline in usage-settings-page. The table + * a11y semantics (aria-label + scope) are locked in settings-usage-contract. */ import { strict as assert } from 'node:assert'; @@ -35,25 +38,15 @@ const SETTINGS_ROWS_CONSUMERS = [ 'apps/desktop/src/renderer/settings/memory-settings-page.tsx', ]; -/** Site whose table becomes the Table family. */ -const TABLE_SITES = ['apps/desktop/src/renderer/settings/usage-settings-page.tsx']; - const CARD_PRIMITIVE = 'packages/ui/src/primitives/card.tsx'; -const TABLE_PRIMITIVE = 'packages/ui/src/primitives/table.tsx'; const CARD_IMPORT_RE = /import\s+\{[^}]*\bCard\b[^}]*\}\s+from\s+['"][^'"]*(?:@maka\/ui|primitives\/card)['"]/; -const TABLE_IMPORT_RE = - /import\s+\{[^}]*\bTable\w*\b[^}]*\}\s+from\s+['"][^'"]*(?:@maka\/ui|primitives\/table)['"]/; -describe('card/table converge (#520 PR9)', () => { - it('ships Card and Table primitives with data-slot', async () => { +describe('card converge (#520 PR9)', () => { + it('ships Card primitive with data-slot', async () => { const card = await readFile(resolve(REPO_ROOT, CARD_PRIMITIVE), 'utf8'); assert.match(card, /data-slot=["']card["']/, 'Card primitive must carry data-slot="card"'); - - const table = await readFile(resolve(REPO_ROOT, TABLE_PRIMITIVE), 'utf8'); - assert.match(table, /data-slot=["']table["']/, 'Table primitive must carry data-slot="table"'); - assert.match(table, /TableHeader|TableBody|TableRow|TableHead|TableCell/, 'Table family must ship subcomponents'); }); it('card sites import Card', async () => { @@ -72,15 +65,4 @@ describe('card/table converge (#520 PR9)', () => { ); } }); - - it('table sites import Table and drop the bare
', async () => { - for (const rel of TABLE_SITES) { - const src = await readFile(resolve(REPO_ROOT, rel), 'utf8'); - assert.ok(TABLE_IMPORT_RE.test(src), `${rel} must import Table from @maka/ui`); - assert.ok( - !/ { ); assert.match( simpleStatsTable, - /
/, + /\{props\.headers\.map\(\(header\) => \{header\}<\/TableHead>\)\}<\/TableRow>/, + / +
\{cell\}<\/TableHead>\s*\) : \(\s*\{cell\}<\/TableCell>\s*\)/, + /cellIndex === 0 \? \(\s*>; empty?: string }) { + // Local table styles reproduce the retired Table primitive (now removed — a + // single consumer did not justify a public primitive). Values are inline so + // the stats surface stays self-contained until a second HTML consumer + // appears, at which point this can lift back to packages/ui. + const headClass = "border-b border-border px-[var(--space-2)] py-[var(--space-1)] text-left align-middle font-semibold text-foreground-secondary [font-variant-numeric:tabular-nums]"; + const cellClass = "border-b border-border px-[var(--space-2)] py-[var(--space-1)] text-left align-middle text-foreground-secondary [font-variant-numeric:tabular-nums]"; return ( -
- - {props.headers.map((header) => {header})} - - +
+ + {props.headers.map((header) => )} + + {props.rows.length === 0 ? ( - {props.empty ?? '暂无请求记录'} + ) : props.rows.map((row, rowIndex) => ( - + {row.map((cell, cellIndex) => ( cellIndex === 0 ? ( - {cell} + ) : ( - {cell} + ) ))} - + ))} - -
{header}
{props.empty ?? '暂无请求记录'}
{cell}{cell}
+
); } diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index c869fdb898..7f50e728b3 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -31,7 +31,6 @@ export * from './bot-brand.js'; export * from './bot-brand-logo.js'; export * from './primitives/alert.js'; export * from './primitives/card.js'; -export * from './primitives/table.js'; // `markerVariants` / `streamVariants` / `toolVariants` / `LiveIndicator` are // deliberately NOT re-exported here: they are internal styling tables / a // single-consumer dot that the chat call sites apply via relative import, so diff --git a/packages/ui/src/primitives/table.tsx b/packages/ui/src/primitives/table.tsx deleted file mode 100644 index 025d98deb7..0000000000 --- a/packages/ui/src/primitives/table.tsx +++ /dev/null @@ -1,98 +0,0 @@ -"use client"; - -import { forwardRef } from "react"; -import type { ComponentPropsWithoutRef } from "react"; -import { cn } from "../utils.js"; - -/** - * Table — shadcn-style table family for the settings stats surface, so the - * table container carries `data-slot="table"` instead of a hand-rolled - * `.settingsStatsTable` class. The table itself owns the surface chrome - * (border + radius + caption font-size); the row/cell members own the - * tabular-nums + hairline row separators + caption-tone color that the old - * `.settingsStatsTable th, .settingsStatsTable td` rules supplied. - * - * `scope` is left to the caller: the usage-stats table has both column - * headers (`scope="col"` in `
` of each body row), so baking in a default would lie. - */ -export type TableProps = ComponentPropsWithoutRef<"table">; - -export const Table = forwardRef(function Table( - { className, ...props }, - ref, -) { - return ( - - ); -}); - -export const TableHeader = forwardRef< - HTMLTableSectionElement, - ComponentPropsWithoutRef<"thead"> ->(function TableHeader({ className, ...props }, ref) { - return ; -}); - -export const TableBody = forwardRef< - HTMLTableSectionElement, - ComponentPropsWithoutRef<"tbody"> ->(function TableBody({ className, ...props }, ref) { - return ; -}); - -export const TableRow = forwardRef< - HTMLTableRowElement, - ComponentPropsWithoutRef<"tr"> ->(function TableRow({ className, ...props }, ref) { - return ( - - ); -}); - -export const TableHead = forwardRef< - HTMLTableCellElement, - ComponentPropsWithoutRef<"th"> ->(function TableHead({ className, ...props }, ref) { - return ( -
- ); -}); - -export const TableCell = forwardRef< - HTMLTableCellElement, - ComponentPropsWithoutRef<"td"> ->(function TableCell({ className, ...props }, ref) { - return ( - - ); -}); From 4c225c50e92cf2c0cda621d02504aabf895a0fa6 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 6 Jul 2026 00:43:11 +0800 Subject: [PATCH 5/5] docs(css): fix .settingsStatsTable retire comment to point at local SimpleStatsTable Per PR9 review P3: the retire comment still referenced the deleted packages/ui/src/primitives/table.tsx, which would mislead a future maintainer into restoring a public Table primitive. Now points at the local native SimpleStatsTable in usage-settings-page.tsx, matching the actual state. --- apps/desktop/src/renderer/styles/settings/bot.css | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/renderer/styles/settings/bot.css b/apps/desktop/src/renderer/styles/settings/bot.css index 8b7e8868b6..50f3588ead 100644 --- a/apps/desktop/src/renderer/styles/settings/bot.css +++ b/apps/desktop/src/renderer/styles/settings/bot.css @@ -373,10 +373,9 @@ font-size: var(--font-size-ui); } -/* #520 PR9: .settingsStatsTable retired onto the Table primitive family - (packages/ui/src/primitives/table.tsx). Table carries the surface chrome - (border + radius + caption font); TableHead/TableCell carry the - tabular-nums + hairline row separators + caption-tone color. */ +/* #520 PR9: .settingsStatsTable retired onto the local native + SimpleStatsTable in usage-settings-page.tsx (inline Tailwind classes, + no public primitive — a single HTML consumer did not justify one). */ .settingsCloseButton { width: 24px;