import { InfoIcon, Undo2Icon } from "lucide-react";import { useLocation, useNavigate } from "@tanstack/react-router";import { createContext, type ComponentPropsWithoutRef, type ReactNode, useCallback, useContext, useEffect, useMemo, useState,} from "react"; import { PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE, usePrimarySettingsAvailable,} from "../../hooks/useSettings";import { cn } from "../../lib/utils";import { WorkspacePageContainer, type WorkspacePageWidth } from "../WorkspacePageContainer";import { Button } from "../ui/button";import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; declare module "@tanstack/react-router" { interface HistoryState { settingsTargetHighlight?: boolean; }} interface SettingsSearchTargetContextValue { readonly targetId: string | null; readonly highlightTarget: boolean; readonly onTargetHandled: () => void;} const noop = () => undefined;const SettingsSearchTargetContext = createContext<SettingsSearchTargetContextValue>({ targetId: null, highlightTarget: true, onTargetHandled: noop,}); export function SettingsSearchTargetProvider({ targetId, highlightTarget = true, onTargetHandled = noop, children,}: { targetId: string | null; highlightTarget?: boolean; onTargetHandled?: () => void; children: ReactNode;}) { const value = useMemo( () => ({ targetId, highlightTarget, onTargetHandled }), [highlightTarget, onTargetHandled, targetId], ); return <SettingsSearchTargetContext value={value}>{children}</SettingsSearchTargetContext>;} function scrollAndFocusSettingsTarget(target: HTMLElement, highlight = true): void { const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; const markedScrollTarget = typeof target.querySelector === "function" ? target.querySelector<HTMLElement>(":scope > [data-settings-scroll-target]") : null; const scrollTarget = markedScrollTarget ?? (target.tagName === "SECTION" && target.firstElementChild ? (target.firstElementChild as HTMLElement) : target); scrollTarget.scrollIntoView({ behavior: prefersReducedMotion ? "auto" : "smooth", block: "center", }); target.focus({ preventScroll: true }); target.classList.remove("settings-search-target-pulse"); if (!highlight || prefersReducedMotion) return; void target.offsetWidth; target.classList.add("settings-search-target-pulse"); // The class also suppresses the focus outline (the pulse is the destination // indicator), so drop it once the element is no longer the destination. target.addEventListener("blur", () => target.classList.remove("settings-search-target-pulse"), { once: true, });} /** The row id a settings-search jump is currently trying to reach, if any. */export function useSettingsSearchTargetId(): string | null { return useContext(SettingsSearchTargetContext).targetId;} export function useSettingsSearchTarget<T extends HTMLElement>(id: string | undefined) { const { targetId, highlightTarget, onTargetHandled } = useContext(SettingsSearchTargetContext); const isSearchTarget = id !== undefined && id === targetId; const targetRef = useCallback( (target: T | null) => { if (target && isSearchTarget) { scrollAndFocusSettingsTarget(target, highlightTarget); onTargetHandled(); } }, [highlightTarget, isSearchTarget, onTargetHandled], ); return targetRef;} export function SettingsSearchTarget({ children, ...targetProps}: ComponentPropsWithoutRef<"div">) { const targetRef = useSettingsSearchTarget<HTMLDivElement>(targetProps.id); return ( <div {...targetProps} ref={targetRef} tabIndex={targetProps.id ? -1 : targetProps.tabIndex}> {children} </div> );} /** * Trigger classes for the composer model/traits pickers when they sit in a * settings row: match the `sm` control box (the composer pins them to 28px at * every breakpoint) and drop the composer's max-width. */export const SETTINGS_PICKER_TRIGGER_CLASSNAME = "h-8 min-h-8 min-w-0 max-w-none shrink-0 text-foreground/90 hover:text-foreground sm:h-7 sm:min-h-7"; /** Info affordance explaining how a setting interacts with the shared background policy. */export function PolicyTooltip({ children }: { readonly children: string }) { return ( <Tooltip> <TooltipTrigger delay={200} render={ <Button size="icon-micro" variant="ghost-muted" aria-label="Background policy details"> <InfoIcon className="size-3.5" /> </Button> } /> <TooltipPopup side="top" className="max-w-72"> {children} </TooltipPopup> </Tooltip> );} /** Re-render every `intervalMs`; return a stable timestamp snapshot for render-time relative labels. */export function useRelativeTimeTick(intervalMs = 1_000) { const [nowMs, setNowMs] = useState(() => Date.now()); useEffect(() => { const id = setInterval(() => setNowMs(Date.now()), intervalMs); return () => clearInterval(id); }, [intervalMs]); return nowMs;} /** Muted section headings have no descriptions; explanatory copy belongs to individual settings. */export function SettingsSection({ title, hideTitle = false, icon, headerAction, variant = "grouped", children, className, ...sectionProps}: ComponentPropsWithoutRef<"section"> & { title: string; hideTitle?: boolean; icon?: ReactNode; headerAction?: ReactNode; variant?: "grouped" | "plain"; children: ReactNode;}) { const targetRef = useSettingsSearchTarget<HTMLElement>(sectionProps.id); return ( <section {...sectionProps} ref={targetRef} tabIndex={sectionProps.id ? -1 : sectionProps.tabIndex} className={cn(!hideTitle && "space-y-2.5", className)} > {hideTitle ? ( <h2 className="sr-only">{title}</h2> ) : ( <div data-settings-scroll-target className="flex min-h-7 items-start justify-between gap-4 px-3 sm:px-4" > <div className="min-w-0"> <h2 className="flex min-h-7 items-center gap-2 text-sm font-normal tracking-[-0.005em] text-foreground/70"> {icon} {title} </h2> </div> <div className="flex min-h-7 min-w-7 items-center justify-end">{headerAction}</div> </div> )} <div data-settings-scroll-target={hideTitle ? "" : undefined} className={cn( "relative overflow-visible text-foreground", variant === "grouped" ? "rounded-xl border border-border/60 bg-card/40 shadow-xs/5 [&>*+*]:border-t [&>*+*]:border-border/50 [&>[data-slot=settings-row]]:rounded-none" : "space-y-1", )} > {children} </div> </section> );} export function SettingsUnavailableGroup({ children, message,}: { children: ReactNode; message?: ReactNode;}) { if (message === undefined) return children; return ( <div className="border-border/60 bg-muted/20 py-1.5"> <div className="flex items-start gap-2 px-3 py-2 text-[12px] leading-relaxed text-muted-foreground sm:px-4"> <InfoIcon className="mt-0.5 size-3.5 shrink-0 text-warning" /> <p>{message}</p> </div> <div className="[&_h3]:opacity-64 [&_p]:opacity-64">{children}</div> </div> );} /** * One setting. `serverScoped` marks rows whose value lives in the primary * environment's settings.json; where there is no primary (the hosted app) * the control goes inert with a tooltip instead of showing an editable * default that would never save. * * Keep descriptions short enough for one line where possible. Allow wrapping * for clarity or narrow screens instead of truncating or forcing no-wrap. * * Control sizing across settings follows three tiers so rows share a baseline: * - `control` slot: `size="sm"` (Button, Select, Input, NumberField) or `icon-sm`. * - Section `headerAction`s and buttons inside list items, cards, toolbars: `xs` / `icon-xs`. * - Inline affordances (reset arrows, info tooltips, table-cell buttons): `icon-micro`. * Dialog footers keep the app-wide default button size. */export function SettingsRow({ title, description, status, resetAction, control, serverScoped = false, children, className, ...rowProps}: Omit<ComponentPropsWithoutRef<"div">, "title"> & { title: ReactNode; description?: ReactNode; status?: ReactNode; resetAction?: ReactNode; control?: ReactNode; serverScoped?: boolean; children?: ReactNode;}) { const targetRef = useSettingsSearchTarget<HTMLDivElement>(rowProps.id); const primarySettingsAvailable = usePrimarySettingsAvailable(); const unavailable = serverScoped && !primarySettingsAvailable; const renderedReset = unavailable ? null : resetAction; const renderedControl = unavailable && control ? ( <Tooltip> <TooltipTrigger render={ // Focusable so keyboard users can still reach the explanation. <span tabIndex={0} className="flex w-full items-center rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring sm:w-auto" /> } > <div inert className="flex w-full items-center gap-2 opacity-50 sm:w-auto"> {control} </div> </TooltipTrigger> <TooltipPopup side="top" className="max-w-72"> {PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE} </TooltipPopup> </Tooltip> ) : ( control ); return ( <div {...rowProps} ref={targetRef} tabIndex={rowProps.id ? -1 : rowProps.tabIndex} data-slot="settings-row" className={cn( "rounded-xl px-3 sm:px-4 aria-disabled:opacity-50 aria-disabled:[&_*]:text-muted-foreground", children ? "pt-3 pb-1" : "py-3", className, )} > <div className="flex flex-col gap-3 sm:grid sm:grid-cols-[minmax(0,1fr)_minmax(10rem,auto)] sm:items-center sm:gap-8"> <div className="min-w-0 flex-1 space-y-1"> <div className="flex min-h-5 items-center gap-1.5"> <h3 className="text-sm font-medium tracking-[-0.005em] text-foreground">{title}</h3> <span className="inline-flex h-5 w-5 shrink-0 items-center justify-center"> {renderedReset} </span> </div> {description ? ( <p className="max-w-xl text-[13px] leading-[1.45] text-muted-foreground/80"> {description} </p> ) : null} {status ? <div className="pt-0.5 text-xs text-muted-foreground">{status}</div> : null} </div> {renderedControl ? ( <div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end"> {renderedControl} </div> ) : null} </div> {unavailable && children ? ( <div inert className="opacity-50"> {children} </div> ) : ( children )} </div> );} export function SettingResetButton({ label, tooltip = "Reset to default", disabled = false, onClick,}: { label: string; tooltip?: string; disabled?: boolean; onClick: () => void;}) { return ( <Tooltip> <TooltipTrigger render={ <Button size="icon-micro" variant="ghost-muted" aria-label={`Reset ${label} to default`} disabled={disabled} onClick={(event) => { event.stopPropagation(); onClick(); }} > <Undo2Icon className="size-3" /> </Button> } /> <TooltipPopup side="top">{tooltip}</TooltipPopup> </Tooltip> );} export function SettingsPageContainer({ children, className, width = "readable",}: { children: ReactNode; className?: string; width?: WorkspacePageWidth;}) { const navigate = useNavigate(); const hash = useLocation({ select: (location) => location.hash }); const highlightTarget = useLocation({ select: (location) => location.state.settingsTargetHighlight !== false, }); const targetId = hash.replace(/^#/, "") || null; const clearTargetHash = useCallback(() => { void navigate({ hash: "", replace: true, resetScroll: false, hashScrollIntoView: false, state: { settingsTargetHighlight: true }, }); }, [navigate]); return ( <SettingsSearchTargetProvider targetId={targetId} highlightTarget={highlightTarget} onTargetHandled={clearTargetHash} > <div className="topbar-scroll-fade scrollbar-gutter-both flex-1 overflow-y-auto" data-settings-page-scroll > <WorkspacePageContainer width={width} className={cn("gap-8", className)}> {children} </WorkspacePageContainer> </div> </SettingsSearchTargetProvider> );} export function scrollToSettingsTarget( targetId: string, { highlight = true }: { readonly highlight?: boolean } = {},): boolean { const target = document.getElementById(targetId); if (!target) return false; scrollAndFocusSettingsTarget(target, highlight); return true;}
import { InfoIcon, Undo2Icon } from "lucide-react";
import { useLocation, useNavigate } from "@tanstack/react-router";
import {
createContext,
type ComponentPropsWithoutRef,
type ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import {
PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE,
usePrimarySettingsAvailable,
} from "../../hooks/useSettings";
import { cn } from "../../lib/utils";
import { WorkspacePageContainer, type WorkspacePageWidth } from "../WorkspacePageContainer";
import { Button } from "../ui/button";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
declare module "@tanstack/react-router" {
interface HistoryState {
settingsTargetHighlight?: boolean;
}
}
interface SettingsSearchTargetContextValue {
readonly targetId: string | null;
readonly highlightTarget: boolean;
readonly onTargetHandled: () => void;
}
const noop = () => undefined;
const SettingsSearchTargetContext = createContext<SettingsSearchTargetContextValue>({
targetId: null,
highlightTarget: true,
onTargetHandled: noop,
});
export function SettingsSearchTargetProvider({
targetId,
highlightTarget = true,
onTargetHandled = noop,
children,
}: {
targetId: string | null;
highlightTarget?: boolean;
onTargetHandled?: () => void;
children: ReactNode;
}) {
const value = useMemo(
() => ({ targetId, highlightTarget, onTargetHandled }),
[highlightTarget, onTargetHandled, targetId],
);
return <SettingsSearchTargetContext value={value}>{children}</SettingsSearchTargetContext>;
}
function scrollAndFocusSettingsTarget(target: HTMLElement, highlight = true): void {
const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const markedScrollTarget =
typeof target.querySelector === "function"
? target.querySelector<HTMLElement>(":scope > [data-settings-scroll-target]")
: null;
const scrollTarget =
markedScrollTarget ??
(target.tagName === "SECTION" && target.firstElementChild
? (target.firstElementChild as HTMLElement)
: target);
scrollTarget.scrollIntoView({
behavior: prefersReducedMotion ? "auto" : "smooth",
block: "center",
});
target.focus({ preventScroll: true });
target.classList.remove("settings-search-target-pulse");
if (!highlight || prefersReducedMotion) return;
void target.offsetWidth;
target.classList.add("settings-search-target-pulse");
// The class also suppresses the focus outline (the pulse is the destination
// indicator), so drop it once the element is no longer the destination.
target.addEventListener("blur", () => target.classList.remove("settings-search-target-pulse"), {
once: true,
});
}
/** The row id a settings-search jump is currently trying to reach, if any. */
export function useSettingsSearchTargetId(): string | null {
return useContext(SettingsSearchTargetContext).targetId;
}
export function useSettingsSearchTarget<T extends HTMLElement>(id: string | undefined) {
const { targetId, highlightTarget, onTargetHandled } = useContext(SettingsSearchTargetContext);
const isSearchTarget = id !== undefined && id === targetId;
const targetRef = useCallback(
(target: T | null) => {
if (target && isSearchTarget) {
scrollAndFocusSettingsTarget(target, highlightTarget);
onTargetHandled();
}
},
[highlightTarget, isSearchTarget, onTargetHandled],
);
return targetRef;
}
export function SettingsSearchTarget({
children,
...targetProps
}: ComponentPropsWithoutRef<"div">) {
const targetRef = useSettingsSearchTarget<HTMLDivElement>(targetProps.id);
return (
<div {...targetProps} ref={targetRef} tabIndex={targetProps.id ? -1 : targetProps.tabIndex}>
{children}
</div>
);
}
/**
* Trigger classes for the composer model/traits pickers when they sit in a
* settings row: match the `sm` control box (the composer pins them to 28px at
* every breakpoint) and drop the composer's max-width.
*/
export const SETTINGS_PICKER_TRIGGER_CLASSNAME =
"h-8 min-h-8 min-w-0 max-w-none shrink-0 text-foreground/90 hover:text-foreground sm:h-7 sm:min-h-7";
/** Info affordance explaining how a setting interacts with the shared background policy. */
export function PolicyTooltip({ children }: { readonly children: string }) {
return (
<Tooltip>
<TooltipTrigger
delay={200}
render={
<Button size="icon-micro" variant="ghost-muted" aria-label="Background policy details">
<InfoIcon className="size-3.5" />
</Button>
}
/>
<TooltipPopup side="top" className="max-w-72">
{children}
</TooltipPopup>
</Tooltip>
);
}
/** Re-render every `intervalMs`; return a stable timestamp snapshot for render-time relative labels. */
export function useRelativeTimeTick(intervalMs = 1_000) {
const [nowMs, setNowMs] = useState(() => Date.now());
useEffect(() => {
const id = setInterval(() => setNowMs(Date.now()), intervalMs);
return () => clearInterval(id);
}, [intervalMs]);
return nowMs;
}
/** Muted section headings have no descriptions; explanatory copy belongs to individual settings. */
export function SettingsSection({
title,
hideTitle = false,
icon,
headerAction,
variant = "grouped",
children,
className,
...sectionProps
}: ComponentPropsWithoutRef<"section"> & {
title: string;
hideTitle?: boolean;
icon?: ReactNode;
headerAction?: ReactNode;
variant?: "grouped" | "plain";
children: ReactNode;
}) {
const targetRef = useSettingsSearchTarget<HTMLElement>(sectionProps.id);
return (
<section
{...sectionProps}
ref={targetRef}
tabIndex={sectionProps.id ? -1 : sectionProps.tabIndex}
className={cn(!hideTitle && "space-y-2.5", className)}
>
{hideTitle ? (
<h2 className="sr-only">{title}</h2>
) : (
<div
data-settings-scroll-target
className="flex min-h-7 items-start justify-between gap-4 px-3 sm:px-4"
>
<div className="min-w-0">
<h2 className="flex min-h-7 items-center gap-2 text-sm font-normal tracking-[-0.005em] text-foreground/70">
{icon}
{title}
</h2>
</div>
<div className="flex min-h-7 min-w-7 items-center justify-end">{headerAction}</div>
</div>
)}
<div
data-settings-scroll-target={hideTitle ? "" : undefined}
className={cn(
"relative overflow-visible text-foreground",
variant === "grouped"
? "rounded-xl border border-border/60 bg-card/40 shadow-xs/5 [&>*+*]:border-t [&>*+*]:border-border/50 [&>[data-slot=settings-row]]:rounded-none"
: "space-y-1",
)}
>
{children}
</div>
</section>
);
}
export function SettingsUnavailableGroup({
children,
message,
}: {
children: ReactNode;
message?: ReactNode;
}) {
if (message === undefined) return children;
return (
<div className="border-border/60 bg-muted/20 py-1.5">
<div className="flex items-start gap-2 px-3 py-2 text-[12px] leading-relaxed text-muted-foreground sm:px-4">
<InfoIcon className="mt-0.5 size-3.5 shrink-0 text-warning" />
<p>{message}</p>
</div>
<div className="[&_h3]:opacity-64 [&_p]:opacity-64">{children}</div>
</div>
);
}
/**
* One setting. `serverScoped` marks rows whose value lives in the primary
* environment's settings.json; where there is no primary (the hosted app)
* the control goes inert with a tooltip instead of showing an editable
* default that would never save.
*
* Keep descriptions short enough for one line where possible. Allow wrapping
* for clarity or narrow screens instead of truncating or forcing no-wrap.
*
* Control sizing across settings follows three tiers so rows share a baseline:
* - `control` slot: `size="sm"` (Button, Select, Input, NumberField) or `icon-sm`.
* - Section `headerAction`s and buttons inside list items, cards, toolbars: `xs` / `icon-xs`.
* - Inline affordances (reset arrows, info tooltips, table-cell buttons): `icon-micro`.
* Dialog footers keep the app-wide default button size.
*/
export function SettingsRow({
title,
description,
status,
resetAction,
control,
serverScoped = false,
children,
className,
...rowProps
}: Omit<ComponentPropsWithoutRef<"div">, "title"> & {
title: ReactNode;
description?: ReactNode;
status?: ReactNode;
resetAction?: ReactNode;
control?: ReactNode;
serverScoped?: boolean;
children?: ReactNode;
}) {
const targetRef = useSettingsSearchTarget<HTMLDivElement>(rowProps.id);
const primarySettingsAvailable = usePrimarySettingsAvailable();
const unavailable = serverScoped && !primarySettingsAvailable;
const renderedReset = unavailable ? null : resetAction;
const renderedControl =
unavailable && control ? (
<Tooltip>
<TooltipTrigger
render={
// Focusable so keyboard users can still reach the explanation.
<span
tabIndex={0}
className="flex w-full items-center rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring sm:w-auto"
/>
}
>
<div inert className="flex w-full items-center gap-2 opacity-50 sm:w-auto">
{control}
</div>
</TooltipTrigger>
<TooltipPopup side="top" className="max-w-72">
{PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE}
</TooltipPopup>
</Tooltip>
) : (
control
);
return (
<div
{...rowProps}
ref={targetRef}
tabIndex={rowProps.id ? -1 : rowProps.tabIndex}
data-slot="settings-row"
className={cn(
"rounded-xl px-3 sm:px-4 aria-disabled:opacity-50 aria-disabled:[&_*]:text-muted-foreground",
children ? "pt-3 pb-1" : "py-3",
className,
)}
>
<div className="flex flex-col gap-3 sm:grid sm:grid-cols-[minmax(0,1fr)_minmax(10rem,auto)] sm:items-center sm:gap-8">
<div className="min-w-0 flex-1 space-y-1">
<div className="flex min-h-5 items-center gap-1.5">
<h3 className="text-sm font-medium tracking-[-0.005em] text-foreground">{title}</h3>
<span className="inline-flex h-5 w-5 shrink-0 items-center justify-center">
{renderedReset}
</span>
</div>
{description ? (
<p className="max-w-xl text-[13px] leading-[1.45] text-muted-foreground/80">
{description}
</p>
) : null}
{status ? <div className="pt-0.5 text-xs text-muted-foreground">{status}</div> : null}
</div>
{renderedControl ? (
<div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end">
{renderedControl}
</div>
) : null}
</div>
{unavailable && children ? (
<div inert className="opacity-50">
{children}
</div>
) : (
children
)}
</div>
);
}
export function SettingResetButton({
label,
tooltip = "Reset to default",
disabled = false,
onClick,
}: {
label: string;
tooltip?: string;
disabled?: boolean;
onClick: () => void;
}) {
return (
<Tooltip>
<TooltipTrigger
render={
<Button
size="icon-micro"
variant="ghost-muted"
aria-label={`Reset ${label} to default`}
disabled={disabled}
onClick={(event) => {
event.stopPropagation();
onClick();
}}
>
<Undo2Icon className="size-3" />
</Button>
}
/>
<TooltipPopup side="top">{tooltip}</TooltipPopup>
</Tooltip>
);
}
export function SettingsPageContainer({
children,
className,
width = "readable",
}: {
children: ReactNode;
className?: string;
width?: WorkspacePageWidth;
}) {
const navigate = useNavigate();
const hash = useLocation({ select: (location) => location.hash });
const highlightTarget = useLocation({
select: (location) => location.state.settingsTargetHighlight !== false,
});
const targetId = hash.replace(/^#/, "") || null;
const clearTargetHash = useCallback(() => {
void navigate({
hash: "",
replace: true,
resetScroll: false,
hashScrollIntoView: false,
state: { settingsTargetHighlight: true },
});
}, [navigate]);
return (
<SettingsSearchTargetProvider
targetId={targetId}
highlightTarget={highlightTarget}
onTargetHandled={clearTargetHash}
>
<div
className="topbar-scroll-fade scrollbar-gutter-both flex-1 overflow-y-auto"
data-settings-page-scroll
>
<WorkspacePageContainer width={width} className={cn("gap-8", className)}>
{children}
</WorkspacePageContainer>
</div>
</SettingsSearchTargetProvider>
);
}
export function scrollToSettingsTarget(
targetId: string,
{ highlight = true }: { readonly highlight?: boolean } = {},
): boolean {
const target = document.getElementById(targetId);
if (!target) return false;
scrollAndFocusSettingsTarget(target, highlight);
return true;
}