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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions apps/desktop/src/main/__tests__/badge-converge-contract.test.ts
Original file line numberDiff line numberDiff line change
@@ -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 `<Badge>`. The two settings CSS chips (3, 4) route
* through the squared `<Chip>` 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 <PrimitiveBadge> remains (aliased name retired)', async () => {
for (const rel of MIGRATED_FILES) {
const src = await readFile(resolve(REPO_ROOT, rel), 'utf8');
assert.ok(!/<PrimitiveBadge\b/.test(src), `${rel} must use <Badge>, not <PrimitiveBadge>`);
}
});

});
68 changes: 68 additions & 0 deletions apps/desktop/src/main/__tests__/card-converge-contract.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
/**
* 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 `<div>` carrying a hand-rolled class.
*
* - settingsRows (row-list container) + settingsMetricCard (metric tile) +
* maka-error-card (crash surface) → Card
*
* 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 <pre>, not a
* small inline callout.
*
* The usage stats table is NOT on a public Table primitive: with only one HTML
* <table> 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';
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 <Card>. */
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 <div className="settingsRows"> 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',
];

const CARD_PRIMITIVE = 'packages/ui/src/primitives/card.tsx';

const CARD_IMPORT_RE =
/import\s+\{[^}]*\bCard\b[^}]*\}\s+from\s+['"][^'"]*(?:@maka\/ui|primitives\/card)['"]/;

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"');
});

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 <div className="settingsRows">', async () => {
for (const rel of SETTINGS_ROWS_CONSUMERS) {
const src = await readFile(resolve(REPO_ROOT, rel), 'utf8');
assert.ok(
!/<div\s+className=["'][^"']*\bsettingsRows\b/.test(src),
`${rel} must route through SettingsRows/Card, not a bare div.settingsRows`,
);
}
});
});
75 changes: 75 additions & 0 deletions apps/desktop/src/main/__tests__/chip-converge-contract.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
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 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 () => {
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 — 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');

// 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
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 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 = [
'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) {
assert.match(read(rel), CHIP_IMPORT_RE, `${rel} must import Chip`);
}

// 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)');
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.tsxbadgeVariants 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' },
Expand DownExpand Up@@ -383,7 +383,6 @@ describe('radius token governance (#406 gap 4)', () => {
'.settingsHealthError': '--radius-surface',
'.settingsHealthRefresh': '--radius-control',
'.settingsBotHero': '--radius-surface',
'.settingsRows': '--radius-surface',
'.settingsNotice': '--radius-surface',
'.settingsAboutLogo': '--radius-surface',
'.settingsAboutPrivacy': '--radius-surface',
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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');
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -129,17 +129,17 @@ describe('Settings usage dashboard contract', () => {
);
assert.match(
simpleStatsTable,
/<table className="settingsStatsTable" aria-label=\{props\.ariaLabel\}>/,
/<table\s+aria-label=\{props\.ariaLabel\}/,
'Usage stats table must expose its caller-provided name',
);
assert.match(
simpleStatsTable,
/<tr>\{props\.headers\.map\(\(header\) => <th key=\{header\} scope="col">\{header\}<\/th>\)\}<\/tr>/,
/<th key=\{header\} scope="col"/,
'Usage stats table column headers must expose column scope',
);
assert.match(
simpleStatsTable,
/cellIndex === 0 \? \(\s*<th key=\{cellIndex\} scope="row">\{cell\}<\/th>\s*\) : \(\s*<td key=\{cellIndex\}>\{cell\}<\/td>\s*\)/,
/cellIndex === 0 \? \(\s*<th key=\{cellIndex\} scope="row"/,
'Usage stats table rows must expose the first data cell as a scoped row header',
);
assert.doesNotMatch(
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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',
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/src/main/__tests__/web-search-boundary.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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],
/<div className="settingsRows settingsWebSearchCredentialCard">/,
/<SettingsRows className="settingsWebSearchCredentialCard">/,
'Web search credential controls should sit in the shared grouped Settings card primitive',
);
assert.match(
page![0],
/<div className="settingsRows settingsWebSearchQueryCard">/,
/<SettingsRows className="settingsWebSearchQueryCard">/,
'Web search live-query controls should sit in the shared grouped Settings card primitive',
);
for (const rowClass of [
Expand Down
6 changes: 3 additions & 3 deletions apps/desktop/src/renderer/error-boundary.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand DownExpand Up@@ -101,7 +101,7 @@ export class ErrorBoundary extends Component<{ children: ReactNode }, State> {

return (
<div className="maka-error-surface" role="alert" aria-live="assertive">
<div className="maka-error-card">
<Card className="maka-error-card">
<span className="maka-error-icon" aria-hidden="true">
<AlertTriangle size={28} strokeWidth={1.6} />
</span>
Expand DownExpand Up@@ -148,7 +148,7 @@ export class ErrorBoundary extends Component<{ children: ReactNode }, State> {
<p className="maka-error-copy-status">剪贴板不可用或被系统拒绝;可以手动选择上面的错误摘要。</p>
)}
</div>
</div>
</Card>
</div>
);
}
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
feat(ui): converge card/table + badge surfaces onto primitives (#520 PR9) by Astro-Han · Pull Request #554 · apache/maka · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions apps/desktop/src/main/__tests__/badge-converge-contract.test.ts
Original file line numberDiff line numberDiff line change
@@ -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 `<Badge>`. The two settings CSS chips (3, 4) route
* through the squared `<Chip>` 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 <PrimitiveBadge> remains (aliased name retired)', async () => {
for (const rel of MIGRATED_FILES) {
const src = await readFile(resolve(REPO_ROOT, rel), 'utf8');
assert.ok(!/<PrimitiveBadge\b/.test(src), `${rel} must use <Badge>, not <PrimitiveBadge>`);
}
});

});
68 changes: 68 additions & 0 deletions apps/desktop/src/main/__tests__/card-converge-contract.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
/**
* 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 `<div>` carrying a hand-rolled class.
*
* - settingsRows (row-list container) + settingsMetricCard (metric tile) +
* maka-error-card (crash surface) → Card
*
* 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 <pre>, not a
* small inline callout.
*
* The usage stats table is NOT on a public Table primitive: with only one HTML
* <table> 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';
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 <Card>. */
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 <div className="settingsRows"> 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',
];

const CARD_PRIMITIVE = 'packages/ui/src/primitives/card.tsx';

const CARD_IMPORT_RE =
/import\s+\{[^}]*\bCard\b[^}]*\}\s+from\s+['"][^'"]*(?:@maka\/ui|primitives\/card)['"]/;

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"');
});

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 <div className="settingsRows">', async () => {
for (const rel of SETTINGS_ROWS_CONSUMERS) {
const src = await readFile(resolve(REPO_ROOT, rel), 'utf8');
assert.ok(
!/<div\s+className=["'][^"']*\bsettingsRows\b/.test(src),
`${rel} must route through SettingsRows/Card, not a bare div.settingsRows`,
);
}
});
});
75 changes: 75 additions & 0 deletions apps/desktop/src/main/__tests__/chip-converge-contract.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
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 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 () => {
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 — 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');

// 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
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 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 = [
'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) {
assert.match(read(rel), CHIP_IMPORT_RE, `${rel} must import Chip`);
}

// 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)');
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.tsxbadgeVariants 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' },
Expand DownExpand Up@@ -383,7 +383,6 @@ describe('radius token governance (#406 gap 4)', () => {
'.settingsHealthError': '--radius-surface',
'.settingsHealthRefresh': '--radius-control',
'.settingsBotHero': '--radius-surface',
'.settingsRows': '--radius-surface',
'.settingsNotice': '--radius-surface',
'.settingsAboutLogo': '--radius-surface',
'.settingsAboutPrivacy': '--radius-surface',
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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');
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -129,17 +129,17 @@ describe('Settings usage dashboard contract', () => {
);
assert.match(
simpleStatsTable,
/<table className="settingsStatsTable" aria-label=\{props\.ariaLabel\}>/,
/<table\s+aria-label=\{props\.ariaLabel\}/,
'Usage stats table must expose its caller-provided name',
);
assert.match(
simpleStatsTable,
/<tr>\{props\.headers\.map\(\(header\) => <th key=\{header\} scope="col">\{header\}<\/th>\)\}<\/tr>/,
/<th key=\{header\} scope="col"/,
'Usage stats table column headers must expose column scope',
);
assert.match(
simpleStatsTable,
/cellIndex === 0 \? \(\s*<th key=\{cellIndex\} scope="row">\{cell\}<\/th>\s*\) : \(\s*<td key=\{cellIndex\}>\{cell\}<\/td>\s*\)/,
/cellIndex === 0 \? \(\s*<th key=\{cellIndex\} scope="row"/,
'Usage stats table rows must expose the first data cell as a scoped row header',
);
assert.doesNotMatch(
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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',
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/src/main/__tests__/web-search-boundary.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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],
/<div className="settingsRows settingsWebSearchCredentialCard">/,
/<SettingsRows className="settingsWebSearchCredentialCard">/,
'Web search credential controls should sit in the shared grouped Settings card primitive',
);
assert.match(
page![0],
/<div className="settingsRows settingsWebSearchQueryCard">/,
/<SettingsRows className="settingsWebSearchQueryCard">/,
'Web search live-query controls should sit in the shared grouped Settings card primitive',
);
for (const rowClass of [
Expand Down
6 changes: 3 additions & 3 deletions apps/desktop/src/renderer/error-boundary.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand DownExpand Up@@ -101,7 +101,7 @@ export class ErrorBoundary extends Component<{ children: ReactNode }, State> {

return (
<div className="maka-error-surface" role="alert" aria-live="assertive">
<div className="maka-error-card">
<Card className="maka-error-card">
<span className="maka-error-icon" aria-hidden="true">
<AlertTriangle size={28} strokeWidth={1.6} />
</span>
Expand DownExpand Up@@ -148,7 +148,7 @@ export class ErrorBoundary extends Component<{ children: ReactNode }, State> {
<p className="maka-error-copy-status">剪贴板不可用或被系统拒绝;可以手动选择上面的错误摘要。</p>
)}
</div>
</div>
</Card>
</div>
);
}
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(ui): converge card/table + badge surfaces onto primitives (#520 PR9) by Astro-Han · Pull Request #554 · apache/maka · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions apps/desktop/src/main/__tests__/badge-converge-contract.test.ts
Original file line numberDiff line numberDiff line change
@@ -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 `<Badge>`. The two settings CSS chips (3, 4) route
* through the squared `<Chip>` 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 <PrimitiveBadge> remains (aliased name retired)', async () => {
for (const rel of MIGRATED_FILES) {
const src = await readFile(resolve(REPO_ROOT, rel), 'utf8');
assert.ok(!/<PrimitiveBadge\b/.test(src), `${rel} must use <Badge>, not <PrimitiveBadge>`);
}
});

});
68 changes: 68 additions & 0 deletions apps/desktop/src/main/__tests__/card-converge-contract.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
/**
* 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 `<div>` carrying a hand-rolled class.
*
* - settingsRows (row-list container) + settingsMetricCard (metric tile) +
* maka-error-card (crash surface) → Card
*
* 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 <pre>, not a
* small inline callout.
*
* The usage stats table is NOT on a public Table primitive: with only one HTML
* <table> 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';
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 <Card>. */
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 <div className="settingsRows"> 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',
];

const CARD_PRIMITIVE = 'packages/ui/src/primitives/card.tsx';

const CARD_IMPORT_RE =
/import\s+\{[^}]*\bCard\b[^}]*\}\s+from\s+['"][^'"]*(?:@maka\/ui|primitives\/card)['"]/;

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"');
});

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 <div className="settingsRows">', async () => {
for (const rel of SETTINGS_ROWS_CONSUMERS) {
const src = await readFile(resolve(REPO_ROOT, rel), 'utf8');
assert.ok(
!/<div\s+className=["'][^"']*\bsettingsRows\b/.test(src),
`${rel} must route through SettingsRows/Card, not a bare div.settingsRows`,
);
}
});
});
75 changes: 75 additions & 0 deletions apps/desktop/src/main/__tests__/chip-converge-contract.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
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 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 () => {
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 — 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');

// 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
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 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 = [
'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) {
assert.match(read(rel), CHIP_IMPORT_RE, `${rel} must import Chip`);
}

// 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)');
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.tsxbadgeVariants 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' },
Expand DownExpand Up@@ -383,7 +383,6 @@ describe('radius token governance (#406 gap 4)', () => {
'.settingsHealthError': '--radius-surface',
'.settingsHealthRefresh': '--radius-control',
'.settingsBotHero': '--radius-surface',
'.settingsRows': '--radius-surface',
'.settingsNotice': '--radius-surface',
'.settingsAboutLogo': '--radius-surface',
'.settingsAboutPrivacy': '--radius-surface',
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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');
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -129,17 +129,17 @@ describe('Settings usage dashboard contract', () => {
);
assert.match(
simpleStatsTable,
/<table className="settingsStatsTable" aria-label=\{props\.ariaLabel\}>/,
/<table\s+aria-label=\{props\.ariaLabel\}/,
'Usage stats table must expose its caller-provided name',
);
assert.match(
simpleStatsTable,
/<tr>\{props\.headers\.map\(\(header\) => <th key=\{header\} scope="col">\{header\}<\/th>\)\}<\/tr>/,
/<th key=\{header\} scope="col"/,
'Usage stats table column headers must expose column scope',
);
assert.match(
simpleStatsTable,
/cellIndex === 0 \? \(\s*<th key=\{cellIndex\} scope="row">\{cell\}<\/th>\s*\) : \(\s*<td key=\{cellIndex\}>\{cell\}<\/td>\s*\)/,
/cellIndex === 0 \? \(\s*<th key=\{cellIndex\} scope="row"/,
'Usage stats table rows must expose the first data cell as a scoped row header',
);
assert.doesNotMatch(
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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',
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/src/main/__tests__/web-search-boundary.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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],
/<div className="settingsRows settingsWebSearchCredentialCard">/,
/<SettingsRows className="settingsWebSearchCredentialCard">/,
'Web search credential controls should sit in the shared grouped Settings card primitive',
);
assert.match(
page![0],
/<div className="settingsRows settingsWebSearchQueryCard">/,
/<SettingsRows className="settingsWebSearchQueryCard">/,
'Web search live-query controls should sit in the shared grouped Settings card primitive',
);
for (const rowClass of [
Expand Down
6 changes: 3 additions & 3 deletions apps/desktop/src/renderer/error-boundary.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand DownExpand Up@@ -101,7 +101,7 @@ export class ErrorBoundary extends Component<{ children: ReactNode }, State> {

return (
<div className="maka-error-surface" role="alert" aria-live="assertive">
<div className="maka-error-card">
<Card className="maka-error-card">
<span className="maka-error-icon" aria-hidden="true">
<AlertTriangle size={28} strokeWidth={1.6} />
</span>
Expand DownExpand Up@@ -148,7 +148,7 @@ export class ErrorBoundary extends Component<{ children: ReactNode }, State> {
<p className="maka-error-copy-status">剪贴板不可用或被系统拒绝;可以手动选择上面的错误摘要。</p>
)}
</div>
</div>
</Card>
</div>
);
}
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(ui): converge card/table + badge surfaces onto primitives (#520 PR9) by Astro-Han · Pull Request #554 · apache/maka · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions apps/desktop/src/main/__tests__/badge-converge-contract.test.ts
Original file line numberDiff line numberDiff line change
@@ -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 `<Badge>`. The two settings CSS chips (3, 4) route
* through the squared `<Chip>` 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 <PrimitiveBadge> remains (aliased name retired)', async () => {
for (const rel of MIGRATED_FILES) {
const src = await readFile(resolve(REPO_ROOT, rel), 'utf8');
assert.ok(!/<PrimitiveBadge\b/.test(src), `${rel} must use <Badge>, not <PrimitiveBadge>`);
}
});

});
68 changes: 68 additions & 0 deletions apps/desktop/src/main/__tests__/card-converge-contract.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
/**
* 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 `<div>` carrying a hand-rolled class.
*
* - settingsRows (row-list container) + settingsMetricCard (metric tile) +
* maka-error-card (crash surface) → Card
*
* 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 <pre>, not a
* small inline callout.
*
* The usage stats table is NOT on a public Table primitive: with only one HTML
* <table> 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';
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 <Card>. */
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 <div className="settingsRows"> 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',
];

const CARD_PRIMITIVE = 'packages/ui/src/primitives/card.tsx';

const CARD_IMPORT_RE =
/import\s+\{[^}]*\bCard\b[^}]*\}\s+from\s+['"][^'"]*(?:@maka\/ui|primitives\/card)['"]/;

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"');
});

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 <div className="settingsRows">', async () => {
for (const rel of SETTINGS_ROWS_CONSUMERS) {
const src = await readFile(resolve(REPO_ROOT, rel), 'utf8');
assert.ok(
!/<div\s+className=["'][^"']*\bsettingsRows\b/.test(src),
`${rel} must route through SettingsRows/Card, not a bare div.settingsRows`,
);
}
});
});
75 changes: 75 additions & 0 deletions apps/desktop/src/main/__tests__/chip-converge-contract.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
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 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 () => {
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 — 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');

// 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
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 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 = [
'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) {
assert.match(read(rel), CHIP_IMPORT_RE, `${rel} must import Chip`);
}

// 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)');
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.tsxbadgeVariants 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' },
Expand DownExpand Up@@ -383,7 +383,6 @@ describe('radius token governance (#406 gap 4)', () => {
'.settingsHealthError': '--radius-surface',
'.settingsHealthRefresh': '--radius-control',
'.settingsBotHero': '--radius-surface',
'.settingsRows': '--radius-surface',
'.settingsNotice': '--radius-surface',
'.settingsAboutLogo': '--radius-surface',
'.settingsAboutPrivacy': '--radius-surface',
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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');
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -129,17 +129,17 @@ describe('Settings usage dashboard contract', () => {
);
assert.match(
simpleStatsTable,
/<table className="settingsStatsTable" aria-label=\{props\.ariaLabel\}>/,
/<table\s+aria-label=\{props\.ariaLabel\}/,
'Usage stats table must expose its caller-provided name',
);
assert.match(
simpleStatsTable,
/<tr>\{props\.headers\.map\(\(header\) => <th key=\{header\} scope="col">\{header\}<\/th>\)\}<\/tr>/,
/<th key=\{header\} scope="col"/,
'Usage stats table column headers must expose column scope',
);
assert.match(
simpleStatsTable,
/cellIndex === 0 \? \(\s*<th key=\{cellIndex\} scope="row">\{cell\}<\/th>\s*\) : \(\s*<td key=\{cellIndex\}>\{cell\}<\/td>\s*\)/,
/cellIndex === 0 \? \(\s*<th key=\{cellIndex\} scope="row"/,
'Usage stats table rows must expose the first data cell as a scoped row header',
);
assert.doesNotMatch(
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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',
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/src/main/__tests__/web-search-boundary.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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],
/<div className="settingsRows settingsWebSearchCredentialCard">/,
/<SettingsRows className="settingsWebSearchCredentialCard">/,
'Web search credential controls should sit in the shared grouped Settings card primitive',
);
assert.match(
page![0],
/<div className="settingsRows settingsWebSearchQueryCard">/,
/<SettingsRows className="settingsWebSearchQueryCard">/,
'Web search live-query controls should sit in the shared grouped Settings card primitive',
);
for (const rowClass of [
Expand Down
6 changes: 3 additions & 3 deletions apps/desktop/src/renderer/error-boundary.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand DownExpand Up@@ -101,7 +101,7 @@ export class ErrorBoundary extends Component<{ children: ReactNode }, State> {

return (
<div className="maka-error-surface" role="alert" aria-live="assertive">
<div className="maka-error-card">
<Card className="maka-error-card">
<span className="maka-error-icon" aria-hidden="true">
<AlertTriangle size={28} strokeWidth={1.6} />
</span>
Expand DownExpand Up@@ -148,7 +148,7 @@ export class ErrorBoundary extends Component<{ children: ReactNode }, State> {
<p className="maka-error-copy-status">剪贴板不可用或被系统拒绝;可以手动选择上面的错误摘要。</p>
)}
</div>
</div>
</Card>
</div>
);
}
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' feat(ui): converge card/table + badge surfaces onto primitives (#520 PR9) by Astro-Han · Pull Request #554 · apache/maka · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions apps/desktop/src/main/__tests__/badge-converge-contract.test.ts
Original file line numberDiff line numberDiff line change
@@ -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 `<Badge>`. The two settings CSS chips (3, 4) route
* through the squared `<Chip>` 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 <PrimitiveBadge> remains (aliased name retired)', async () => {
for (const rel of MIGRATED_FILES) {
const src = await readFile(resolve(REPO_ROOT, rel), 'utf8');
assert.ok(!/<PrimitiveBadge\b/.test(src), `${rel} must use <Badge>, not <PrimitiveBadge>`);
}
});

});
68 changes: 68 additions & 0 deletions apps/desktop/src/main/__tests__/card-converge-contract.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
/**
* 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 `<div>` carrying a hand-rolled class.
*
* - settingsRows (row-list container) + settingsMetricCard (metric tile) +
* maka-error-card (crash surface) → Card
*
* 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 <pre>, not a
* small inline callout.
*
* The usage stats table is NOT on a public Table primitive: with only one HTML
* <table> 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';
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 <Card>. */
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 <div className="settingsRows"> 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',
];

const CARD_PRIMITIVE = 'packages/ui/src/primitives/card.tsx';

const CARD_IMPORT_RE =
/import\s+\{[^}]*\bCard\b[^}]*\}\s+from\s+['"][^'"]*(?:@maka\/ui|primitives\/card)['"]/;

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"');
});

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 <div className="settingsRows">', async () => {
for (const rel of SETTINGS_ROWS_CONSUMERS) {
const src = await readFile(resolve(REPO_ROOT, rel), 'utf8');
assert.ok(
!/<div\s+className=["'][^"']*\bsettingsRows\b/.test(src),
`${rel} must route through SettingsRows/Card, not a bare div.settingsRows`,
);
}
});
});
75 changes: 75 additions & 0 deletions apps/desktop/src/main/__tests__/chip-converge-contract.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
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 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 () => {
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 — 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');

// 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
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 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 = [
'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) {
assert.match(read(rel), CHIP_IMPORT_RE, `${rel} must import Chip`);
}

// 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)');
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.tsxbadgeVariants 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' },
Expand DownExpand Up@@ -383,7 +383,6 @@ describe('radius token governance (#406 gap 4)', () => {
'.settingsHealthError': '--radius-surface',
'.settingsHealthRefresh': '--radius-control',
'.settingsBotHero': '--radius-surface',
'.settingsRows': '--radius-surface',
'.settingsNotice': '--radius-surface',
'.settingsAboutLogo': '--radius-surface',
'.settingsAboutPrivacy': '--radius-surface',
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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');
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -129,17 +129,17 @@ describe('Settings usage dashboard contract', () => {
);
assert.match(
simpleStatsTable,
/<table className="settingsStatsTable" aria-label=\{props\.ariaLabel\}>/,
/<table\s+aria-label=\{props\.ariaLabel\}/,
'Usage stats table must expose its caller-provided name',
);
assert.match(
simpleStatsTable,
/<tr>\{props\.headers\.map\(\(header\) => <th key=\{header\} scope="col">\{header\}<\/th>\)\}<\/tr>/,
/<th key=\{header\} scope="col"/,
'Usage stats table column headers must expose column scope',
);
assert.match(
simpleStatsTable,
/cellIndex === 0 \? \(\s*<th key=\{cellIndex\} scope="row">\{cell\}<\/th>\s*\) : \(\s*<td key=\{cellIndex\}>\{cell\}<\/td>\s*\)/,
/cellIndex === 0 \? \(\s*<th key=\{cellIndex\} scope="row"/,
'Usage stats table rows must expose the first data cell as a scoped row header',
);
assert.doesNotMatch(
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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',
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/src/main/__tests__/web-search-boundary.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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],
/<div className="settingsRows settingsWebSearchCredentialCard">/,
/<SettingsRows className="settingsWebSearchCredentialCard">/,
'Web search credential controls should sit in the shared grouped Settings card primitive',
);
assert.match(
page![0],
/<div className="settingsRows settingsWebSearchQueryCard">/,
/<SettingsRows className="settingsWebSearchQueryCard">/,
'Web search live-query controls should sit in the shared grouped Settings card primitive',
);
for (const rowClass of [
Expand Down
6 changes: 3 additions & 3 deletions apps/desktop/src/renderer/error-boundary.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand DownExpand Up@@ -101,7 +101,7 @@ export class ErrorBoundary extends Component<{ children: ReactNode }, State> {

return (
<div className="maka-error-surface" role="alert" aria-live="assertive">
<div className="maka-error-card">
<Card className="maka-error-card">
<span className="maka-error-icon" aria-hidden="true">
<AlertTriangle size={28} strokeWidth={1.6} />
</span>
Expand DownExpand Up@@ -148,7 +148,7 @@ export class ErrorBoundary extends Component<{ children: ReactNode }, State> {
<p className="maka-error-copy-status">剪贴板不可用或被系统拒绝;可以手动选择上面的错误摘要。</p>
)}
</div>
</div>
</Card>
</div>
);
}
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(ui): converge card/table + badge surfaces onto primitives (#520 PR9) by Astro-Han · Pull Request #554 · apache/maka · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions apps/desktop/src/main/__tests__/badge-converge-contract.test.ts
Original file line numberDiff line numberDiff line change
@@ -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 `<Badge>`. The two settings CSS chips (3, 4) route
* through the squared `<Chip>` 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 <PrimitiveBadge> remains (aliased name retired)', async () => {
for (const rel of MIGRATED_FILES) {
const src = await readFile(resolve(REPO_ROOT, rel), 'utf8');
assert.ok(!/<PrimitiveBadge\b/.test(src), `${rel} must use <Badge>, not <PrimitiveBadge>`);
}
});

});
68 changes: 68 additions & 0 deletions apps/desktop/src/main/__tests__/card-converge-contract.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
/**
* 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 `<div>` carrying a hand-rolled class.
*
* - settingsRows (row-list container) + settingsMetricCard (metric tile) +
* maka-error-card (crash surface) → Card
*
* 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 <pre>, not a
* small inline callout.
*
* The usage stats table is NOT on a public Table primitive: with only one HTML
* <table> 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';
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 <Card>. */
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 <div className="settingsRows"> 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',
];

const CARD_PRIMITIVE = 'packages/ui/src/primitives/card.tsx';

const CARD_IMPORT_RE =
/import\s+\{[^}]*\bCard\b[^}]*\}\s+from\s+['"][^'"]*(?:@maka\/ui|primitives\/card)['"]/;

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"');
});

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 <div className="settingsRows">', async () => {
for (const rel of SETTINGS_ROWS_CONSUMERS) {
const src = await readFile(resolve(REPO_ROOT, rel), 'utf8');
assert.ok(
!/<div\s+className=["'][^"']*\bsettingsRows\b/.test(src),
`${rel} must route through SettingsRows/Card, not a bare div.settingsRows`,
);
}
});
});
75 changes: 75 additions & 0 deletions apps/desktop/src/main/__tests__/chip-converge-contract.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
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 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 () => {
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 — 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');

// 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
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 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 = [
'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) {
assert.match(read(rel), CHIP_IMPORT_RE, `${rel} must import Chip`);
}

// 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)');
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.tsxbadgeVariants 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' },
Expand DownExpand Up@@ -383,7 +383,6 @@ describe('radius token governance (#406 gap 4)', () => {
'.settingsHealthError': '--radius-surface',
'.settingsHealthRefresh': '--radius-control',
'.settingsBotHero': '--radius-surface',
'.settingsRows': '--radius-surface',
'.settingsNotice': '--radius-surface',
'.settingsAboutLogo': '--radius-surface',
'.settingsAboutPrivacy': '--radius-surface',
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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');
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -129,17 +129,17 @@ describe('Settings usage dashboard contract', () => {
);
assert.match(
simpleStatsTable,
/<table className="settingsStatsTable" aria-label=\{props\.ariaLabel\}>/,
/<table\s+aria-label=\{props\.ariaLabel\}/,
'Usage stats table must expose its caller-provided name',
);
assert.match(
simpleStatsTable,
/<tr>\{props\.headers\.map\(\(header\) => <th key=\{header\} scope="col">\{header\}<\/th>\)\}<\/tr>/,
/<th key=\{header\} scope="col"/,
'Usage stats table column headers must expose column scope',
);
assert.match(
simpleStatsTable,
/cellIndex === 0 \? \(\s*<th key=\{cellIndex\} scope="row">\{cell\}<\/th>\s*\) : \(\s*<td key=\{cellIndex\}>\{cell\}<\/td>\s*\)/,
/cellIndex === 0 \? \(\s*<th key=\{cellIndex\} scope="row"/,
'Usage stats table rows must expose the first data cell as a scoped row header',
);
assert.doesNotMatch(
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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',
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/src/main/__tests__/web-search-boundary.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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],
/<div className="settingsRows settingsWebSearchCredentialCard">/,
/<SettingsRows className="settingsWebSearchCredentialCard">/,
'Web search credential controls should sit in the shared grouped Settings card primitive',
);
assert.match(
page![0],
/<div className="settingsRows settingsWebSearchQueryCard">/,
/<SettingsRows className="settingsWebSearchQueryCard">/,
'Web search live-query controls should sit in the shared grouped Settings card primitive',
);
for (const rowClass of [
Expand Down
6 changes: 3 additions & 3 deletions apps/desktop/src/renderer/error-boundary.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand DownExpand Up@@ -101,7 +101,7 @@ export class ErrorBoundary extends Component<{ children: ReactNode }, State> {

return (
<div className="maka-error-surface" role="alert" aria-live="assertive">
<div className="maka-error-card">
<Card className="maka-error-card">
<span className="maka-error-icon" aria-hidden="true">
<AlertTriangle size={28} strokeWidth={1.6} />
</span>
Expand DownExpand Up@@ -148,7 +148,7 @@ export class ErrorBoundary extends Component<{ children: ReactNode }, State> {
<p className="maka-error-copy-status">剪贴板不可用或被系统拒绝;可以手动选择上面的错误摘要。</p>
)}
</div>
</div>
</Card>
</div>
);
}
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(ui): converge card/table + badge surfaces onto primitives (#520 PR9) by Astro-Han · Pull Request #554 · apache/maka · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions apps/desktop/src/main/__tests__/badge-converge-contract.test.ts
Original file line numberDiff line numberDiff line change
@@ -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 `<Badge>`. The two settings CSS chips (3, 4) route
* through the squared `<Chip>` 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 <PrimitiveBadge> remains (aliased name retired)', async () => {
for (const rel of MIGRATED_FILES) {
const src = await readFile(resolve(REPO_ROOT, rel), 'utf8');
assert.ok(!/<PrimitiveBadge\b/.test(src), `${rel} must use <Badge>, not <PrimitiveBadge>`);
}
});

});
68 changes: 68 additions & 0 deletions apps/desktop/src/main/__tests__/card-converge-contract.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
/**
* 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 `<div>` carrying a hand-rolled class.
*
* - settingsRows (row-list container) + settingsMetricCard (metric tile) +
* maka-error-card (crash surface) → Card
*
* 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 <pre>, not a
* small inline callout.
*
* The usage stats table is NOT on a public Table primitive: with only one HTML
* <table> 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';
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 <Card>. */
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 <div className="settingsRows"> 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',
];

const CARD_PRIMITIVE = 'packages/ui/src/primitives/card.tsx';

const CARD_IMPORT_RE =
/import\s+\{[^}]*\bCard\b[^}]*\}\s+from\s+['"][^'"]*(?:@maka\/ui|primitives\/card)['"]/;

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"');
});

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 <div className="settingsRows">', async () => {
for (const rel of SETTINGS_ROWS_CONSUMERS) {
const src = await readFile(resolve(REPO_ROOT, rel), 'utf8');
assert.ok(
!/<div\s+className=["'][^"']*\bsettingsRows\b/.test(src),
`${rel} must route through SettingsRows/Card, not a bare div.settingsRows`,
);
}
});
});
75 changes: 75 additions & 0 deletions apps/desktop/src/main/__tests__/chip-converge-contract.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
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 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 () => {
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 — 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');

// 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
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 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 = [
'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) {
assert.match(read(rel), CHIP_IMPORT_RE, `${rel} must import Chip`);
}

// 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)');
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.tsxbadgeVariants 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' },
Expand DownExpand Up@@ -383,7 +383,6 @@ describe('radius token governance (#406 gap 4)', () => {
'.settingsHealthError': '--radius-surface',
'.settingsHealthRefresh': '--radius-control',
'.settingsBotHero': '--radius-surface',
'.settingsRows': '--radius-surface',
'.settingsNotice': '--radius-surface',
'.settingsAboutLogo': '--radius-surface',
'.settingsAboutPrivacy': '--radius-surface',
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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');
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -129,17 +129,17 @@ describe('Settings usage dashboard contract', () => {
);
assert.match(
simpleStatsTable,
/<table className="settingsStatsTable" aria-label=\{props\.ariaLabel\}>/,
/<table\s+aria-label=\{props\.ariaLabel\}/,
'Usage stats table must expose its caller-provided name',
);
assert.match(
simpleStatsTable,
/<tr>\{props\.headers\.map\(\(header\) => <th key=\{header\} scope="col">\{header\}<\/th>\)\}<\/tr>/,
/<th key=\{header\} scope="col"/,
'Usage stats table column headers must expose column scope',
);
assert.match(
simpleStatsTable,
/cellIndex === 0 \? \(\s*<th key=\{cellIndex\} scope="row">\{cell\}<\/th>\s*\) : \(\s*<td key=\{cellIndex\}>\{cell\}<\/td>\s*\)/,
/cellIndex === 0 \? \(\s*<th key=\{cellIndex\} scope="row"/,
'Usage stats table rows must expose the first data cell as a scoped row header',
);
assert.doesNotMatch(
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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',
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/src/main/__tests__/web-search-boundary.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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],
/<div className="settingsRows settingsWebSearchCredentialCard">/,
/<SettingsRows className="settingsWebSearchCredentialCard">/,
'Web search credential controls should sit in the shared grouped Settings card primitive',
);
assert.match(
page![0],
/<div className="settingsRows settingsWebSearchQueryCard">/,
/<SettingsRows className="settingsWebSearchQueryCard">/,
'Web search live-query controls should sit in the shared grouped Settings card primitive',
);
for (const rowClass of [
Expand Down
6 changes: 3 additions & 3 deletions apps/desktop/src/renderer/error-boundary.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand DownExpand Up@@ -101,7 +101,7 @@ export class ErrorBoundary extends Component<{ children: ReactNode }, State> {

return (
<div className="maka-error-surface" role="alert" aria-live="assertive">
<div className="maka-error-card">
<Card className="maka-error-card">
<span className="maka-error-icon" aria-hidden="true">
<AlertTriangle size={28} strokeWidth={1.6} />
</span>
Expand DownExpand Up@@ -148,7 +148,7 @@ export class ErrorBoundary extends Component<{ children: ReactNode }, State> {
<p className="maka-error-copy-status">剪贴板不可用或被系统拒绝;可以手动选择上面的错误摘要。</p>
)}
</div>
</div>
</Card>
</div>
);
}
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); feat(ui): converge card/table + badge surfaces onto primitives (#520 PR9) by Astro-Han · Pull Request #554 · apache/maka · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions apps/desktop/src/main/__tests__/badge-converge-contract.test.ts
Original file line numberDiff line numberDiff line change
@@ -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 `<Badge>`. The two settings CSS chips (3, 4) route
* through the squared `<Chip>` 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 <PrimitiveBadge> remains (aliased name retired)', async () => {
for (const rel of MIGRATED_FILES) {
const src = await readFile(resolve(REPO_ROOT, rel), 'utf8');
assert.ok(!/<PrimitiveBadge\b/.test(src), `${rel} must use <Badge>, not <PrimitiveBadge>`);
}
});

});
68 changes: 68 additions & 0 deletions apps/desktop/src/main/__tests__/card-converge-contract.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
/**
* 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 `<div>` carrying a hand-rolled class.
*
* - settingsRows (row-list container) + settingsMetricCard (metric tile) +
* maka-error-card (crash surface) → Card
*
* 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 <pre>, not a
* small inline callout.
*
* The usage stats table is NOT on a public Table primitive: with only one HTML
* <table> 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';
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 <Card>. */
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 <div className="settingsRows"> 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',
];

const CARD_PRIMITIVE = 'packages/ui/src/primitives/card.tsx';

const CARD_IMPORT_RE =
/import\s+\{[^}]*\bCard\b[^}]*\}\s+from\s+['"][^'"]*(?:@maka\/ui|primitives\/card)['"]/;

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"');
});

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 <div className="settingsRows">', async () => {
for (const rel of SETTINGS_ROWS_CONSUMERS) {
const src = await readFile(resolve(REPO_ROOT, rel), 'utf8');
assert.ok(
!/<div\s+className=["'][^"']*\bsettingsRows\b/.test(src),
`${rel} must route through SettingsRows/Card, not a bare div.settingsRows`,
);
}
});
});
75 changes: 75 additions & 0 deletions apps/desktop/src/main/__tests__/chip-converge-contract.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
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 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 () => {
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 — 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');

// 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
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 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 = [
'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) {
assert.match(read(rel), CHIP_IMPORT_RE, `${rel} must import Chip`);
}

// 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)');
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.tsxbadgeVariants 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' },
Expand DownExpand Up@@ -383,7 +383,6 @@ describe('radius token governance (#406 gap 4)', () => {
'.settingsHealthError': '--radius-surface',
'.settingsHealthRefresh': '--radius-control',
'.settingsBotHero': '--radius-surface',
'.settingsRows': '--radius-surface',
'.settingsNotice': '--radius-surface',
'.settingsAboutLogo': '--radius-surface',
'.settingsAboutPrivacy': '--radius-surface',
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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');
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -129,17 +129,17 @@ describe('Settings usage dashboard contract', () => {
);
assert.match(
simpleStatsTable,
/<table className="settingsStatsTable" aria-label=\{props\.ariaLabel\}>/,
/<table\s+aria-label=\{props\.ariaLabel\}/,
'Usage stats table must expose its caller-provided name',
);
assert.match(
simpleStatsTable,
/<tr>\{props\.headers\.map\(\(header\) => <th key=\{header\} scope="col">\{header\}<\/th>\)\}<\/tr>/,
/<th key=\{header\} scope="col"/,
'Usage stats table column headers must expose column scope',
);
assert.match(
simpleStatsTable,
/cellIndex === 0 \? \(\s*<th key=\{cellIndex\} scope="row">\{cell\}<\/th>\s*\) : \(\s*<td key=\{cellIndex\}>\{cell\}<\/td>\s*\)/,
/cellIndex === 0 \? \(\s*<th key=\{cellIndex\} scope="row"/,
'Usage stats table rows must expose the first data cell as a scoped row header',
);
assert.doesNotMatch(
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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',
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/src/main/__tests__/web-search-boundary.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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],
/<div className="settingsRows settingsWebSearchCredentialCard">/,
/<SettingsRows className="settingsWebSearchCredentialCard">/,
'Web search credential controls should sit in the shared grouped Settings card primitive',
);
assert.match(
page![0],
/<div className="settingsRows settingsWebSearchQueryCard">/,
/<SettingsRows className="settingsWebSearchQueryCard">/,
'Web search live-query controls should sit in the shared grouped Settings card primitive',
);
for (const rowClass of [
Expand Down
6 changes: 3 additions & 3 deletions apps/desktop/src/renderer/error-boundary.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand DownExpand Up@@ -101,7 +101,7 @@ export class ErrorBoundary extends Component<{ children: ReactNode }, State> {

return (
<div className="maka-error-surface" role="alert" aria-live="assertive">
<div className="maka-error-card">
<Card className="maka-error-card">
<span className="maka-error-icon" aria-hidden="true">
<AlertTriangle size={28} strokeWidth={1.6} />
</span>
Expand DownExpand Up@@ -148,7 +148,7 @@ export class ErrorBoundary extends Component<{ children: ReactNode }, State> {
<p className="maka-error-copy-status">剪贴板不可用或被系统拒绝;可以手动选择上面的错误摘要。</p>
)}
</div>
</div>
</Card>
</div>
);
}
Expand Down
Loading