+
{tab.group}
)}
@@ -201,7 +188,8 @@ function SidebarInner({
{footer && (
{footer}
diff --git a/client/src/components/Settings/Section.tsx b/client/src/components/Settings/Section.tsx
index dbb43938f..6d6640616 100644
--- a/client/src/components/Settings/Section.tsx
+++ b/client/src/components/Settings/Section.tsx
@@ -1,5 +1,6 @@
import React from 'react'
import type { LucideIcon } from 'lucide-react'
+import { SettingsCard } from './settingsKit'
interface SectionProps {
title: string
@@ -9,20 +10,18 @@ interface SectionProps {
*/
icon: LucideIcon | React.ComponentType<{ className?: string }>
badge?: React.ReactNode
+ /** One line under the title in the head band. */
+ hint?: React.ReactNode
+ /** A control on the right of the head band. */
+ action?: React.ReactNode
children: React.ReactNode
}
-export default function Section({ title, icon: Icon, badge, children }: SectionProps): React.ReactElement {
+/** A settings group: the planner's card with a head band (see settingsKit). */
+export default function Section({ title, icon, badge, hint, action, children }: SectionProps): React.ReactElement {
return (
-
-
-
-
{title}
- {badge}
-
-
- {children}
-
-
+
+ {children}
+
)
}
diff --git a/client/src/components/Settings/settingsKit.tsx b/client/src/components/Settings/settingsKit.tsx
new file mode 100644
index 000000000..9024269e1
--- /dev/null
+++ b/client/src/components/Settings/settingsKit.tsx
@@ -0,0 +1,169 @@
+import type { ReactNode } from 'react'
+import type { LucideIcon } from 'lucide-react'
+import { NEUTRAL_TINT, fs } from '../shared/DialogShell'
+
+/*
+ * The settings and admin pages in the planner's language (#2541): a tinted bar
+ * on top, cards with a head band, white rows inside them, eyebrow labels over
+ * fields. One kit for both pages, so a toggle, a hint or a danger zone reads the
+ * same wherever it sits. Fields themselves come from shared/dialogParts (INPUT,
+ * EditorField, Segmented) and shared/CustomSelect, like in the editors.
+ */
+
+type IconLike = LucideIcon | React.ComponentType<{ className?: string; size?: number; strokeWidth?: number }>
+
+/** The page's own bar: icon tile, title and subtitle on the left, actions on the right. */
+export function SettingsHeader({ icon: Icon, title, subtitle, actions }: {
+ icon: IconLike
+ title: ReactNode
+ subtitle?: ReactNode
+ actions?: ReactNode
+}) {
+ return (
+
+
+
+
+
+
{title}
+ {subtitle &&
{subtitle}
}
+
+ {actions &&
{actions}
}
+
+ )
+}
+
+/**
+ * A group of settings: a card with a head band (icon tile, title, an optional
+ * badge and an action on the right) and its body below. `tone` tints the band:
+ * neutral for ordinary groups, danger for the destructive ones.
+ */
+export function SettingsCard({ icon: Icon, title, hint, badge, action, tone = 'neutral', children, id, bodyClassName }: {
+ icon?: IconLike
+ title: ReactNode
+ /** One line under the title in the band. */
+ hint?: ReactNode
+ badge?: ReactNode
+ action?: ReactNode
+ tone?: 'neutral' | 'danger'
+ children?: ReactNode
+ id?: string
+ /** Replaces the body's padded column. */
+ bodyClassName?: string
+}) {
+ const tint = tone === 'danger' ? 'color-mix(in srgb, var(--danger) 9%, transparent)' : NEUTRAL_TINT
+ return (
+
+
+ {Icon && (
+
+
+
+ )}
+
+
+
{title}
+ {badge}
+
+ {hint &&
{hint}
}
+
+ {action &&
{action}
}
+
+ {children != null && {children}
}
+
+ )
+}
+
+/** A white box of rows separated by hairlines, the way the bag sidebar lists its bags. */
+export function SettingRows({ children, className = '' }: { children: ReactNode; className?: string }) {
+ return (
+
+ {children}
+
+ )
+}
+
+/**
+ * One setting as a row: what it is (and a hint) on the left, the control on the
+ * right. `stacked` puts the control under the text for wide controls (a select,
+ * a segmented row) on narrow windows and always for a `full` control.
+ */
+export function SettingRow({ label, hint, control, children, stacked = false, dimmed = false, htmlFor }: {
+ label: ReactNode
+ hint?: ReactNode
+ /** The control on the right. */
+ control?: ReactNode
+ /** Extra content under the row (an expanded editor, a list). */
+ children?: ReactNode
+ stacked?: boolean
+ /** While a save is on its way. */
+ dimmed?: boolean
+ htmlFor?: string
+}) {
+ return (
+
+
+
+
+ {hint &&
{hint}
}
+
+ {control &&
{control}
}
+
+ {children &&
{children}
}
+
+ )
+}
+
+/** A quiet line of explanation inside a card body. */
+export function SettingsHint({ children, className = '' }: { children: ReactNode; className?: string }) {
+ return
{children}
+}
+
+/** A row of pills to pick one (or several) from, for choices too many for a segmented track. */
+export function ChoiceChips
({ value, options, onChange, label, multi }: {
+ value: T | T[]
+ options: readonly { value: T; label: ReactNode; title?: string }[]
+ onChange: (value: T) => void
+ label: string
+ multi?: boolean
+}) {
+ const isOn = (v: T) => (Array.isArray(value) ? value.includes(v) : value === v)
+ return (
+
+ {options.map(o => {
+ const on = isOn(o.value)
+ return (
+
+ )
+ })}
+
+ )
+}
+
+/** A small status pill for a card's band or a row: neutral, success, warning or danger. */
+export function StatusPill({ tone = 'neutral', children, icon }: { tone?: 'neutral' | 'success' | 'warning' | 'danger' | 'accent'; children: ReactNode; icon?: ReactNode }) {
+ const look = {
+ neutral: 'border border-edge-faint bg-surface-card text-content-secondary',
+ success: 'bg-success-soft text-success',
+ warning: 'bg-warning-soft text-warning',
+ danger: 'bg-danger-soft text-danger',
+ accent: 'bg-accent text-accent-text',
+ }[tone]
+ return (
+
+ {icon}{children}
+
+ )
+}
+
+/** The buttons of a settings card, in the dialogs' shapes. */
+export const SETTINGS_BUTTON = 'inline-flex items-center justify-center gap-1.5 rounded-[10px] bg-surface-card px-3.5 py-2 font-medium text-content shadow-sm ring-1 ring-edge-faint hover:bg-surface-secondary disabled:cursor-default disabled:opacity-50'
+export const SETTINGS_BUTTON_PRIMARY = 'inline-flex items-center justify-center gap-1.5 rounded-[10px] bg-accent px-4 py-2 font-medium text-accent-text hover:opacity-90 disabled:cursor-default disabled:opacity-50'
+export const SETTINGS_BUTTON_DANGER = 'inline-flex items-center justify-center gap-1.5 rounded-[10px] bg-danger-soft px-3.5 py-2 font-medium text-danger hover:opacity-90 disabled:cursor-default disabled:opacity-50'
+/** A round icon action in a band or a row, named by its tooltip. */
+export const SETTINGS_ICON_BUTTON = 'grid h-8 w-8 flex-none place-items-center rounded-[10px] bg-surface-card text-content-muted shadow-sm ring-1 ring-edge-faint hover:text-content disabled:cursor-default disabled:opacity-50'
diff --git a/client/src/pages/SettingsPage.tsx b/client/src/pages/SettingsPage.tsx
index 6c0626074..cba7d7cf1 100644
--- a/client/src/pages/SettingsPage.tsx
+++ b/client/src/pages/SettingsPage.tsx
@@ -3,6 +3,7 @@ import { Settings, SlidersHorizontal, Paintbrush, Map, Bell, Plug, CloudOff, Use
import { useTranslation } from '../i18n'
import PageShell from '../components/Layout/PageShell'
import PageSidebar, { type PageSidebarTab } from '../components/Layout/PageSidebar'
+import { SettingsHeader } from '../components/Settings/settingsKit'
import DisplaySettingsTab from '../components/Settings/DisplaySettingsTab'
import AppearanceSettingsTab from '../components/Settings/AppearanceSettingsTab'
import MapSettingsTab from '../components/Settings/MapSettingsTab'
@@ -55,18 +56,10 @@ function SettingsPageDesktop(): React.ReactElement {
return (
-
+
{/* Header */}
-
-
-
-
-
-
{t('settings.title')}
-
{t('settings.subtitle')}
-
-
+
{/* Sidebar layout */}