From 047f48337d14f8b489c9bb1c47bfffcac5ceb055 Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Fri, 14 Aug 2026 14:07:52 +0800 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20=E4=BE=A7=E8=BE=B9=E5=8D=A1?= =?UTF-8?q?=E7=89=87=E8=AE=BE=E7=BD=AE=E9=A1=B5=E8=A7=86=E8=A7=89=E9=87=8D?= =?UTF-8?q?=E6=9E=84=E2=80=94=E2=80=94=E5=88=86=E7=BB=84=E5=AE=B9=E5=99=A8?= =?UTF-8?q?=E5=8D=A1=E7=89=87=E3=80=81=E8=AE=A1=E6=95=B0=E5=BE=BD=E6=A0=87?= =?UTF-8?q?=E3=80=81=E8=87=AA=E5=AE=9A=E4=B9=89=E5=BC=80=E5=85=B3=E3=80=81?= =?UTF-8?q?=E5=8A=A0=E5=AE=BD=E8=AE=BE=E7=BD=AE=E5=BC=B9=E7=AA=97=EF=BC=88?= =?UTF-8?q?=E6=8F=8F=E8=BF=B0=20+=20=E5=AE=8C=E6=88=90=E6=8C=89=E9=92=AE?= =?UTF-8?q?=EF=BC=89=EF=BC=8C=E5=8D=A1=E7=89=87=E9=BD=BF=E8=BD=AE=E4=B8=8E?= =?UTF-8?q?=E5=8B=BE=E9=80=89=E5=BE=BD=E6=A0=87=E7=BA=B5=E5=90=91=E5=AF=B9?= =?UTF-8?q?=E9=BD=90=E5=90=8C=E5=B0=BA=E5=AF=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/client/SideCardSection.module.css | 279 +++++++++++++++++++------- src/client/SideCardSection.tsx | 266 ++++++++++++++---------- src/client/locales.ts | 6 + tests/side-card-section.spec.tsx | 38 +++- 4 files changed, 408 insertions(+), 181 deletions(-) diff --git a/src/client/SideCardSection.module.css b/src/client/SideCardSection.module.css index 2517744..f24861c 100644 --- a/src/client/SideCardSection.module.css +++ b/src/client/SideCardSection.module.css @@ -1,20 +1,28 @@ /** * Side card settings section chrome (DSH-native tokens throughout): + * - the section is a column of GROUP CONTAINER cards (the PluginCard recipe: + * l2 hairline, 16px radius, layer-3 fill) with a heading + inventory count + * badge (the settings catalogHeading recipe), opening with a one-line + * intro (the DSH section heading+intro recipe); + * - 常规 holds full-width settings rows (the DSH settings-row recipe: + * title/desc left + control right, hairline separators) whose toggles are + * custom switches — a real checkbox (native semantics and focus) driving + * a styled track/thumb; * - the feature inventories are responsive GRIDS of small toggle cards - * (auto-fill columns — several cards per row, wrapping); - * - a card's on/off state IS its visual state: enabled = brand border + - * tinted fill + a check badge pinned to the far right of the top row, - * disabled = neutral hairline + dimmed text; + * (auto-fill columns — several cards per row, wrapping): icon chip + + * title + type id, the card's on/off state IS its visual state + * (enabled = brand border + tinted fill + circular check badge, + * disabled = neutral hairline + dimmed); * - features that declare related settings carry a gear corner button - * opening a native Modal whose rows are the DSH settings-row recipe - * (title/desc left + native accent checkbox, hairline separators); + * opening a native Modal (wider than the primitive default) whose rows + * follow the same row recipe with switch controls and a Done footer; * - the width input stays a native settings row (a value, not a switch). */ .section { display: flex; flex-direction: column; - gap: 8px; + gap: 14px; width: 100%; /* Tall inventories (7 tab cards + 9 viewer cards + general rows) must never be clipped by the settings shell: fill the shell's options area @@ -24,15 +32,55 @@ overflow-y: auto; } -.section > :last-child { - border-bottom: none; +/* One-line section intro (the DSH section heading+intro recipe). */ +.intro { + margin: 0; + padding: 0 2px; + font-size: 13px; + line-height: 20px; + color: var(--dsw-alias-label-tertiary); +} + +/* Group container: the DSH card recipe (PluginCard) — l2 hairline, r16, + layer-3 fill; holds its own heading + stacked rows / card grid. */ +.group { + flex: none; + display: flex; + flex-direction: column; + gap: 8px; + padding: 18px 20px 20px; + box-sizing: border-box; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 16px; + background: var(--dsw-alias-bg-layer-3); +} + +/* Group heading: 13/600 + the inventory count badge (the settings + catalogHeading recipe: count in tabular-nums, tertiary). */ +.groupHeading { + display: flex; + align-items: baseline; + gap: 7px; + padding: 0 2px 6px; + font-size: 13px; + line-height: 20px; + font-weight: 600; + color: var(--dsw-alias-label-primary); +} + +.count { + font-size: 12px; + line-height: 18px; + font-weight: 400; + color: var(--dsw-alias-label-tertiary); + font-variant-numeric: tabular-nums; } /* Responsive inventory: as many small cards per row as the width allows. */ .grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(148px, 1fr)); - gap: 8px; + grid-template-columns: repeat(auto-fill, minmax(168px, 1fr)); + gap: 10px; } /* One small toggle card (visual chrome; the interactive part is .cardMain). */ @@ -40,7 +88,7 @@ position: relative; display: flex; flex-direction: column; - border-radius: 8px; + border-radius: 12px; border: 1px solid var(--dsw-alias-border-l2); background: transparent; font: inherit; @@ -52,6 +100,7 @@ /* Hover affordance on disabled cards only (enabled cards are already lit). */ .card:not(.cardOn):hover { background: var(--dsw-alias-interactive-bg-hover); + border-color: var(--dsw-alias-label-dimmed); } /* Enabled = highlighted: brand border + tinted fill. */ @@ -64,9 +113,9 @@ .cardMain { display: flex; flex-direction: column; - gap: 2px; + gap: 6px; width: 100%; - padding: 10px 12px; + padding: 12px; border: 0; border-radius: inherit; background: transparent; @@ -85,18 +134,28 @@ .cardTop { display: flex; align-items: center; - gap: 6px; + gap: 8px; min-width: 0; } -.cardIcon { +/* The icon sits in a rounded chip: brand-tinted while the card is on, + neutral (layer-2 fill + hairline) while off. */ +.cardIconChip { display: inline-flex; align-items: center; + justify-content: center; flex: none; + width: 28px; + height: 28px; + border-radius: 8px; + border: 1px solid var(--dsw-alias-border-l2); + background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-tertiary); } -.cardOn .cardIcon { +.cardOn .cardIconChip { + border-color: color-mix(in srgb, var(--dsw-alias-button-primary-fill) 35%, transparent); + background: color-mix(in srgb, var(--dsw-alias-button-primary-fill) 12%, transparent); color: var(--dsw-alias-button-primary-fill); } @@ -105,6 +164,7 @@ min-width: 0; font-size: 13px; line-height: 20px; + font-weight: 600; color: var(--dsw-alias-label-secondary); white-space: nowrap; overflow: hidden; @@ -115,12 +175,18 @@ color: var(--dsw-alias-label-primary); } -/* The check badge: pinned to the FAR RIGHT of the top row (enabled only). */ +/* The check badge: a circular brand-filled pin at the card's far right + (enabled only), the icon in the inverted surface token (the .save recipe). */ .cardCheck { display: inline-flex; align-items: center; + justify-content: center; flex: none; - color: var(--dsw-alias-button-primary-fill); + width: 16px; + height: 16px; + border-radius: 50%; + background: var(--dsw-alias-button-primary-fill); + color: var(--dsw-alias-bg-layer-3); } .cardDesc { @@ -136,24 +202,28 @@ color: var(--dsw-alias-label-secondary); } -/* The gear corner button: bottom-right of cards that declare related - settings; the desc gets right padding so the ellipsis never runs under it. */ +/* Cards with a settings gear keep the desc clear of it: the gear sits in + the desc row, sharing the check badge's right edge (a vertical column of + controls at the card's right side). */ .cardWithGear .cardDesc { - padding-right: 18px; + padding-right: 30px; } +/* The gear corner button: directly UNDER the check badge on the card's + right edge — the two controls are vertically aligned (same right inset, + one row apart) and the SAME 16px size, not side by side. */ .cardGear { position: absolute; - right: 6px; - bottom: 6px; + right: 12px; + top: 46px; display: inline-flex; align-items: center; justify-content: center; - width: 20px; - height: 20px; + width: 16px; + height: 16px; padding: 0; border: 0; - border-radius: 6px; + border-radius: 50%; background: transparent; color: var(--dsw-alias-label-tertiary); cursor: pointer; @@ -164,26 +234,21 @@ background: var(--dsw-alias-interactive-bg-hover); } -/* Inventory section heading (常规 / 侧边栏内容 / 文件预览). */ -.sectionHeading { - padding: 14px 0 0; - font-size: 12px; - line-height: 17px; - font-weight: 600; - letter-spacing: 0.02em; - color: var(--dsw-alias-label-secondary); -} - -/* Native settings row recipe (the width input row + popup rows). */ +/* Settings-row recipe (the DSH General section rows): title/desc left, + control right, hairline separators (stripped on the last row). */ .row { display: flex; align-items: center; justify-content: space-between; gap: 16px; - padding: 12px 0; + padding: 12px 2px; border-bottom: 1px solid var(--dsw-alias-border-l2); } +.row:last-child { + border-bottom: none; +} + .rowText { display: flex; flex-direction: column; @@ -199,45 +264,66 @@ .desc { font-size: 12px; - line-height: 17px; - color: var(--dsw-alias-label-secondary); + line-height: 18px; + color: var(--dsw-alias-label-tertiary); } -/* Native checkbox: 16px, brand accent (the settings-row recipe). */ -.toggle { +/* Custom switch: a real checkbox (native semantics + focus, visually + hidden) driving a styled 36x20 track with a sliding thumb. */ +.switch { + position: relative; + display: inline-flex; flex: none; - width: 16px; - height: 16px; + cursor: pointer; +} + +.switchInput { + position: absolute; + width: 1px; + height: 1px; margin: 0; - accent-color: var(--dsw-alias-button-primary-fill); - cursor: pointer; + opacity: 0; } -.toggle:focus-visible { - outline: 2px solid var(--dsw-alias-border-l4); - outline-offset: 2px; -} - -/* Secondary settings popup body: stacked native rows, trailing separator - stripped by the trailing-child rule. */ -.popupRows { - display: flex; - flex-direction: column; - width: 100%; -} - -.popupRow { - display: flex; +.switchTrack { + display: inline-flex; align-items: center; - justify-content: space-between; - gap: 16px; - padding: 16px 0; - border-bottom: 1px solid var(--dsw-alias-border-l2); - cursor: pointer; + width: 36px; + height: 20px; + padding: 2px; + box-sizing: border-box; + border-radius: 10px; + border: 1px solid var(--dsw-alias-border-l2); + background: var(--dsw-alias-bg-layer-1); + transition: background 0.15s ease, border-color 0.15s ease; } -.popupRows > :last-child { - border-bottom: none; +.switchThumb { + display: block; + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--dsw-alias-label-secondary); + transition: transform 0.15s ease, background 0.15s ease; +} + +.switch:hover .switchTrack { + border-color: var(--dsw-alias-label-dimmed); +} + +.switchInput:checked + .switchTrack { + border-color: var(--dsw-alias-button-primary-fill); + background: var(--dsw-alias-button-primary-fill); +} + +.switchInput:checked + .switchTrack .switchThumb { + transform: translateX(16px); + background: var(--dsw-alias-bg-layer-3); +} + +.switchInput:focus-visible + .switchTrack { + outline: 2px solid var(--dsw-alias-state-business-primary); + outline-offset: 2px; } .control { @@ -257,6 +343,57 @@ color: var(--dsw-alias-label-secondary); } +/* Secondary settings popup: a wider dialog than the primitive default (the + dialog portals to document.body; the doubled class keeps the width rule + above the primitive's .dialog regardless of stylesheet order). */ +.popupDialog.popupDialog { + width: min(460px, 100%); +} + +/* Popup rows: the same settings-row recipe with switch controls. */ +.popupRows { + display: flex; + flex-direction: column; + width: 100%; +} + +.popupRow { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 14px 2px; + border-bottom: 1px solid var(--dsw-alias-border-l2); +} + +.popupRow:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + +.popupRows > :last-child { + border-bottom: none; +} + +/* The popup's Done button: the settings primary-button recipe + (PluginCard .save: label-primary fill, inverted surface text). */ +.done { + appearance: none; + border: 1px solid transparent; + border-radius: 8px; + padding: 5px 14px; + font: inherit; + font-size: 13px; + line-height: 1.5; + cursor: pointer; + background: var(--dsw-alias-label-primary); + color: var(--dsw-alias-bg-layer-3); +} + +.done:focus-visible { + outline: 2px solid var(--dsw-alias-brand-primary); + outline-offset: 1px; +} + .error { padding: 10px 0 2px; font-size: 12px; @@ -265,7 +402,9 @@ } @media (prefers-reduced-motion: reduce) { - .card { + .card, + .switchTrack, + .switchThumb { transition: none; } } diff --git a/src/client/SideCardSection.tsx b/src/client/SideCardSection.tsx index fb159ac..0dba6ee 100644 --- a/src/client/SideCardSection.tsx +++ b/src/client/SideCardSection.tsx @@ -4,21 +4,30 @@ * * The section is DECLARATIVE — it renders the enable/disable inventory from * the sidebar service's registries instead of hardcoding rows: - * - 常规: new conversations open the panel by default (a toggle card), and - * the default panel width as a percent of the window (number input row). + * - 常规: new conversations open the panel by default (a toggle row), the + * default panel width as a percent of the window (number input row), and + * the open-path interception toggle — the DSH settings-row recipe + * (title/desc left + control right, hairline separators). * - 侧边栏内容: one SMALL CARD per REGISTERED tab type (built-ins and * external plugins alike), laid out in a responsive grid that wraps - * several cards per row — icon + title + type id, clicked to toggle the - * switch persisted in `prefs.tabsEnabled[id]`. - * - 文件预览: one SMALL CARD per REGISTERED file viewer — icon + title + - * the extensions it covers, clicked to toggle `prefs.viewersEnabled[id]`. + * several cards per row — icon chip + title + type id, clicked to toggle + * the switch persisted in `prefs.tabsEnabled[id]`. + * - 文件预览: one SMALL CARD per REGISTERED file viewer — icon chip + title + * + the extensions it covers, clicked to toggle `prefs.viewersEnabled[id]`. + * + * Every group lives in a container card (the DSH PluginCard recipe: l2 + * hairline, 16px radius, layer-3 fill) with a heading and an inventory count + * badge (the settings catalogHeading recipe); the section opens with a + * one-line intro (the DSH section heading+intro recipe). * * A card's on/off state is its VISUAL STATE: enabled = highlighted (brand - * border + tinted fill + a check badge pinned to the card's far right), - * disabled = neutral and dimmed. Features that declare `settings.toggles` - * carry a gear corner button that opens a native Modal with the related - * settings as native checkbox rows (e.g. the Subagent page's "auto-open - * when a subagent appears", the Terminal page's model terminal tools). + * border + tinted fill + a circular check badge pinned to the card's far + * right), disabled = neutral and dimmed. Features that declare + * `settings.toggles` carry a gear corner button that opens a native Modal + * (wider than the primitive default) with the related settings as + * title/desc + custom-switch rows and a Done footer. The toggles + * themselves are custom switches: a real checkbox (native semantics and + * focus) driving a styled track/thumb. * * Writes ride the plugin's own fenced settings route (the host calls the * settings seam in-process — the DSH settings RPC domain does not serve @@ -32,8 +41,6 @@ import { Fragment, useEffect, useRef, useState, type ReactNode } from 'react' import { IconCheckOutline16, - IconCodeOutline16, - IconPanelLeftOutline16, IconSettingsOutline16, Input, Modal, @@ -106,9 +113,36 @@ function prefBool(prefs: SidebarPrefs, key: string): boolean { } /** - * The body of a feature's secondary settings popup: one native checkbox row - * per declared toggle. Extracted so the rows are testable without opening - * the Modal (the Modal portal renders only while open). + * The custom switch: a real checkbox (hidden, native semantics and focus) + * driving a styled track/thumb. Used by the general toggle rows and the + * secondary settings popup rows. + */ +function Switch(props: { + checked: boolean + onChange: (next: boolean) => void + label: string +}) { + const { checked, onChange, label } = props + return ( + + ) +} + +/** + * The body of a feature's secondary settings popup: one row (title/desc + + * custom switch) per declared toggle. Extracted so the rows are testable + * without opening the Modal (the Modal portal renders only while open). */ export function FeatureSettingsRows(props: { toggles: readonly SidebarSettingToggle[] @@ -121,19 +155,17 @@ export function FeatureSettingsRows(props: { {toggles.map(toggle => { const title = textOf(toggle.title) return ( -