mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-10 02:41:14 +00:00
cfb42af4e0
* fix(dashboard): replace Stack Health update badge with an icon The pill badge duplicated space already used by the stack name column. A CircleArrowUp icon after the name signals an update is available without competing with the existing ArrowUp/ArrowDown sort indicators in the same table. * fix(dashboard): add accessible name to update-available icon Icon-only indicators need an aria-label directly on the icon; title on a non-interactive span is not reliably announced by screen readers. * test(dashboard): cover the update-available icon's accessible name The icon-only indicator and its aria-label fix had no regression guard, unlike the equivalent update dot in StackRow. * refactor(dashboard): compute the update-available label once per row It was being derived twice (title and aria-label) from the same row.outdatedServices input. * fix: drop Community-tier pricing upsells from settings Community operators no longer see the "See pricing" link in Licensing or the "Need direct support?" callout in Support. The pricing link now only shows for an expired paid license needing to renew. * fix: make Resources images/volumes tables actually scrollable The tables were wrapped in a Radix ScrollArea sized with max-h-[62vh]. Radix's viewport uses height:100%, which cannot resolve against an ancestor whose computed height is auto (max-height alone isn't a definite height), so the viewport silently grew past the visible box and the extra rows were clipped with no way to reach them. Verified live: several image rows were permanently unreachable, with no working internal scrollbar and not enough outer page scroll to compensate. Switched to an explicit h-[62vh], which the viewport can resolve correctly, matching every other working ScrollArea in the codebase. Falls back to h-auto below the md breakpoint so the bespoke mobile layout keeps shrinking to content and scrolling via the outer page instead of gaining a fixed-height inner scroll box. * fix: apply ScrollArea definite-height fix across remaining lists Radix ScrollArea needs an explicit height, not max-height, or the viewport collapses and clipped rows become unreachable. Extend the Resources fix to security, settings, git, and create/import surfaces, and drop redundant outer wrappers where ModalBody already scrolls. * fix: migrate Networking tables to Radix ScrollArea Networks and Findings used native max-h + overflow-auto, which worked but broke glass scrollbar consistency with Resources and the design system. Switch them to ScrollArea with a definite height and the same mobile fallback as the other inventory tables. * fix: warn Classic bar users that the style is retiring soon When Appearance Navigation is set to Classic bar, show the same warn SettingsCallout pattern used for Constrained graphics. Preference is kept until removal; no alternate style is named in the copy. * fix: move Channels delivery retries below channel tabs Put channel configuration first and keep Delivery retries as a shared footer control under the Discord/Slack/Webhook/Apprise tabs. * fix: drop redundant More masthead from Smart bar overflow menu The trigger already reads More, so the dropdown masthead repeated the same label. Leave titled mastheads on Compact Navigate and Add quick link menus. * test: align Smart More E2E with masthead removal The overflow menu no longer shows a More heading. Assert the menu via the Logs item and lock that the redundant masthead stays gone. * fix: consolidate Fleet Map toolbar filters into a single row Adopt the same retractable search control used on Fleet > Overview and move the flag filters (missing deps, port conflicts, orphans, shared) onto the toolbar row right after the Graph/List selector. The node filter becomes a dropdown instead of individual toggle chips so it does not clutter the row as fleet size grows. * fix: move Networking Topology filters onto the search toolbar row Merge the ownership selector and boolean filter chips (include system, exposed, drift, missing external, shared) onto the same row as the stack/network search inputs, matching the Fleet Map toolbar layout. * fix: default the reclaimable-space banner off Resources > Docker & Storage's "Show reclaimable-space banner" toggle now defaults to off instead of on. Also flips the /settings fetch failure path to fail closed (hide the banner) to match the new default, instead of failing open. * fix: raise Compact launcher quick links cap from 5 to 7 * fix: add Discord link to Settings Support Self-serve Gives users a community chat channel alongside Documentation and GitHub Issues, using the official Discord mark since lucide-react has no brand icon for it. * fix: stop container NET I/O metric row height jump Give NET I/O more column share than CPU/MEM and keep metric values on one line with truncate so three-digit rates cannot grow the strip. * fix: elevate Doctor tab between Activity and Drift Make Compose Doctor easier to find in the anatomy strip by placing it with the ops judgment cluster, ahead of Dossier and inventory tabs.
571 lines
27 KiB
TypeScript
571 lines
27 KiB
TypeScript
import { Check, Info } from 'lucide-react';
|
||
import { cn } from '@/lib/utils';
|
||
import { Combobox } from '@/components/ui/combobox';
|
||
import { Slider } from '@/components/ui/slider';
|
||
import { SegmentedControl } from '@/components/ui/segmented-control';
|
||
import { TogglePill } from '@/components/ui/toggle-pill';
|
||
import { useDensity } from '@/hooks/use-density';
|
||
import type { Density } from '@/hooks/use-density';
|
||
import { useLogChipColorMode, type LogChipColorMode } from '@/hooks/use-log-chip-color-mode';
|
||
import { useTopNavLabels } from '@/hooks/use-top-nav-labels';
|
||
import { useTopNavAlign, type TopNavAlign } from '@/hooks/use-top-nav-align';
|
||
import { useTopNavMode, type TopNavMode } from '@/hooks/use-top-nav-mode';
|
||
import { useTopNavQuickLinks, MAX_QUICK_LINKS } from '@/hooks/use-top-nav-quick-links';
|
||
import { getAppNavItem } from '@/lib/navigation/appNavRegistry';
|
||
import type { NavDestination } from '@/lib/navigation/appNavRegistry';
|
||
import type { ActiveView } from '@/lib/router/routeTypes';
|
||
import {
|
||
useTheme, activeVisualStyle, THEME_MODE_OPTIONS, ACCENTS, CONTRAST, BORDER_BOOST, GLOW, TYPE_SCALE,
|
||
type VisualStyle, type HeadingStyle, type ChartStyle,
|
||
} from '@/hooks/use-theme';
|
||
import { AccentPicker } from '@/components/theme/AccentPicker';
|
||
import { ThemePreview } from '@/components/theme/ThemePreview';
|
||
import { TypeChips } from '@/components/theme/TypeChips';
|
||
import { UI_FONT_OPTIONS, MONO_FONT_OPTIONS } from '@/components/theme/typeOptions';
|
||
import { SettingsSection } from './SettingsSection';
|
||
import { SettingsField } from './SettingsField';
|
||
import { SettingsActions, SettingsSecondaryButton } from './SettingsActions';
|
||
import { SettingsCallout } from './SettingsCallout';
|
||
|
||
const DENSITY_OPTIONS: { value: Density; label: string }[] = [
|
||
{ value: 'comfortable', label: 'Comfortable' },
|
||
{ value: 'compact', label: 'Compact' },
|
||
];
|
||
|
||
const DENSITY_DESCRIPTIONS: Record<Density, string> = {
|
||
comfortable: 'Default spacing. Roomy rows for review and orientation.',
|
||
compact: 'Tighter rows and tiles. Fits more on screen for dense dashboards.',
|
||
};
|
||
|
||
const TOP_NAV_ALIGN_OPTIONS: { value: TopNavAlign; label: string }[] = [
|
||
{ value: 'left', label: 'Left' },
|
||
{ value: 'center', label: 'Center' },
|
||
];
|
||
|
||
const TOP_NAV_MODE_OPTIONS: { value: TopNavMode; label: string }[] = [
|
||
{ value: 'classic', label: 'Classic bar' },
|
||
{ value: 'smart', label: 'Smart bar' },
|
||
{ value: 'compact', label: 'Compact launcher' },
|
||
];
|
||
|
||
const CHART_STYLE_OPTIONS: { value: ChartStyle; label: string }[] = [
|
||
{ value: 'muted', label: 'Muted' },
|
||
{ value: 'heat', label: 'Heat' },
|
||
{ value: 'signature', label: 'Signature' },
|
||
];
|
||
|
||
const HEADING_STYLE_OPTIONS: { value: HeadingStyle; label: string }[] = [
|
||
{ value: 'clean', label: 'Clean' },
|
||
{ value: 'signature', label: 'Signature' },
|
||
];
|
||
|
||
const CHIP_COLOR_OPTIONS: { value: LogChipColorMode; label: string }[] = [
|
||
{ value: 'unified', label: 'Unified' },
|
||
{ value: 'per-service', label: 'Per service' },
|
||
];
|
||
|
||
const fmtSigned = (v: number) => `${v > 0 ? '+' : ''}${v.toFixed(2)}`;
|
||
|
||
// Preview swatches for the Visual style cards. Calm uses the muted ramp; Signature
|
||
// deliberately puts the brand bar next to destructive to show the clash it fixes.
|
||
interface VisualCardData {
|
||
kind: VisualStyle;
|
||
name: string;
|
||
blurb: string;
|
||
bars: string[];
|
||
}
|
||
|
||
const VISUAL_CARDS: VisualCardData[] = [
|
||
{
|
||
kind: 'calm',
|
||
name: 'Calm',
|
||
blurb: 'Upright headings, muted flat charts. The readable default.',
|
||
bars: ['oklch(0.605 0.105 28)', 'oklch(0.715 0.085 70)', 'oklch(0.675 0.045 88)', 'oklch(0.565 0.018 250)'],
|
||
},
|
||
{
|
||
kind: 'signature',
|
||
name: 'Signature',
|
||
blurb: "Italic serif headings, saturated charts. Today's look.",
|
||
bars: ['var(--destructive)', 'var(--warning)', 'var(--brand)', 'color-mix(in oklch, var(--warning) 50%, var(--stat-icon))'],
|
||
},
|
||
];
|
||
const VISUAL_BAR_HEIGHTS = [16, 11, 13, 8];
|
||
|
||
function VisualCard({
|
||
card, selected, disabled, onSelect,
|
||
}: {
|
||
card: VisualCardData;
|
||
selected: boolean;
|
||
disabled?: boolean;
|
||
onSelect: () => void;
|
||
}) {
|
||
const { kind, name, blurb, bars } = card;
|
||
const calm = kind === 'calm';
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={onSelect}
|
||
disabled={disabled}
|
||
aria-pressed={selected}
|
||
className={cn(
|
||
'flex flex-col overflow-hidden rounded-lg border bg-well text-left transition-colors',
|
||
selected
|
||
? 'border-brand/55 ring-1 ring-brand/40'
|
||
: 'border-card-border border-t-card-border-top hover:border-t-card-border-hover',
|
||
disabled && 'opacity-50',
|
||
)}
|
||
>
|
||
<div className="flex h-[74px] flex-col justify-center gap-1.5 border-b border-hairline px-3.5">
|
||
<span
|
||
className={cn(
|
||
'text-[22px] leading-none text-stat-value',
|
||
calm ? 'font-sans font-semibold not-italic' : 'font-display font-normal italic',
|
||
)}
|
||
>
|
||
Critical
|
||
</span>
|
||
<span className="flex items-end gap-1" style={{ height: 16 }}>
|
||
{bars.map((b, i) => (
|
||
<span
|
||
key={i}
|
||
className="block w-[9px] rounded-sm"
|
||
style={{ background: b, height: VISUAL_BAR_HEIGHTS[i] }}
|
||
/>
|
||
))}
|
||
</span>
|
||
</div>
|
||
<div className="px-3.5 pb-3 pt-2.5">
|
||
<div className="flex items-center gap-2 text-[13px] font-semibold text-stat-value">
|
||
{name}
|
||
{selected ? <Check className="h-3.5 w-3.5 text-brand" strokeWidth={2.5} /> : null}
|
||
</div>
|
||
<p className="mt-1 text-[11px] leading-snug text-stat-subtitle">{blurb}</p>
|
||
</div>
|
||
</button>
|
||
);
|
||
}
|
||
|
||
export function AppearanceSection({
|
||
quickLinkCandidates = [],
|
||
}: {
|
||
quickLinkCandidates?: NavDestination[];
|
||
}) {
|
||
const [density, setDensity] = useDensity();
|
||
const [chipColorMode, setChipColorMode] = useLogChipColorMode();
|
||
const [topNavLabels, setTopNavLabels] = useTopNavLabels();
|
||
const [topNavAlign, setTopNavAlign] = useTopNavAlign();
|
||
const [topNavMode, setTopNavMode] = useTopNavMode();
|
||
const {
|
||
persistedIds: quickLinkIds,
|
||
addQuickLink,
|
||
removeQuickLink,
|
||
resetQuickLinks,
|
||
} = useTopNavQuickLinks();
|
||
const persistedSet = new Set(quickLinkIds);
|
||
const unpinnedCandidates = quickLinkCandidates.filter((item) => !persistedSet.has(item.value));
|
||
const atCapacity = quickLinkIds.length >= MAX_QUICK_LINKS;
|
||
const addEnabled = !atCapacity && unpinnedCandidates.length > 0;
|
||
const addDisabledReason = atCapacity
|
||
? 'Remove a pin or reset to free a slot'
|
||
: 'No more destinations available on this node';
|
||
const addOptions = unpinnedCandidates.map((item) => ({
|
||
value: item.value,
|
||
label: item.label,
|
||
}));
|
||
const {
|
||
theme, accent, borderBoost, glow, contrast, uiFont, monoFont, typeScale,
|
||
headingStyle, chartStyle, reducedEffects, reducedMotion, readability,
|
||
setTheme, setAccent, setBorderBoost, setGlow, setContrast, setUiFont, setMonoFont, setTypeScale,
|
||
setVisualStyle, setHeadingStyle, setChartStyle, setReducedEffects, setReducedMotion, setReadability,
|
||
} = useTheme();
|
||
const accentLabel = ACCENTS.find((a) => a.id === accent)?.label ?? 'Cyan';
|
||
// Readability is a sticky master: it forces the calm resolution at apply time
|
||
// without mutating the stored sub-axes, so the controls reflect the forced
|
||
// value and lock while it is on. Effects are reduced under readability too.
|
||
const effectiveReduced = readability || reducedEffects;
|
||
const effectiveContrast = contrast + (readability ? 0.18 : 0);
|
||
// A card is selected only while the stored sub-axes still match its preset, so
|
||
// a custom combination de-selects both (and readability resolves to null).
|
||
// Shared with the topbar quick-switch via activeVisualStyle so both surfaces
|
||
// agree on which style is active.
|
||
const activeVisual = activeVisualStyle({ headingStyle, chartStyle, reducedEffects, readability });
|
||
|
||
return (
|
||
<div className="flex flex-col gap-10">
|
||
<SettingsSection title="Visual style" kicker="the master switch">
|
||
<div className="grid grid-cols-2 gap-2.5 pt-3">
|
||
{VISUAL_CARDS.map((c) => (
|
||
<VisualCard
|
||
key={c.kind}
|
||
card={c}
|
||
selected={activeVisual === c.kind}
|
||
disabled={readability}
|
||
onSelect={() => setVisualStyle(c.kind)}
|
||
/>
|
||
))}
|
||
</div>
|
||
{readability ? (
|
||
<div className="mt-3 flex items-start gap-2.5 rounded-md border border-brand/30 bg-brand/10 px-3 py-2.5">
|
||
<Info className="mt-0.5 h-4 w-4 shrink-0 text-brand" strokeWidth={1.5} />
|
||
<p className="text-sm leading-relaxed text-stat-title">
|
||
<span className="font-medium text-brand">Readability mode is on.</span> It forces the calmest
|
||
settings and a small contrast lift. Turn it off below to choose a style by hand.
|
||
</p>
|
||
</div>
|
||
) : null}
|
||
</SettingsSection>
|
||
|
||
<SettingsSection title="Security visualization" kicker="the chart fix">
|
||
<SettingsField
|
||
label="Chart palette"
|
||
helper="Muted desaturates the severity colors; Heat is one warm ramp with no red/blue clash; Signature is the saturated set."
|
||
align="start"
|
||
>
|
||
<SegmentedControl
|
||
value={readability ? 'muted' : chartStyle}
|
||
options={CHART_STYLE_OPTIONS}
|
||
onChange={setChartStyle}
|
||
disabled={readability}
|
||
ariaLabel="Chart palette"
|
||
/>
|
||
</SettingsField>
|
||
</SettingsSection>
|
||
|
||
<SettingsSection title="Readability" kicker="this browser">
|
||
<SettingsField
|
||
label="Readability mode"
|
||
helper="One switch: upright headings, muted flat charts, reduced effects, and a contrast lift."
|
||
>
|
||
<TogglePill checked={readability} onChange={setReadability} aria-label="Readability mode" />
|
||
</SettingsField>
|
||
|
||
<SettingsField
|
||
label="Header style"
|
||
helper="Clean uses the upright interface face; Signature keeps the italic display serif."
|
||
align="start"
|
||
>
|
||
<SegmentedControl
|
||
value={readability ? 'clean' : headingStyle}
|
||
options={HEADING_STYLE_OPTIONS}
|
||
onChange={setHeadingStyle}
|
||
disabled={readability}
|
||
ariaLabel="Header style"
|
||
/>
|
||
</SettingsField>
|
||
|
||
<SettingsField
|
||
label="Contrast"
|
||
helper="Master contrast: spreads the page, ink, and borders together. Pair high contrast with OLED for the crispest panel."
|
||
>
|
||
<div className="flex items-center gap-3">
|
||
<Slider
|
||
value={[contrast]}
|
||
min={CONTRAST.min}
|
||
max={CONTRAST.max}
|
||
step={CONTRAST.step}
|
||
onValueChange={([v]) => setContrast(v)}
|
||
aria-label="Contrast"
|
||
/>
|
||
<span className="w-12 shrink-0 text-right font-mono text-xs tabular-nums text-stat-subtitle">
|
||
{fmtSigned(effectiveContrast)}
|
||
</span>
|
||
</div>
|
||
</SettingsField>
|
||
</SettingsSection>
|
||
|
||
<SettingsSection title="Motion & effects" kicker="this browser">
|
||
{!reducedMotion ? (
|
||
<SettingsCallout
|
||
tone="warn"
|
||
icon={<Info className="h-4 w-4" strokeWidth={1.5} />}
|
||
title="Constrained graphics"
|
||
subtitle="If the active Sencho tab feels heavy on integrated graphics or similar devices, turn on Reduced motion. Reduced effects and Calm alone may not be enough for that idle cost."
|
||
/>
|
||
) : null}
|
||
<SettingsField
|
||
label="Reduced motion"
|
||
helper="Minimizes interface animations and transitions (dialogs, menus, expand and collapse). Calm turns this on by default; choosing Signature turns it off. You can still toggle it independently afterward. First control to try for high idle GPU use on constrained graphics. Toasts are unaffected."
|
||
>
|
||
<TogglePill
|
||
checked={reducedMotion}
|
||
onChange={setReducedMotion}
|
||
aria-label="Reduced motion"
|
||
/>
|
||
</SettingsField>
|
||
|
||
<SettingsField
|
||
label="Reduced effects"
|
||
helper="Flattens card bevels, the accent glow, and chart gradients, and turns off glass blur with solid chrome fills. Also stops the decorative masthead rail animations even if Reduced motion is off. Optional secondary material simplification; not a substitute for Reduced motion on reported idle GPU cost."
|
||
>
|
||
<TogglePill
|
||
checked={effectiveReduced}
|
||
onChange={setReducedEffects}
|
||
disabled={readability}
|
||
aria-label="Reduced effects"
|
||
/>
|
||
</SettingsField>
|
||
|
||
<SettingsField
|
||
label="Ambient glow"
|
||
helper="Intensity of the accent-tinted glow behind the page."
|
||
>
|
||
<div className="flex items-center gap-3">
|
||
<Slider
|
||
value={[glow]}
|
||
min={GLOW.min}
|
||
max={GLOW.max}
|
||
step={GLOW.step}
|
||
onValueChange={([v]) => setGlow(v)}
|
||
disabled={effectiveReduced}
|
||
aria-label="Ambient glow"
|
||
/>
|
||
<span className="w-12 shrink-0 text-right font-mono text-xs tabular-nums text-stat-subtitle">
|
||
{glow.toFixed(2)}
|
||
</span>
|
||
</div>
|
||
</SettingsField>
|
||
|
||
<SettingsActions>
|
||
<SettingsSecondaryButton
|
||
onClick={() => setVisualStyle('calm')}
|
||
disabled={readability}
|
||
>
|
||
Reset to default
|
||
</SettingsSecondaryButton>
|
||
</SettingsActions>
|
||
</SettingsSection>
|
||
|
||
<SettingsSection title="Theme" kicker="this browser">
|
||
<div className="pt-3">
|
||
<ThemePreview />
|
||
</div>
|
||
|
||
<SettingsField
|
||
label="Mode"
|
||
helper="Dim lifts surfaces off black; OLED is true black; Light inverts; Auto follows your OS."
|
||
>
|
||
<SegmentedControl
|
||
value={theme}
|
||
options={THEME_MODE_OPTIONS}
|
||
onChange={setTheme}
|
||
ariaLabel="Theme mode"
|
||
/>
|
||
</SettingsField>
|
||
|
||
<SettingsField
|
||
label="Accent"
|
||
helper={`The one data color, used for charts, rails, and focus. Currently ${accentLabel}.`}
|
||
align="start"
|
||
>
|
||
<AccentPicker value={accent} onChange={setAccent} />
|
||
</SettingsField>
|
||
|
||
<SettingsField
|
||
label="Border brightness"
|
||
helper="Lift or soften every hairline so separation reads exactly how you like it."
|
||
>
|
||
<div className="flex items-center gap-3">
|
||
<Slider
|
||
value={[borderBoost]}
|
||
min={BORDER_BOOST.min}
|
||
max={BORDER_BOOST.max}
|
||
step={BORDER_BOOST.step}
|
||
onValueChange={([v]) => setBorderBoost(v)}
|
||
disabled={readability}
|
||
aria-label="Border brightness"
|
||
/>
|
||
<span className="w-12 shrink-0 text-right font-mono text-xs tabular-nums text-stat-subtitle">
|
||
{fmtSigned(readability ? 0.03 : borderBoost)}
|
||
</span>
|
||
</div>
|
||
</SettingsField>
|
||
|
||
<SettingsActions>
|
||
<SettingsSecondaryButton
|
||
onClick={() => {
|
||
setContrast(CONTRAST.default);
|
||
setBorderBoost(BORDER_BOOST.default);
|
||
setGlow(GLOW.default);
|
||
}}
|
||
>
|
||
Reset fine-tune
|
||
</SettingsSecondaryButton>
|
||
</SettingsActions>
|
||
</SettingsSection>
|
||
|
||
<SettingsSection title="Typography" kicker="this browser">
|
||
<SettingsField
|
||
label="Interface font"
|
||
helper="The sans face for body, labels, navigation, and buttons. Heading style follows your Visual style choice."
|
||
align="start"
|
||
>
|
||
<TypeChips value={uiFont} options={UI_FONT_OPTIONS} onChange={setUiFont} ariaLabel="Interface font" />
|
||
</SettingsField>
|
||
|
||
<SettingsField
|
||
label="Data font"
|
||
helper="The mono face for terminal, stats, codes, and timestamps."
|
||
align="start"
|
||
>
|
||
<TypeChips value={monoFont} options={MONO_FONT_OPTIONS} onChange={setMonoFont} ariaLabel="Data font" />
|
||
</SettingsField>
|
||
|
||
<SettingsField
|
||
label="Text size"
|
||
helper="Scales the whole interface from a single root multiplier. Default 1.00×."
|
||
>
|
||
<div className="flex items-center gap-3">
|
||
<Slider
|
||
value={[typeScale]}
|
||
min={TYPE_SCALE.min}
|
||
max={TYPE_SCALE.max}
|
||
step={TYPE_SCALE.step}
|
||
onValueChange={([v]) => setTypeScale(v)}
|
||
aria-label="Text size"
|
||
/>
|
||
<span className="w-12 shrink-0 text-right font-mono text-xs tabular-nums text-stat-subtitle">
|
||
{typeScale.toFixed(2)}×
|
||
</span>
|
||
</div>
|
||
</SettingsField>
|
||
</SettingsSection>
|
||
|
||
<SettingsSection title="Display" kicker="this browser">
|
||
<SettingsField
|
||
label="Density"
|
||
helper={DENSITY_DESCRIPTIONS[density]}
|
||
>
|
||
<Combobox
|
||
options={DENSITY_OPTIONS}
|
||
value={density}
|
||
onValueChange={(v) => {
|
||
if (v === 'comfortable' || v === 'compact') setDensity(v);
|
||
}}
|
||
placeholder="Select density"
|
||
/>
|
||
</SettingsField>
|
||
|
||
<SettingsField
|
||
label="Log chip color"
|
||
helper="Applies to service chips on multi-service or multi-container stacks. Unified uses the accent color for all chips. Per-service assigns each service a stable label color for faster visual scanning."
|
||
>
|
||
<SegmentedControl
|
||
value={chipColorMode}
|
||
options={CHIP_COLOR_OPTIONS}
|
||
onChange={setChipColorMode}
|
||
ariaLabel="Log chip color mode"
|
||
/>
|
||
</SettingsField>
|
||
</SettingsSection>
|
||
|
||
<SettingsSection title="Navigation" kicker="this browser">
|
||
{topNavMode === 'classic' ? (
|
||
<SettingsCallout
|
||
tone="warn"
|
||
icon={<Info className="h-4 w-4" strokeWidth={1.5} />}
|
||
title="Classic bar retiring"
|
||
subtitle="Classic bar will be removed soon. Your preference is kept until then."
|
||
/>
|
||
) : null}
|
||
<SettingsField
|
||
label="Navigation style"
|
||
helper="Smart bar is the recommended default: primary destinations stay visible, and the rest live under More. Classic keeps the full horizontal strip. Compact launcher puts destinations in a menu with optional quick links."
|
||
>
|
||
<SegmentedControl
|
||
value={topNavMode}
|
||
options={TOP_NAV_MODE_OPTIONS}
|
||
onChange={setTopNavMode}
|
||
ariaLabel="Navigation style"
|
||
/>
|
||
</SettingsField>
|
||
|
||
{(topNavMode === 'classic' || topNavMode === 'smart') && (
|
||
<SettingsField
|
||
label="Top navigation labels"
|
||
helper="Show text labels beside top navigation icons. Turn off for a more compact navigation bar."
|
||
>
|
||
<TogglePill checked={topNavLabels} onChange={setTopNavLabels} />
|
||
</SettingsField>
|
||
)}
|
||
|
||
{(topNavMode === 'classic' || topNavMode === 'smart') && !topNavLabels && (
|
||
<SettingsField
|
||
label="Top navigation alignment"
|
||
helper="Place the icon-only navigation against the left edge or centered in the bar."
|
||
>
|
||
<SegmentedControl
|
||
value={topNavAlign}
|
||
options={TOP_NAV_ALIGN_OPTIONS}
|
||
onChange={setTopNavAlign}
|
||
ariaLabel="Top navigation alignment"
|
||
/>
|
||
</SettingsField>
|
||
)}
|
||
|
||
{topNavMode === 'compact' && (
|
||
<SettingsField
|
||
label="Quick links"
|
||
helper="Up to seven pinned destinations on the top bar. Defaults are a starting set; add reachable destinations here or with the trailing + on the Compact bar."
|
||
>
|
||
<div className="flex w-full flex-col gap-2">
|
||
{quickLinkIds.length === 0 ? (
|
||
<p className="font-mono text-[11px] text-stat-subtitle">No quick links pinned.</p>
|
||
) : (
|
||
<ul className="flex flex-col gap-1">
|
||
{quickLinkIds.map((id) => {
|
||
const item = getAppNavItem(id);
|
||
const label = item?.label ?? id;
|
||
return (
|
||
<li
|
||
key={id}
|
||
className="flex items-center justify-between gap-2 rounded-md border border-glass-border px-2 py-1.5"
|
||
>
|
||
<span className="font-mono text-[11px] uppercase tracking-[0.14em]">
|
||
{label}
|
||
</span>
|
||
<SettingsSecondaryButton
|
||
type="button"
|
||
aria-label={`Remove ${label}`}
|
||
onClick={() => removeQuickLink(id)}
|
||
>
|
||
Remove
|
||
</SettingsSecondaryButton>
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
)}
|
||
<div className="flex flex-col gap-1.5">
|
||
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
|
||
Add quick link
|
||
</span>
|
||
{addEnabled ? (
|
||
<Combobox
|
||
options={addOptions}
|
||
value=""
|
||
onValueChange={(value) => {
|
||
if (value) addQuickLink(value as ActiveView);
|
||
}}
|
||
placeholder="Choose a destination"
|
||
/>
|
||
) : (
|
||
<p className="font-mono text-[11px] text-stat-subtitle">{addDisabledReason}</p>
|
||
)}
|
||
</div>
|
||
<SettingsActions>
|
||
<SettingsSecondaryButton type="button" onClick={resetQuickLinks}>
|
||
Reset to defaults
|
||
</SettingsSecondaryButton>
|
||
</SettingsActions>
|
||
</div>
|
||
</SettingsField>
|
||
)}
|
||
</SettingsSection>
|
||
|
||
<p className="font-mono text-[10px] leading-3 uppercase tracking-[0.18em] text-stat-subtitle/70">
|
||
ⓘ saved to this browser only · every device remembers its own choice
|
||
</p>
|
||
</div>
|
||
);
|
||
}
|