feat: 侧边卡片设置页视觉重构——分组容器卡片、计数徽标、自定义开关、加宽设置弹窗(描述 + 完成按钮),卡片齿轮与勾选徽标纵向对齐同尺寸
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+165
-101
@@ -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 (
|
||||
<label className={css.switch}>
|
||||
<input
|
||||
type="checkbox"
|
||||
className={css.switchInput}
|
||||
checked={checked}
|
||||
aria-label={label}
|
||||
onChange={event => { onChange(event.currentTarget.checked) }}
|
||||
/>
|
||||
<span className={css.switchTrack} aria-hidden="true">
|
||||
<span className={css.switchThumb} />
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<label key={toggle.key} className={css.popupRow}>
|
||||
<div key={toggle.key} className={css.popupRow}>
|
||||
<span className={css.rowText}>
|
||||
<span className={css.title}>{title}</span>
|
||||
{textOf(toggle.desc) !== '' && <span className={css.desc}>{textOf(toggle.desc)}</span>}
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
className={css.toggle}
|
||||
<Switch
|
||||
label={title}
|
||||
checked={prefBool(prefs, toggle.key)}
|
||||
aria-label={title}
|
||||
onChange={event => { onToggle(toggle, event.currentTarget.checked) }}
|
||||
onChange={(next) => { onToggle(toggle, next) }}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
@@ -262,9 +294,10 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
|
||||
|
||||
/**
|
||||
* One SMALL toggle card for the responsive inventory grid: the card's main
|
||||
* area is the switch (click to flips, visual state IS the state), the
|
||||
* check badge sits at the far right, and a feature that declares related
|
||||
* settings carries a gear corner button opening its settings popup.
|
||||
* area is the switch (click to flips, visual state IS the state), the icon
|
||||
* sits in a rounded chip, the check badge pins to the far right, and a
|
||||
* feature that declares related settings carries a gear corner button
|
||||
* opening its settings popup.
|
||||
*/
|
||||
const renderCard = (props: {
|
||||
title: string
|
||||
@@ -289,12 +322,12 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
|
||||
>
|
||||
<span className={css.cardTop}>
|
||||
{props.icon !== null && props.icon !== undefined && (
|
||||
<span className={css.cardIcon}>{props.icon}</span>
|
||||
<span className={css.cardIconChip}>{props.icon}</span>
|
||||
)}
|
||||
<span className={css.cardTitle}>{props.title}</span>
|
||||
{props.enabled && (
|
||||
<span className={css.cardCheck}>
|
||||
<IconCheckOutline16 size={14} />
|
||||
<IconCheckOutline16 size={12} />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
@@ -308,7 +341,7 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
|
||||
title={t('settingsPopup')}
|
||||
onClick={props.onOpenSettings}
|
||||
>
|
||||
<IconSettingsOutline16 size={14} />
|
||||
<IconSettingsOutline16 size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -317,96 +350,127 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
|
||||
|
||||
return (
|
||||
<div className={css.section}>
|
||||
<div className={css.sectionHeading}>{t('settingsGeneralTitle')}</div>
|
||||
{renderCard({
|
||||
title: t('settingsOpenTitle'),
|
||||
desc: t('settingsOpenDesc'),
|
||||
icon: <IconPanelLeftOutline16 size={16} />,
|
||||
enabled: prefs.openByDefault,
|
||||
onToggle,
|
||||
})}
|
||||
<div className={css.row}>
|
||||
<span className={css.rowText}>
|
||||
<span className={css.title}>{t('settingsWidthTitle')}</span>
|
||||
<span className={css.desc}>{t('settingsWidthDesc')}</span>
|
||||
</span>
|
||||
<span className={css.control}>
|
||||
<Input
|
||||
type="number"
|
||||
className={css.percentInput}
|
||||
value={widthDraft}
|
||||
min={WIDTH_PERCENT_MIN}
|
||||
max={WIDTH_PERCENT_MAX}
|
||||
step={1}
|
||||
aria-label={t('settingsWidthTitle')}
|
||||
onChange={event => { setWidthDraft(event.currentTarget.value) }}
|
||||
onBlur={commitWidth}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter') event.currentTarget.blur()
|
||||
}}
|
||||
<p className={css.intro}>{t('settingsIntro')}</p>
|
||||
|
||||
{/* 常规: the DSH settings-row recipe — title/desc left, control right. */}
|
||||
<div className={css.group}>
|
||||
<div className={css.groupHeading}>{t('settingsGeneralTitle')}</div>
|
||||
<div className={css.row}>
|
||||
<span className={css.rowText}>
|
||||
<span className={css.title}>{t('settingsOpenTitle')}</span>
|
||||
<span className={css.desc}>{t('settingsOpenDesc')}</span>
|
||||
</span>
|
||||
<Switch
|
||||
label={t('settingsOpenTitle')}
|
||||
checked={prefs.openByDefault}
|
||||
onChange={onToggle}
|
||||
/>
|
||||
<span className={css.suffix}>{t('settingsWidthSuffix')}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className={css.row}>
|
||||
<span className={css.rowText}>
|
||||
<span className={css.title}>{t('settingsWidthTitle')}</span>
|
||||
<span className={css.desc}>{t('settingsWidthDesc')}</span>
|
||||
</span>
|
||||
<span className={css.control}>
|
||||
<Input
|
||||
type="number"
|
||||
className={css.percentInput}
|
||||
value={widthDraft}
|
||||
min={WIDTH_PERCENT_MIN}
|
||||
max={WIDTH_PERCENT_MAX}
|
||||
step={1}
|
||||
aria-label={t('settingsWidthTitle')}
|
||||
onChange={event => { setWidthDraft(event.currentTarget.value) }}
|
||||
onBlur={commitWidth}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter') event.currentTarget.blur()
|
||||
}}
|
||||
/>
|
||||
<span className={css.suffix}>{t('settingsWidthSuffix')}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className={css.row}>
|
||||
<span className={css.rowText}>
|
||||
<span className={css.title}>{t('settingsOpenPathTitle')}</span>
|
||||
<span className={css.desc}>{t('settingsOpenPathDesc')}</span>
|
||||
</span>
|
||||
<Switch
|
||||
label={t('settingsOpenPathTitle')}
|
||||
checked={prefs.interceptOpenPath}
|
||||
onChange={(next) => { togglePref({ interceptOpenPath: next }) }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{renderCard({
|
||||
title: t('settingsOpenPathTitle'),
|
||||
desc: t('settingsOpenPathDesc'),
|
||||
icon: <IconCodeOutline16 size={16} />,
|
||||
enabled: prefs.interceptOpenPath,
|
||||
onToggle: (next) => { togglePref({ interceptOpenPath: next }) },
|
||||
})}
|
||||
|
||||
{/* 侧边栏内容: one small card per registered tab type in a responsive
|
||||
grid; features declaring `settings.toggles` open their settings in
|
||||
the popup (gear corner button) instead of nested inline rows. */}
|
||||
<div className={css.sectionHeading}>{t('settingsTabsTitle')}</div>
|
||||
<div className={css.grid}>
|
||||
{tabs.map(tab => (
|
||||
<Fragment key={tab.id}>
|
||||
{renderCard({
|
||||
title: textOf(tab.title),
|
||||
desc: tab.id,
|
||||
icon: iconOf(tab.icon, 16),
|
||||
enabled: prefs.tabsEnabled[tab.id] !== false,
|
||||
onToggle: (next) => { onToggleTab(tab.id, next) },
|
||||
// The settings gear only while the feature is enabled: its
|
||||
// related settings are dormant while the feature is off.
|
||||
onOpenSettings: prefs.tabsEnabled[tab.id] !== false
|
||||
&& (tab.settings?.toggles?.length ?? 0) > 0
|
||||
? () => { setSettingsFor(tab) }
|
||||
: undefined,
|
||||
})}
|
||||
</Fragment>
|
||||
))}
|
||||
<div className={css.group}>
|
||||
<div className={css.groupHeading}>
|
||||
<span>{t('settingsTabsTitle')}</span>
|
||||
<span className={css.count}>{tabs.length}</span>
|
||||
</div>
|
||||
<div className={css.grid}>
|
||||
{tabs.map(tab => (
|
||||
<Fragment key={tab.id}>
|
||||
{renderCard({
|
||||
title: textOf(tab.title),
|
||||
desc: tab.id,
|
||||
icon: iconOf(tab.icon, 16),
|
||||
enabled: prefs.tabsEnabled[tab.id] !== false,
|
||||
onToggle: (next) => { onToggleTab(tab.id, next) },
|
||||
// The settings gear only while the feature is enabled: its
|
||||
// related settings are dormant while the feature is off.
|
||||
onOpenSettings: prefs.tabsEnabled[tab.id] !== false
|
||||
&& (tab.settings?.toggles?.length ?? 0) > 0
|
||||
? () => { setSettingsFor(tab) }
|
||||
: undefined,
|
||||
})}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 文件预览: one small card per registered file viewer. */}
|
||||
<div className={css.sectionHeading}>{t('settingsViewersTitle')}</div>
|
||||
<div className={css.grid}>
|
||||
{viewers.map(viewer => (
|
||||
<Fragment key={viewer.id}>
|
||||
{renderCard({
|
||||
title: textOf(viewer.title) || viewer.id,
|
||||
desc: viewer.exts.length === 0 ? t('settingsViewerCatchAll') : viewer.exts.join(' · '),
|
||||
icon: iconOf(viewer.icon, 16),
|
||||
enabled: prefs.viewersEnabled[viewer.id] !== false,
|
||||
onToggle: (next) => { onToggleViewer(viewer.id, next) },
|
||||
})}
|
||||
</Fragment>
|
||||
))}
|
||||
<div className={css.group}>
|
||||
<div className={css.groupHeading}>
|
||||
<span>{t('settingsViewersTitle')}</span>
|
||||
<span className={css.count}>{viewers.length}</span>
|
||||
</div>
|
||||
<div className={css.grid}>
|
||||
{viewers.map(viewer => (
|
||||
<Fragment key={viewer.id}>
|
||||
{renderCard({
|
||||
title: textOf(viewer.title) || viewer.id,
|
||||
desc: viewer.exts.length === 0 ? t('settingsViewerCatchAll') : viewer.exts.join(' · '),
|
||||
icon: iconOf(viewer.icon, 16),
|
||||
enabled: prefs.viewersEnabled[viewer.id] !== false,
|
||||
onToggle: (next) => { onToggleViewer(viewer.id, next) },
|
||||
})}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* The secondary settings popup: a feature's declared related settings
|
||||
as native checkbox rows (Modal chrome is the app's own). Mounted
|
||||
only while a feature is open — the Modal primitive runs hooks
|
||||
unconditionally, so a closed-but-mounted Modal would break SSR
|
||||
(and the renderToString spec) under the test dual-react split. */}
|
||||
as title/desc + switch rows in a wider-than-default Modal with a
|
||||
Done footer (Modal chrome is the app's own). Mounted only while a
|
||||
feature is open — the Modal primitive runs hooks unconditionally,
|
||||
so a closed-but-mounted Modal would break SSR (and the
|
||||
renderToString spec) under the test dual-react split. */}
|
||||
{settingsFor !== null && (
|
||||
<Modal
|
||||
open
|
||||
onClose={() => { setSettingsFor(null) }}
|
||||
title={textOf(settingsFor.title)}
|
||||
description={t('settingsPopupDesc', { feature: textOf(settingsFor.title) })}
|
||||
closeLabel={t('close')}
|
||||
className={css.popupDialog}
|
||||
footer={(
|
||||
<button type="button" className={css.done} onClick={() => { setSettingsFor(null) }}>
|
||||
{t('settingsDone')}
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
<FeatureSettingsRows
|
||||
toggles={settingsFor.settings?.toggles ?? []}
|
||||
|
||||
@@ -106,6 +106,9 @@ export const zh = {
|
||||
copyAbsolute: '复制绝对地址',
|
||||
download: '下载',
|
||||
settingsNav: '侧边卡片',
|
||||
settingsIntro: '管理侧边卡片的显示内容与默认行为',
|
||||
settingsPopupDesc: '为「{feature}」配置相关选项',
|
||||
settingsDone: '完成',
|
||||
settingsOpenTitle: '新会话默认打开',
|
||||
settingsOpenDesc: '新建会话时自动展开侧边卡片;已存在的会话保持各自布局',
|
||||
settingsWidthTitle: '默认宽度占比',
|
||||
@@ -307,6 +310,9 @@ export const en: Record<keyof typeof zh, string> = {
|
||||
copyAbsolute: 'Copy absolute path',
|
||||
download: 'Download',
|
||||
settingsNav: 'Side card',
|
||||
settingsIntro: 'Manage what the side card shows and how it behaves',
|
||||
settingsPopupDesc: 'Configure related options for {feature}',
|
||||
settingsDone: 'Done',
|
||||
settingsOpenTitle: 'Open by default for new conversations',
|
||||
settingsOpenDesc: 'Expand the side card automatically for brand-new conversations; existing conversations keep their own layouts',
|
||||
settingsWidthTitle: 'Default width share',
|
||||
|
||||
@@ -5,9 +5,11 @@
|
||||
* copy. The toggles are CARDS in a responsive grid: the card's main area is
|
||||
* the switch, the visual state IS the state (highlighted = enabled),
|
||||
* announced via `aria-pressed`, and the check badge sits at the far right.
|
||||
* Features that declare related settings carry a gear corner button whose
|
||||
* popup rows (native checkboxes) are tested through the extracted
|
||||
* FeatureSettingsRows component (the Modal portal renders only while open).
|
||||
* The general rows follow the DSH settings-row recipe with custom SWITCHES
|
||||
* (real checkboxes driving a styled track). Features that declare related
|
||||
* settings carry a gear corner button whose popup rows (switch controls)
|
||||
* are tested through the extracted FeatureSettingsRows component (the Modal
|
||||
* portal renders only while open).
|
||||
*
|
||||
* Rendered with renderToString (mount effects — the settings RPC sync — do
|
||||
* not run in SSR; the initial store prefs are the render input).
|
||||
@@ -78,14 +80,27 @@ describe('SideCardSection declarative inventory', () => {
|
||||
expect(html).toContain('>explorer<')
|
||||
expect(html).toContain('data-icon="subagent"')
|
||||
expect(html).toContain('>Subagents<')
|
||||
// Default prefs: openByDefault + interceptOpenPath + both tabs + the
|
||||
// image viewer are all enabled → 5 cards pressed, none pressed=false.
|
||||
// Default prefs: openByDefault + interceptOpenPath switches checked, and
|
||||
// both tabs + the image viewer cards pressed (3 aria-pressed cards).
|
||||
// The nested auto-open toggle is NOT an inline card (it lives in the popup).
|
||||
expect(pressedCount(html, 'true')).toBe(5)
|
||||
expect(pressedCount(html, 'true')).toBe(3)
|
||||
expect(pressedCount(html, 'false')).toBe(0)
|
||||
// The general toggles are custom switches (real checkboxes, checked).
|
||||
expect(html.match(/checked=""/g)?.length).toBe(2)
|
||||
expect(html).not.toContain('Auto-open Subagents')
|
||||
})
|
||||
|
||||
it('renders the section intro and group headings with inventory counts', () => {
|
||||
const { store, service } = mount()
|
||||
const html = renderSection(store, service)
|
||||
expect(html).toContain('Manage what the side card shows and how it behaves')
|
||||
// Group headings carry the inventory count badge (2 tabs, 1 viewer).
|
||||
expect(html).toContain('>Sidebar content</span><span')
|
||||
expect(html).toContain('>File viewers</span><span')
|
||||
expect(html).toContain('>2</span>')
|
||||
expect(html).toContain('>1</span>')
|
||||
})
|
||||
|
||||
it('renders one small card per registered viewer: icon + title + exts', () => {
|
||||
const { store, service } = mount()
|
||||
const html = renderSection(store, service)
|
||||
@@ -111,8 +126,9 @@ describe('SideCardSection declarative inventory', () => {
|
||||
expect(html).toContain('>Subagents<')
|
||||
expect(html).toContain('>Image<')
|
||||
expect(pressedCount(html, 'false')).toBe(2)
|
||||
// openByDefault + interceptOpenPath + the explorer stay pressed.
|
||||
expect(pressedCount(html, 'true')).toBe(3)
|
||||
// The explorer card stays pressed; the general switches stay checked.
|
||||
expect(pressedCount(html, 'true')).toBe(1)
|
||||
expect(html.match(/checked=""/g)?.length).toBe(2)
|
||||
})
|
||||
|
||||
it('hides the gear of a disabled feature (its related settings are dormant)', () => {
|
||||
@@ -134,7 +150,7 @@ describe('FeatureSettingsRows (the secondary settings popup body)', () => {
|
||||
desc: () => 'Expand on new subagent',
|
||||
}]
|
||||
|
||||
it('renders one native checkbox row per declared toggle with its current value', () => {
|
||||
it('renders one switch row per declared toggle with its current value', () => {
|
||||
const html = renderToString(createElement(FeatureSettingsRows, {
|
||||
toggles,
|
||||
prefs,
|
||||
@@ -142,7 +158,9 @@ describe('FeatureSettingsRows (the secondary settings popup body)', () => {
|
||||
}))
|
||||
expect(html).toContain('Auto-open Subagents')
|
||||
expect(html).toContain('Expand on new subagent')
|
||||
// The checkbox reflects the prefs value (false here → unchecked).
|
||||
// The row's switch is a real checkbox (aria-label = the toggle title)
|
||||
// reflecting the prefs value (false here → unchecked).
|
||||
expect(html).toContain('aria-label="Auto-open Subagents"')
|
||||
expect(html).not.toContain('checked=""')
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user