blob: 4e77bac8eba7b6fb86698464cbf3f993a574146b [file]
// apps/desktop/src/renderer/settings/settings-section.tsx
//
// The ONE grouping unit for a settings page.
//
// Before this, the 14 settings pages shared no page vocabulary. 通用 stacked
// four Astryx `Card`s with NO titles — four unlabeled boxes whose grouping the
// user had to infer. 外观 used no cards at all. 权限与能力 opened with a
// `SectionHeader` repeating the page title verbatim. Five different page-root
// containers existed (`settingsStructuredPage`, `settingsUsagePage`,
// `settingsFeatureStatusPage`, `settingsHealthPage`, `settingsAboutPage`) and
// 222 bespoke `.settings*` selectors carried the difference.
//
// A settings page is a list of LABELED GROUPS. That is the whole model:
// a group states what it configures, optionally why, optionally offers one
// group-level action, and then lists its rows. `SettingsSection` is that unit,
// so a page becomes a flat list of sections and stops inventing layout.
//
// `variant`:
// 'rows' (default) — the body is the shared `.settingsRows` open row group:
// edge-to-edge rows split by hairlines, no card chrome. This is the Astryx
// settings idiom (see the CLI's settings/settings-dialog templates and
// `astryx docs layout`: "no stacked full-width Cards as page structure";
// rows are "edge-to-edge, dividers"). Cards remain for genuine callouts.
// 'bare' — the body is a plain block, for groups whose content is not a row
// list (the 外观 option grids, a form layout, a chart). The section still
// contributes its header, anchor divider, and page rhythm.
import type { ReactNode } from 'react';
import { Divider, Heading, HStack, Item, Text, VStack } from '@astryxdesign/core';
import { cn } from '@maka/ui';
/**
* The ONE page-root container: a flat list of `SettingsSection`s at the open
* idiom's 32px rhythm. Pages used to reach for the bare
* `.settingsStructuredPage` class; the kit owns the container now, so a page
* never references page-layout CSS directly.
*
* Class is `settingsPageStack`, NOT `settingsPage` — `.settingsModal.settingsPage`
* is the pre-existing fullscreen-shell modifier and must not match this rule.
* Deliberately a `div` + kit-owned `.settingsPageStack` grid (nav-sidebar.css),
* NOT an Astryx `VStack`: the #1362 fix needs grid's `minmax(0, 1fr)`
* explicit column. In a flex stack a stretched child keeps its
* `min-width: auto` min-content floor, so one wide child (a scrollable
* `<pre>`, a long mono path) would poke past the content column again.
*/
export function SettingsPage(props: {
className?: string;
/** `section` when the page is a labeled landmark of a larger surface. */
as?: 'div' | 'section';
'aria-label'?: string;
children: ReactNode;
}) {
const Tag = props.as ?? 'div';
return (
<Tag className={cn('settingsPageStack', props.className)} aria-label={props['aria-label']}>
{props.children}
</Tag>
);
}
export function SettingsSection(props: {
/** Group label. Omit only for a page's single unlabeled lead group. */
title?: ReactNode;
/** id for the title Heading; the section wires `aria-labelledby` to it so
* the landmark is named (remote-access e2e relies on these headings). */
titleId?: string;
/** One quiet line under the title explaining what the group governs. */
description?: ReactNode;
/** Group-level action cluster (refresh, add, filter), right-aligned. */
action?: ReactNode;
variant?: 'rows' | 'bare';
className?: string;
/** Class for the body element, when a page needs to pin its own grid. */
bodyClassName?: string;
children: ReactNode;
}) {
const hasHeader = props.title != null || props.description != null || props.action != null;
return (
<section className={cn('settingsSection', props.className)} aria-labelledby={props.titleId}>
{hasHeader ? (
/* The header is Astryx's own settings idiom — `Heading level={3}` over
a `Text type="supporting" color="secondary"` lede — as used by the
`settings` and `settings-sidebar` page templates the CLI vendors.
It was @maka/ui's SectionHeader, which styles the same two lines with
hand-written Tailwind (`text-[length:var(--font-size-ui)]
font-semibold`, a caption-sized subtitle). Deferring to Astryx means
section typography now moves with the theme instead of with a copy
of the theme's values. */
/* wrap: at the 480px window floor a multi-button action cluster
must drop under the title instead of crushing it (the old
bot-runtime header carried a media query for this). */
<HStack gap={3} align="start" justify="between" wrap="wrap">
<VStack gap={0.5}>
{props.title != null ? <Heading level={3} id={props.titleId}>{props.title}</Heading> : null}
{props.description != null ? (
<Text type="supporting" size="sm" color="secondary">{props.description}</Text>
) : null}
</VStack>
{props.action != null ? <div>{props.action}</div> : null}
</HStack>
) : null}
{hasHeader ? <Divider /> : null}
{props.variant === 'bare' ? (
<div className={cn('settingsSectionBody', props.bodyClassName)}>{props.children}</div>
) : (
<div className={cn('settingsRows', props.bodyClassName)}>
{props.children}
</div>
)}
</section>
);
}
/**
* The ONE row unit inside a 'rows' section: label + wrapping helper line on
* the left, one control (or read-only value) on the right. Astryx `Item` is
* the layout; this wrapper exists for two Astryx behaviors that are wrong
* for a settings surface, fixed once here instead of per call site:
*
* 1. `Item` single-line-truncates STRING descriptions. A settings helper
* line ("switching applies immediately and persists…") must wrap, never
* ellipsize — the truncated tail is exactly the part that says what the
* control does. Wrapping the text in a fragment makes it a ReactNode,
* which `Item` renders without truncation; the description span's
* inherited type styles still apply.
* 2. The end slot needs a bounded share of the row. An unbounded control
* (SegmentedControl with English labels, a model picker trigger) would
* otherwise crush the label column to nothing before it wraps —
* `.settingsRowEnd` caps it and lets the container query in rows.css
* stack it under the label on narrow cards.
*
* `density="balanced"` (8px block, flush inline) keeps rows edge-aligned
* with the section header text — the open idiom has no card inset, so rows
* must not indent relative to their heading.
*/
export function SettingsRow(props: {
label: ReactNode;
description?: ReactNode;
/** The row's control / value cluster, right-aligned. */
end?: ReactNode;
align?: 'center' | 'start';
children?: never;
}) {
return (
<Item
density="balanced"
align={props.align}
label={props.label}
description={props.description == null ? undefined : <>{props.description}</>}
endContent={props.end == null ? undefined : <span className="settingsRowEnd">{props.end}</span>}
/>
);
}
/**
* A full-width form block inside a 'rows' section — a `FormLayout`, one wide
* `TextInput`/`TextArea`, or a preview body. Owns the same 12px inset as
* `SettingsRow` via padding (not margin), so the card's hairline dividers
* span the full card width on either side of it.
*/
export function SettingsField(props: { className?: string; children: ReactNode }) {
return <div className={cn('settingsFieldRow', props.className)}>{props.children}</div>;
}
/** A trailing action cluster row (test/export/import buttons) in a 'rows' card. */
export function SettingsActions(props: { role?: string; 'aria-label'?: string; children: ReactNode }) {
return (
<div className="settingsFieldRow settingsActionRow" role={props.role} aria-label={props['aria-label']}>
{props.children}
</div>
);
}