diff --git a/apps/desktop/src/main/__tests__/renderer-style-pruning-contract.test.ts b/apps/desktop/src/main/__tests__/renderer-style-pruning-contract.test.ts index c6b4b4bfe1..ad208187b6 100644 --- a/apps/desktop/src/main/__tests__/renderer-style-pruning-contract.test.ts +++ b/apps/desktop/src/main/__tests__/renderer-style-pruning-contract.test.ts @@ -10,15 +10,19 @@ describe('renderer style pruning contract', () => { it('does not keep CSS for retired renderer hooks', async () => { const styles = await readFile(STYLES_PATH, 'utf8'); const retiredHooks = [ + 'connectionStatus', 'maka-indeterminate-bar', 'maka-nav-disclosure', + 'maka-nav-primary', 'maka-nav-tree', 'maka-session-archive-link', 'maka-session-filter', 'maka-session-panel-help-chip', 'maka-session-search-clear', 'maka-sidebar-brand', + 'maka-skill-workbench-rail', 'maka-streaming-token-fade-in', + 'providerCatalog', 'settingsCardProviders', 'settingsFeatureStatusHeroActions', 'settingsHeader', diff --git a/apps/desktop/src/renderer/settings/SettingsModal.tsx b/apps/desktop/src/renderer/settings/SettingsModal.tsx index 7b220b7d43..ca117f9cae 100644 --- a/apps/desktop/src/renderer/settings/SettingsModal.tsx +++ b/apps/desktop/src/renderer/settings/SettingsModal.tsx @@ -91,13 +91,7 @@ import { Input, OverlayScrollArea, RelativeTime, - SelectItem, - SelectPopup, - SelectPortal, - SelectPositioner, - SelectRoot, - SelectTrigger, - SelectValue, + SettingsSelect, PrimitiveBadge, Switch as BaseUiSwitch, Textarea, @@ -179,39 +173,11 @@ function radioTabIndex(value: T, current: T, values: readonly return !values.includes(current) && values[0] === value ? 0 : -1; } -function SettingsSelect(props: { - value: T; - options: ReadonlyArray; - onChange(value: T): void; - ariaLabel: string; - disabled?: boolean; -}) { - return ( - ({ value, label }))} - disabled={props.disabled} - onValueChange={(value) => { - if (value !== null) props.onChange(value); - }} - > - - - - - - - {props.options.map(([value, label]) => ( - - {label} - - ))} - - - - - ); -} +// `SettingsSelect` moved to `packages/ui/src/primitives/settings-select.tsx` +// in PR round-AB-shared-select (yuejing 2026-06-25). The Plan Reminder +// platform select now uses the same primitive, so option shape, +// selected-trigger icon rendering, and chrome contract are one source +// of truth (kenji styles inventory task #128). Imported via `@maka/ui`. // `SettingsNavGroup` + `NAV_GROUP_ORDER` moved to `nav-group-summary.ts` // (PR-HEALTH-1) so the H1/H2 group-summary assertions can be pinned with diff --git a/apps/desktop/src/renderer/styles.css b/apps/desktop/src/renderer/styles.css index 8c5a6c0353..2e8088d72f 100644 --- a/apps/desktop/src/renderer/styles.css +++ b/apps/desktop/src/renderer/styles.css @@ -806,28 +806,6 @@ button:active { color: currentColor; } -.maka-session-panel[data-collapsed="true"] .maka-nav-primary { - width: 28px; - min-width: 28px; - height: 28px; - min-height: 28px; - display: grid; - grid-template-columns: 1fr; - place-items: center; - gap: 0; - margin-inline: auto; - padding: 0; - border-radius: 6px; -} - -.maka-session-panel[data-collapsed="true"] .maka-nav-primary span { - display: none; -} - -.maka-session-panel[data-collapsed="true"] .maka-nav-primary { - background: var(--foreground-5); -} - .maka-nav-icon { width: var(--icon-size); height: var(--icon-size); @@ -3251,8 +3229,14 @@ button:active { box-shadow: 0 0 0 2px oklch(from var(--accent) l c h / 0.12); } -.maka-plan-select { - width: 100%; +/* PR round-AB-shared-select (yuejing 2026-06-25): the Plan Reminder + delivery select and the 5 Settings selects used to ship two slightly + different wrappers — different option shapes, different selected + trigger renderers, different CSS recipes. They are now one + `SettingsSelect` primitive in `@maka/ui` (see kenji styles inventory + `notes/maka-styles-css-inventory-2026-06-25-task-128.md`). These + `.settingsSelect*` rules pin the unified chrome. */ +.settingsSelectTrigger { min-width: 0; height: 34px; justify-content: space-between; @@ -3261,24 +3245,21 @@ button:active { font-weight: 600; } -.maka-plan-select-popup { +.settingsSelectPopup { min-width: var(--anchor-width); } -/* PR audit2-bundled (@kenji audit msg `e4cfbfb0` finding #2): the Plan - Reminder delivery picker now carries real IM brand logos so it reads - the same as Settings → 机器人对话. The icon slot lines up with the - text via inline-flex; the icon tile is a fixed 16x16 to absorb - Iconify's size: '100%' rendering of the lazy-loaded simple-icons - svg without pushing the option row height around. */ -.maka-plan-select-option { +/* The icon slot lines up with the text via inline-flex; the icon tile + is a fixed 16x16 to absorb Iconify's size: '100%' rendering of the + simple-icons svg without pushing option row height around. */ +.settingsSelectOption { display: inline-flex; align-items: center; gap: 8px; min-width: 0; } -.maka-plan-select-option-icon { +.settingsSelectOptionIcon { flex: 0 0 auto; width: 16px; height: 16px; @@ -4311,9 +4292,6 @@ button:active { grid-template-columns: minmax(0, 1fr); } - .maka-skill-workbench-rail { - position: static; - } .maka-skill-library-row { grid-template-columns: 38px minmax(0, 1fr) max-content; gap: 6px; @@ -7437,9 +7415,7 @@ button:active { justify-self: end; } -.settingsRow[data-control-width="select"] > .settingsBaseSelectTrigger { - max-width: 320px; - width: 100%; +.settingsRow[data-control-width="select"] > .settingsSelectTrigger { justify-self: end; } @@ -7863,7 +7839,7 @@ button:active { PR-SETTINGS-INPUT-POLISH-0: cap from 280 → 320 since rows are now roomier (24px L/R padding). */ .settingsField[data-orient="horizontal"] > input, -.settingsField[data-orient="horizontal"] > .settingsBaseSelectTrigger { +.settingsField[data-orient="horizontal"] > .settingsSelectTrigger { max-width: 320px; } /* WAWQAQ msg `0951e3b1` 2026-06-25 + @kenji audit #5 (msg `26a221be`): @@ -9397,14 +9373,6 @@ button:active { line-height: 1.4; } -.providerCatalog { - min-width: 0; - display: grid; - grid-template-rows: auto 1fr auto; - gap: 12px; - overflow: hidden; -} - .catalogTabs { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -9831,8 +9799,7 @@ button:active { flex-wrap: wrap; } -.providerError, -.connectionStatus[data-ok="false"] { +.providerError { color: var(--destructive-text); } diff --git a/packages/ui/src/components.tsx b/packages/ui/src/components.tsx index fd7df90c5e..bcefac4e3e 100644 --- a/packages/ui/src/components.tsx +++ b/packages/ui/src/components.tsx @@ -50,6 +50,7 @@ import { IconifyIcon, } from './icons.js'; import { BOT_BRAND } from './bot-brand.js'; +import { SettingsSelect, type SettingsSelectOption } from './primitives/settings-select.js'; import { redactSecrets } from './redact.js'; import { DeepResearchEmptyHero, EmptyChatHero } from './chat-empty-hero.js'; import { @@ -1992,80 +1993,21 @@ function DailyReviewTopList(props: { title: string; entries: ReadonlyArray = - | readonly [T, string] - | readonly [T, string, ReactNode]; - +// PR round-AB-shared-select (yuejing 2026-06-25, kenji styles inventory +// task #128): `PlanReminderSelect` is now a thin specialization of the +// shared `SettingsSelect` primitive — `width="full"` to preserve the +// existing edge-to-edge sizing inside `.maka-plan-delivery-grid`. +// Plan Reminder and Settings selects share one component so option +// shape, trigger/popup chrome, and the selected-trigger icon contract +// can't drift apart again. function PlanReminderSelect(props: { value: T; - options: ReadonlyArray>; + options: ReadonlyArray>; onChange(value: T): void; ariaLabel: string; disabled?: boolean; }) { - // PR audit3 (@kenji msg `232aec0f` finding #2): the dropdown items - // already render the brand icon, but the collapsed `` - // was falling back to the plain label string — open dropdown showed - // a logo, but the picked value collapsed back to text. Build a - // value→{label,icon} lookup and have `SelectValue`'s function child - // render the icon + label together so the selected state matches the - // option state. - const optionByValue = new Map(); - for (const option of props.options) { - const [value, label] = option; - optionByValue.set(value, { - label, - icon: option.length === 3 ? option[2] : null, - }); - } - const renderOptionRow = (label: string, icon: ReactNode | null, className = 'maka-plan-select-option') => - icon ? ( - - - {label} - - ) : ( - <>{label} - ); - return ( - ({ value, label }))} - disabled={props.disabled} - onValueChange={(value) => { - if (value !== null) props.onChange(value); - }} - > - - - {(value: T) => { - const entry = optionByValue.get(value); - if (!entry) return null; - return renderOptionRow(entry.label, entry.icon); - }} - - - - - - {props.options.map((option) => { - const [value, label] = option; - const icon = option.length === 3 ? option[2] : null; - return ( - - {renderOptionRow(label, icon)} - - ); - })} - - - - - ); + return ; } function PlanReminderPanel(props: { diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index e70b2fb455..9fe1ae951a 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -35,6 +35,7 @@ export * from './primitives/menu.js'; export * from './primitives/group.js'; export * from './primitives/frame.js'; export * from './primitives/preview-card.js'; +export * from './primitives/settings-select.js'; export * from './primitives/input-group.js'; export * from './primitives/pagination.js'; export * from './primitives/sidebar.js'; diff --git a/packages/ui/src/primitives/settings-select.tsx b/packages/ui/src/primitives/settings-select.tsx new file mode 100644 index 0000000000..52bbfc54ed --- /dev/null +++ b/packages/ui/src/primitives/settings-select.tsx @@ -0,0 +1,127 @@ +"use client"; + +import type { ReactElement, ReactNode } from "react"; +import { cn } from "../utils.js"; +import { + SelectItem, + SelectPopup, + SelectPortal, + SelectPositioner, + SelectRoot, + SelectTrigger, + SelectValue, +} from "../ui.js"; + +/** + * Unified Select primitive for settings-style pickers. + * + * Consolidates three local wrappers that all wrapped the same Base UI + * Select primitives with slightly-different option shapes (kenji + * inventory `notes/maka-styles-css-inventory-2026-06-25-task-128.md` + * + audit msg `e4cfbfb0`): + * + * - `apps/desktop/.../SettingsModal.tsx :: SettingsSelect` + * `[value, label]` tuples, fixed `.settingsBaseSelectTrigger` chrome. + * - `packages/ui/src/components.tsx :: PlanReminderSelect` + * `[value, label, icon?]` tuples, rich trigger renderer. + * + * The two had drifted into independent option shapes and CSS recipes; + * a real bug (selected trigger losing the icon) only got fixed in the + * Plan Reminder copy, not the Settings copy. This primitive collapses + * both into one component so the same affordance behaves the same + * everywhere. + * + * Option shape `[value, label, icon?]`: the third tuple slot is an + * optional ReactNode rendered as a 16px leading icon on both the + * selected trigger and each popup item. + * + * `width` controls the trigger max-width — `compact` (140px) is the + * default for inputs/time pickers, `select` (320px) matches the + * existing settings select width, `full` lets the parent constrain. + */ +export type SettingsSelectOption = + | readonly [T, string] + | readonly [T, string, ReactNode]; + +export interface SettingsSelectProps { + value: T; + options: ReadonlyArray>; + onChange(value: T): void; + ariaLabel: string; + disabled?: boolean; + /** Visual width bucket for the trigger. Defaults to `'select'`. */ + width?: "compact" | "select" | "full"; + /** Extra class names appended to the trigger element. */ + className?: string; +} + +const WIDTH_CLASS: Record["width"]>, string> = { + compact: "max-w-[140px] w-full", + select: "max-w-[320px] w-full", + full: "w-full", +}; + +export function SettingsSelect( + props: SettingsSelectProps, +): ReactElement { + const width = props.width ?? "select"; + // Build a value → {label, icon} lookup so the selected-state trigger + // can render the same icon + label row as the popup items. Without + // this the collapsed trigger drops the icon — see Plan Reminder bug + // kenji audit msg `232aec0f` finding #2. + const optionByValue = new Map(); + for (const option of props.options) { + const [value, label] = option; + optionByValue.set(value, { + label, + icon: option.length === 3 ? option[2] : null, + }); + } + const renderOptionRow = (label: string, icon: ReactNode | null) => + icon ? ( + + + {label} + + ) : ( + <>{label} + ); + return ( + ({ value, label }))} + disabled={props.disabled} + onValueChange={(value) => { + if (value !== null) props.onChange(value); + }} + > + + + {(value: T) => { + const entry = optionByValue.get(value); + if (!entry) return null; + return renderOptionRow(entry.label, entry.icon); + }} + + + + + + {props.options.map((option) => { + const [value, label] = option; + const icon = option.length === 3 ? option[2] : null; + return ( + + {renderOptionRow(label, icon)} + + ); + })} + + + + + ); +}