diff --git a/apps/desktop/src/main/__tests__/artifact-pane-lifecycle-contract.test.ts b/apps/desktop/src/main/__tests__/artifact-pane-lifecycle-contract.test.ts index 3c08b0bf3f..a3e98f5e9d 100644 --- a/apps/desktop/src/main/__tests__/artifact-pane-lifecycle-contract.test.ts +++ b/apps/desktop/src/main/__tests__/artifact-pane-lifecycle-contract.test.ts @@ -104,8 +104,8 @@ describe('ArtifactPane async lifecycle contract', () => { assert.match(src, /aria-busy=\{pendingArtifactListRetry \? 'true' : undefined\}/); assert.match(src, /data-pending=\{pendingArtifactListRetry \? 'true' : undefined\}/); assert.match(src, /pendingArtifactListRetry \? '重试中…' : '重试'/); - assert.match(css, /\.maka-artifact-error-retry:disabled \{[\s\S]*cursor: default;[\s\S]*opacity: var\(--opacity-disabled\);[\s\S]*\}/); - assert.match(css, /\.maka-artifact-error-retry\[data-pending="true"\] \{[\s\S]*opacity: var\(--opacity-pending\);[\s\S]*\}/); + assert.doesNotMatch(src, /className="maka-artifact-error-retry"/); + assert.doesNotMatch(css, /\.maka-artifact-error-retry/); assert.doesNotMatch(src, /className="maka-artifact-error-retry"[\s\S]*onClick=\{\(\) => void refresh\(\)\}/); assert.match( subscriptionEffect, @@ -211,7 +211,8 @@ describe('ArtifactPane async lifecycle contract', () => { assert.match(toolbarBlock, /另存中…/); assert.match(toolbarBlock, /复制中…/); assert.match(toolbarBlock, /删除中…/); - assert.match(css, /\.maka-artifact-toolbar-button:disabled \{[\s\S]*cursor: default;[\s\S]*opacity: var\(--opacity-disabled\);[\s\S]*\}/); - assert.match(css, /\.maka-artifact-toolbar-button\[data-pending="true"\] \{[\s\S]*opacity: var\(--opacity-pending\);[\s\S]*\}/); + assert.doesNotMatch(css, /\.maka-artifact-toolbar-button\b/, 'artifact actions must not restore consumer-owned Button states'); + assert.match(toolbarBlock, /variant="secondary"\s+size="sm"/); + assert.match(toolbarBlock, /variant="destructive" size="icon-sm"/); }); }); diff --git a/apps/desktop/src/main/__tests__/bot-settings-ui-contract.test.ts b/apps/desktop/src/main/__tests__/bot-settings-ui-contract.test.ts index 48654b3d0b..66179f5ebc 100644 --- a/apps/desktop/src/main/__tests__/bot-settings-ui-contract.test.ts +++ b/apps/desktop/src/main/__tests__/bot-settings-ui-contract.test.ts @@ -307,8 +307,8 @@ describe('Bot settings UI contract', () => { assert.match(settings, /window\.setInterval\(\(\) => \{[\s\S]*reloadQrCode\(\)/, 'WeChat bridge QR polling must not bypass the reload guard'); assert.match(settings, /setResult\(\{[\s\S]*ok: false,[\s\S]*error: settingsActionErrorMessage\(error\),[\s\S]*hint: '读取本机 wechat-bridge 二维码失败,请确认 bridge 已启动。'/, 'WeChat bridge QR thrown failures must use the Settings scrubber before rendering'); assert.doesNotMatch(settings, /error: error instanceof Error \? error\.message : String\(error\)/, 'WeChat bridge QR modal must not render raw thrown Error.message'); - assert.match(settings, /className="settingsWechatQrSecondary" disabled=\{loading\} onClick=\{reloadQrCode\}/, 'WeChat bridge QR refresh buttons must disable while a QR reload is in flight'); - assert.match(styles, /\.settingsWechatQrSecondary:disabled\s*\{[\s\S]*cursor:\s*progress/, 'WeChat bridge QR reload buttons must have a visible pending state'); + assert.match(settings, /variant="secondary" size="sm" disabled=\{loading\} onClick=\{reloadQrCode\}/, 'WeChat bridge QR refresh buttons must use the governed compact tier and disable while a QR reload is in flight'); + assert.doesNotMatch(styles, /\.settingsWechatQrSecondary\b/, 'WeChat QR actions must not restore consumer-owned Button states'); assert.match(settings, /window\.maka\.settings\.bots\.wechatQrCode\(\)/, 'QR modal must call the bridge QR IPC'); assert.match(settings, /微信扫码登录二维码 { const start = source.indexOf('export function AppShellWorkspaceTopActions'); assert.notEqual(start, -1, 'AppShellWorkspaceTopActions should exist'); const block = source.slice(start); - return [...block.matchAll(/className="maka-workspace-icon-action"/g)].length; + return [...block.matchAll(/render=\{\}/g)].length; } describe('chat header actions inset contract', () => { @@ -98,20 +98,16 @@ describe('chat header actions inset contract', () => { const css = await readRendererContractCss(); const buttonCount = await workspaceTopActionButtonCount(); const toolbarBody = ruleBody(css, '.maka-workspace-top-actions'); - const iconBody = ruleBody(css, '.maka-workspace-icon-action'); - - const buttonWidth = pxDeclaration(iconBody, 'width'); - const buttonHeight = pxDeclaration(iconBody, 'height'); + const buttonSize = 28; const gap = pxDeclaration(toolbarBody, 'gap'); const insetAddend = workspaceTopActionsInsetAddend(css); assert.equal(buttonCount, 4, 'current top-actions toolbar renders four icon buttons'); - assert.equal(buttonWidth, 24, 'top-actions icon buttons are 24px wide'); - assert.equal(buttonHeight, buttonWidth, 'top-actions icon buttons should stay square'); + assert.equal(buttonSize, 28, 'top-actions use the governed compact Button tier'); assert.equal(gap, 6, 'top-actions icon buttons use a 6px gap'); assert.equal( insetAddend, - (buttonCount * buttonWidth) + ((buttonCount - 1) * gap) + CHAT_HEADER_TOOLBAR_CLEARANCE_PX, + (buttonCount * buttonSize) + ((buttonCount - 1) * gap) + CHAT_HEADER_TOOLBAR_CLEARANCE_PX, 'the chat-header inset addend must match the rendered toolbar footprint plus 12px clearance', ); }); diff --git a/apps/desktop/src/main/__tests__/chat-marker-cascade-contract.test.ts b/apps/desktop/src/main/__tests__/chat-marker-cascade-contract.test.ts index e7d7d337b1..143b7697b5 100644 --- a/apps/desktop/src/main/__tests__/chat-marker-cascade-contract.test.ts +++ b/apps/desktop/src/main/__tests__/chat-marker-cascade-contract.test.ts @@ -61,7 +61,7 @@ describe('chat Marker shell migration contract (#332 PR2)', () => { } }); - it('pins the Marker variants to the retired turn-marker pixels/tokens', async () => { + it('pins Marker semantics while shared Button owns action geometry and states', async () => { const rawSrc = await readFile( resolve(REPO_ROOT, 'packages', 'ui', 'src', 'primitives', 'chat.tsx'), 'utf8', @@ -91,18 +91,6 @@ describe('chat Marker shell migration contract (#332 PR2)', () => { // default and revealed on hover / focus-within of the answer block // (`group/answer`), replacing the retired quiet-0.72 + settle fade-in. 'opacity-0 [transition:opacity_var(--duration-quick)_var(--ease-out-strong)] group-hover/answer:opacity-100 focus-within:opacity-100', - 'min-h-[28px]', - // `h-8` (→30px) is folded into the footer-action / lineage-badge shells - // now that the call sites use `UiButton size="nav"` (bare); it used to - // come implicitly from `size="sm"`. - 'h-8', - '[&:hover:not([aria-disabled=true])]:bg-[oklch(from_var(--foreground)_l_c_h_/_0.05)]', - // focus-visible is a non-leaf conflict (the footer action's outline vs - // UiButton's box-shadow ring), so the rendered-style script can't force - // it reliably; this exact literalization of the retired - // `outline: 2px solid var(--focus-ring)` pins it here instead. - 'focus-visible:[outline:2px_solid_var(--focus-ring)]', - 'focus-visible:[outline-offset:2px]', 'data-[pending=true]:opacity-[0.78]', // the combined aria-disabled+pending guard: a copy button can be both // `aria-disabled` and `data-pending` (transient copy click), and the diff --git a/apps/desktop/src/main/__tests__/composer-constant-footprint-contract.test.ts b/apps/desktop/src/main/__tests__/composer-constant-footprint-contract.test.ts index b136161773..2b647fca61 100644 --- a/apps/desktop/src/main/__tests__/composer-constant-footprint-contract.test.ts +++ b/apps/desktop/src/main/__tests__/composer-constant-footprint-contract.test.ts @@ -138,11 +138,12 @@ describe('PR-COMPOSER-CONSTANT-FOOTPRINT-0 contract (issue #740)', () => { assert.doesNotMatch(textareaLine!, /min-h-[a-z0-9]+/i, '.maka-composer-textarea className must not carry a Tailwind min-h-* utility (CSS min-height: var(--h-composer-min) is the single source)'); }); - it('stop button uses an h-8 size (icon-sm or sm, 32px) — streaming toolbar height matches send (icon-sm/32px, locked by control-height-converge-contract), no 4px chat-boundary jump', async () => { + it('stop and send use the governed 32px tier so streaming does not change toolbar height', async () => { const source = await readFile(COMPOSER_TSX, 'utf8'); const stopBlock = source.match(/props\.streaming\s*\?\s*\(\s*/); assert.ok(stopBlock, 'stop button block (streaming branch) not found'); - assert.match(stopBlock[0], /size="(?:icon-sm|sm)"/, 'stop button must use size="icon-sm" or size="sm" (h-8/32px), NOT default md (h-9/36px) which jumps the chat boundary 4px when streaming swaps send→stop; send is locked to 32px by control-height-converge-contract (.maka-composer-send-button height = --h-control-lg = 32px)'); + assert.match(stopBlock[0], /size="md"/); + assert.match(source, /variant="default"\s+size="icon"[\s\S]*aria-label=\{buttonCopy\.sendLabel\}/); }); it('negative cases: same-block duplicate, selector-list companion, compound .maka-composer.composer padding return, .maka-composer padding return, textarea min-h-* return, stop md return', () => { @@ -173,4 +174,4 @@ describe('PR-COMPOSER-CONSTANT-FOOTPRINT-0 contract (issue #740)', () => { assert.ok(stopMdBlock, 'stop block extraction must work on the fixture'); assert.throws(() => assert.match(stopMdBlock[0]!, /size="(?:icon-sm|sm)"/), 'a stop button defaulting to md (no size) must be caught end-to-end (h-9/36px ≠ send h-8/32px)'); }); -}); \ No newline at end of file +}); diff --git a/apps/desktop/src/main/__tests__/control-height-converge-contract.test.ts b/apps/desktop/src/main/__tests__/control-height-converge-contract.test.ts index 3b13528738..4d89581b3a 100644 --- a/apps/desktop/src/main/__tests__/control-height-converge-contract.test.ts +++ b/apps/desktop/src/main/__tests__/control-height-converge-contract.test.ts @@ -89,10 +89,8 @@ const CONTROL_HEIGHT: ControlHeightCheck[] = [ // sidebar / 会话 rows { selector: '.maka-list-row', props: ['min-height'], token: '--h-control-lg' }, { selector: '.maka-list-row-menu-trigger', props: ['width', 'height'], token: '--h-control-lg' }, - // .maka-search-modal-close retired: the close button is the shared - // DialogHeader's quiet icon-sm Button, sized by buttonVariants, not a - // search-modal-specific class. - { selector: '.maka-search-modal-clear', props: ['width', 'height'], token: '--h-control-sm' }, + // Search close and clear actions are shared quiet icon-sm Buttons, sized by + // buttonVariants rather than search-modal-specific CSS. // 设置 nav / triggers { selector: '.settingsBackButton', props: ['height', 'min-height'], token: '--h-control-xl' }, { selector: '.settingsNavItem', props: ['height', 'min-height'], token: '--h-control-xl' }, @@ -102,13 +100,8 @@ const CONTROL_HEIGHT: ControlHeightCheck[] = [ // chat-header / palette controls { selector: '.maka-chat-jump-bottom', props: ['width', 'height'], token: '--h-control-md' }, { selector: '.maka-palette-input-wrap', props: ['min-height'], token: '--h-control-lg' }, - // first-run checklist rows - { selector: '.maka-first-run-checklist-error-action', props: ['min-height'], token: '--h-control-sm' }, + // first-run checklist composite rows { selector: '.maka-first-run-checklist-row > button', props: ['min-height'], token: '--h-control-xl' }, - // composer controls - { selector: '.maka-composer-send-button', props: ['width', 'height'], token: '--h-control-lg' }, - { selector: '.maka-composer-workspace-picker', props: ['min-height'], token: '--h-control-sm' }, - { selector: '.maka-composer-branch-picker', props: ['min-height'], token: '--h-control-sm' }, ]; /** Values that are always allowed (not a control-height beat). `100%` diff --git a/apps/desktop/src/main/__tests__/explore-agent-tool.test.ts b/apps/desktop/src/main/__tests__/explore-agent-tool.test.ts index 730a28896e..6f0dd19f99 100644 --- a/apps/desktop/src/main/__tests__/explore-agent-tool.test.ts +++ b/apps/desktop/src/main/__tests__/explore-agent-tool.test.ts @@ -5,6 +5,8 @@ import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { buildExploreAgentTool, runReadOnlyExplore } from '../explore-agent-tool.js'; +const repoRoot = join(process.cwd(), '..', '..'); + describe('ExploreAgent read-only worker', () => { it('exposes a permission-gated subagent tool', () => { const tool = buildExploreAgentTool(); @@ -554,6 +556,9 @@ describe('ExploreAgent read-only worker', () => { 'ExploreAgent copy actions should keep the governed previewVariants agent-copy part on shared UiButton controls', ); assert.doesNotMatch(previewBlock, /\bmaka-explore-agent-copy\b/); + const chatPrimitive = await readFile(join(repoRoot, 'packages/ui/src/primitives/chat.tsx'), 'utf8'); + const agentCopyVariant = chatPrimitive.match(/"agent-copy":\s*[\s\S]*?data-\[copy-error=true\][^,]+/)?.[0] ?? ''; + assert.doesNotMatch(agentCopyVariant, /\b(?:gap-|min-h-|px-|py-|text-xs)\b/); assert.doesNotMatch(previewBlock, /data-size="sm"/); assert.match(previewBlock, /复制中…/); assert.match(previewBlock, /复制失败/); diff --git a/apps/desktop/src/main/__tests__/first-run-task-suggestions.test.ts b/apps/desktop/src/main/__tests__/first-run-task-suggestions.test.ts index cda4fd2f07..e2684d19d9 100644 --- a/apps/desktop/src/main/__tests__/first-run-task-suggestions.test.ts +++ b/apps/desktop/src/main/__tests__/first-run-task-suggestions.test.ts @@ -206,8 +206,9 @@ describe('FIRST_RUN_TASK_SUGGESTIONS', () => { assert.match(source, /部分状态暂时没刷新成功,已避免把未知状态计成未完成/); assert.match(source, /role="alert"/); assert.match(styles, /\.maka-first-run-checklist-error\s*\{/); - assert.match(styles, /\.maka-first-run-checklist-error-action\s*\{/); - assert.match(styles, /\.maka-first-run-checklist-error-action:disabled\s*\{/); + assert.match(source, /variant="secondary"\s+size="sm"[\s\S]*refreshChecklistStatus/); + assert.doesNotMatch(source, /className="maka-first-run-checklist-error-action"/); + assert.doesNotMatch(styles, /\.maka-first-run-checklist-error-action/); }); it('starts the shipped plan reminder form from the first-run checklist', async () => { diff --git a/apps/desktop/src/main/__tests__/plan-reminder-panel-contract.test.ts b/apps/desktop/src/main/__tests__/plan-reminder-panel-contract.test.ts index 57798661e4..587455fa8e 100644 --- a/apps/desktop/src/main/__tests__/plan-reminder-panel-contract.test.ts +++ b/apps/desktop/src/main/__tests__/plan-reminder-panel-contract.test.ts @@ -45,7 +45,7 @@ describe('Plan Reminder panel async action contract', () => { ); assert.match(panelBlock, /const submitDisabled = !canCreate \|\| submitPending;/); assert.match(panelBlock, /
/); - assert.match(panelBlock, //); + assert.match(panelBlock, //); assert.match( refreshBlock, diff --git a/apps/desktop/src/main/__tests__/radius-converge-contract.test.ts b/apps/desktop/src/main/__tests__/radius-converge-contract.test.ts index 3d32174658..1605c787bb 100644 --- a/apps/desktop/src/main/__tests__/radius-converge-contract.test.ts +++ b/apps/desktop/src/main/__tests__/radius-converge-contract.test.ts @@ -370,7 +370,6 @@ describe('radius token governance (#406 gap 4)', () => { '.maka-code': '--radius-surface', '.maka-skeleton-card': '--radius-surface', '.composer .maka-composer-inner': '--radius-modal', - '.settingsPermissionRefresh': '--radius-control', '.settingsCapabilityGuidanceActions code': '--radius-surface', '.settingsModal': '--radius-modal', '.maka-palette-modal': '--radius-modal', @@ -380,7 +379,6 @@ describe('radius token governance (#406 gap 4)', () => { '.settingsOsPermissionList': '--radius-surface', '.settingsHealthIntro': '--radius-surface', '.settingsHealthError': '--radius-surface', - '.settingsHealthRefresh': '--radius-control', '.settingsBotRuntime': '--radius-surface', '.settingsNotice': '--radius-surface', '.settingsAboutLogo': '--radius-surface', @@ -390,7 +388,6 @@ describe('radius token governance (#406 gap 4)', () => { '.providerUnavailableNotice': '--radius-surface', '.enabledEmptyChip': '--radius-control', '.maka-firstrun-list': '--radius-surface', - '.maka-onboarding-quickchat-submit': '--radius-control', '.maka-first-run-checklist': '--radius-surface', '.providerLogo': '--radius-surface', '.maka-browser-address': '--radius-control', diff --git a/apps/desktop/src/main/__tests__/renderer-error-boundary.test.ts b/apps/desktop/src/main/__tests__/renderer-error-boundary.test.ts index d57db3023c..756488bd2c 100644 --- a/apps/desktop/src/main/__tests__/renderer-error-boundary.test.ts +++ b/apps/desktop/src/main/__tests__/renderer-error-boundary.test.ts @@ -37,7 +37,7 @@ test('renderer error boundary exposes a redacted copyable diagnostic report', as // lock for 复制中…/已复制 feedback). Match the class as a whole word in the // class list instead of an exact className="…", same form as the negative // maka-button check below. - assert.match(source, /variant="outline"[\s\S]*className="[^"]*\bmaka-error-copy-action\b[^"]*"/); + assert.match(source, /variant="secondary"[\s\S]*className="[^"]*\bmaka-error-copy-action\b[^"]*"/); assert.match(source, //); assert.match(source, /` - * (packages/ui/src/components.tsx). The cva button base always carries the - * Tailwind utilities `inline-flex items-center justify-center`, and the - * `nav` size variant deliberately contributes NO layout utilities so that - * `.maka-nav-row` (display: grid + grid-template-columns + text-align: left) - * is the layout source of truth. + * Sidebar rows are semantic Base UI navigation controls whose grid layout + * lives in `.maka-nav-row`. * * That only holds while `.maka-nav-row` outranks the utilities. #257 wrapped * styles.css into `@layer base`/`@layer components`; because Tailwind v4 @@ -77,30 +73,45 @@ describe('renderer style layer cascade contract', () => { layers, 0, `.maka-nav-row is nested in ${layers} @layer block(s); it must stay unlayered to ` + - 'override the cva button base utilities (inline-flex/justify-center). See #257 regression.', + 'remain the authoritative semantic navigation-row layout. See #257 regression.', ); }); - it('keeps .settingsHealthRefresh out of any @layer so it can override secondary Button utilities', async () => { + it('keeps the composite session target on the same control radius as its row action', async () => { const styles = await readAllRendererCss(); - const layers = enclosingLayerCount(styles, '.settingsHealthRefresh {'); - assert.notEqual(layers, -1, '.settingsHealthRefresh { rule not found in renderer CSS'); - assert.equal( - layers, - 0, - '.settingsHealthRefresh must stay unlayered because it overrides the shared secondary Button utility stack (background/border/padding/color).', + assert.match( + styles, + /\.maka-list-row-main\s*\{[^}]*border-radius:\s*var\(--radius-control\);[^}]*\}/, + 'the semantic session target must own the same governed radius as .maka-list-row-menu-trigger', ); }); - it('keeps .settingsPermissionRefresh out of any @layer so it can override secondary Button utilities', async () => { + it('keeps composite session-list controls on one complete interaction contract', async () => { const styles = await readAllRendererCss(); - const layers = enclosingLayerCount(styles, '.settingsPermissionRefresh {'); - assert.notEqual(layers, -1, '.settingsPermissionRefresh { rule not found in renderer CSS'); - assert.equal( - layers, - 0, - '.settingsPermissionRefresh must stay unlayered because it overrides the shared secondary Button utility stack (background/border/padding/color).', - ); + const controls = ':is\\(\\.maka-list-group-toggle, \\.maka-list-project-heading, \\.maka-list-project-more\\)'; + + assert.match(styles, new RegExp(`${controls}:hover\\s*\\{[^}]*background:\\s*var\\(--state-hover-bg\\);`)); + assert.match(styles, new RegExp(`${controls}:active\\s*\\{[^}]*background:\\s*var\\(--state-selected-bg\\);`)); + assert.match(styles, new RegExp(`${controls}:focus-visible\\s*\\{[^}]*outline:\\s*var\\(--focus-ring-width\\) solid var\\(--focus-ring\\);`)); + assert.match(styles, new RegExp(`${controls}:disabled\\s*\\{[^}]*opacity:\\s*var\\(--opacity-disabled\\);`)); + }); + + it('keeps settings utility actions on governed Button variants instead of unlayered reskins', async () => { + const [styles, permission, health, password] = await Promise.all([ + readAllRendererCss(), + readFile('src/renderer/settings/permission-center-page.tsx', 'utf8'), + readFile('src/renderer/settings/health-center-page.tsx', 'utf8'), + readFile('src/renderer/settings/password-input.tsx', 'utf8'), + ]); + + assert.match(permission, /\s*重新检测/); + assert.match(health, /\s*刷新/); + assert.equal(password.match(/variant="quiet"\s+size="icon-sm"/g)?.length, 2); + + for (const legacyClass of ['settingsPermissionRefresh', 'settingsHealthRefresh', 'settingsPasswordToggle']) { + assert.doesNotMatch(`${permission}\n${health}\n${password}`, new RegExp(`className="${legacyClass}"`)); + assert.doesNotMatch(styles, new RegExp(`\\.${legacyClass}(?:\\s|:|\\{|\\[)`)); + } }); /** diff --git a/apps/desktop/src/main/__tests__/renderer-utility-primitives-contract.test.ts b/apps/desktop/src/main/__tests__/renderer-utility-primitives-contract.test.ts index 9cc61c1daf..c0117b868f 100644 --- a/apps/desktop/src/main/__tests__/renderer-utility-primitives-contract.test.ts +++ b/apps/desktop/src/main/__tests__/renderer-utility-primitives-contract.test.ts @@ -101,18 +101,14 @@ describe('renderer utility surfaces use shared UI primitives', () => { const source = await readFile(join(process.cwd(), 'src/renderer/artifact-pane.tsx'), 'utf8'); assert.match(source, /import \{[^}]*\bButton\b[^}]*\bToolbar\b[^}]*\bToolbarGroup\b[^}]*\bToolbarSeparator\b[^}]*\buseToast\b[^}]*\} from '@maka\/ui';/); - assert.doesNotMatch(source, //); assert.match(source, //); - for (const className of [ - 'maka-artifact-pane-collapse', - 'maka-artifact-error-retry', - 'maka-artifact-row', - 'maka-artifact-toolbar-button', - ]) { - assert.match(source, new RegExp(`\}/); + assert.match(source, / { @@ -186,8 +182,9 @@ describe('renderer utility surfaces use shared UI primitives', () => { assert.match(source, /import \{[^}]*\bButton\b[^}]*\} from '.\/ui\.js';/); assert.doesNotMatch(source, /\}/); + assert.match(source, /render=\{ + ))} diff --git a/apps/desktop/src/renderer/OnboardingHero.tsx b/apps/desktop/src/renderer/OnboardingHero.tsx index c04eb92f52..ab7718b6e0 100644 --- a/apps/desktop/src/renderer/OnboardingHero.tsx +++ b/apps/desktop/src/renderer/OnboardingHero.tsx @@ -310,13 +310,13 @@ function NeedsConnectionHero(props: { list header even says 点一个进入设置). One affordance per action — the footer keeps only genuinely distinct paths. */}