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"; interface SettingsSearchTargetContextValue { readonly targetId: string | null; readonly onTargetHandled: () => void;} const noop = () => undefined;const SettingsSearchTargetContext = createContext<SettingsSearchTargetContextValue>({ targetId: null, onTargetHandled: noop,}); export function SettingsSearchTargetProvider({ targetId, onTargetHandled = noop, children,}: { targetId: string | null; onTargetHandled?: () => void; children: ReactNode;}) { const value = useMemo(() => ({ targetId, onTargetHandled }), [onTargetHandled, targetId]); return <SettingsSearchTargetContext value={value}>{children}</SettingsSearchTargetContext>;} function scrollAndFocusSettingsTarget(target: HTMLElement): void { const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; const scrollTarget = 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 (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;} function useSettingsSearchTarget<T extends HTMLElement>(id: string | undefined) { const { targetId, onTargetHandled } = useContext(SettingsSearchTargetContext); const isSearchTarget = id !== undefined && id === targetId; const targetRef = useCallback( (target: T | null) => { if (target && isSearchTarget) { scrollAndFocusSettingsTarget(target); onTargetHandled(); } }, [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;} export function SettingsSection({ title, icon, headerAction, children, className, ...sectionProps}: ComponentPropsWithoutRef<"section"> & { title: string; icon?: ReactNode; headerAction?: ReactNode; children: ReactNode;}) { const targetRef = useSettingsSearchTarget<HTMLElement>(sectionProps.id); return ( <section {...sectionProps} ref={targetRef} tabIndex={sectionProps.id ? -1 : sectionProps.tabIndex} className={cn("space-y-3", className)} > <div className="flex min-h-8 items-center justify-between gap-4 px-3 sm:px-4"> <h2 className="flex items-center gap-2 text-lg font-semibold tracking-[-0.025em] text-foreground"> {icon} {title} </h2> <div className="flex min-h-7 min-w-7 items-center justify-end">{headerAction}</div> </div> <div className="relative space-y-1 overflow-visible text-foreground">{children}</div> </section> );} /** * 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. * * 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} className={cn("rounded-xl px-3 sm:px-4", 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, disabled = false, onClick,}: { label: 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">Reset to default</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 targetId = hash.replace(/^#/, "") || null; const clearTargetHash = useCallback(() => { void navigate({ hash: "", replace: true, resetScroll: false, hashScrollIntoView: false }); }, [navigate]); return ( <SettingsSearchTargetProvider targetId={targetId} 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-12", className)}> {children} </WorkspacePageContainer> </div> </SettingsSearchTargetProvider> );} export function scrollToSettingsTarget(targetId: string): boolean { const target = document.getElementById(targetId); if (!target) return false; scrollAndFocusSettingsTarget(target); 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";
interface SettingsSearchTargetContextValue {
readonly targetId: string | null;
readonly onTargetHandled: () => void;
}
const noop = () => undefined;
const SettingsSearchTargetContext = createContext<SettingsSearchTargetContextValue>({
targetId: null,
onTargetHandled: noop,
});
export function SettingsSearchTargetProvider({
targetId,
onTargetHandled = noop,
children,
}: {
targetId: string | null;
onTargetHandled?: () => void;
children: ReactNode;
}) {
const value = useMemo(() => ({ targetId, onTargetHandled }), [onTargetHandled, targetId]);
return <SettingsSearchTargetContext value={value}>{children}</SettingsSearchTargetContext>;
}
function scrollAndFocusSettingsTarget(target: HTMLElement): void {
const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const scrollTarget =
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 (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;
}
function useSettingsSearchTarget<T extends HTMLElement>(id: string | undefined) {
const { targetId, onTargetHandled } = useContext(SettingsSearchTargetContext);
const isSearchTarget = id !== undefined && id === targetId;
const targetRef = useCallback(
(target: T | null) => {
if (target && isSearchTarget) {
scrollAndFocusSettingsTarget(target);
onTargetHandled();
}
},
[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;
}
export function SettingsSection({
title,
icon,
headerAction,
children,
className,
...sectionProps
}: ComponentPropsWithoutRef<"section"> & {
title: string;
icon?: ReactNode;
headerAction?: ReactNode;
children: ReactNode;
}) {
const targetRef = useSettingsSearchTarget<HTMLElement>(sectionProps.id);
return (
<section
{...sectionProps}
ref={targetRef}
tabIndex={sectionProps.id ? -1 : sectionProps.tabIndex}
className={cn("space-y-3", className)}
>
<div className="flex min-h-8 items-center justify-between gap-4 px-3 sm:px-4">
<h2 className="flex items-center gap-2 text-lg font-semibold tracking-[-0.025em] text-foreground">
{icon}
{title}
</h2>
<div className="flex min-h-7 min-w-7 items-center justify-end">{headerAction}</div>
</div>
<div className="relative space-y-1 overflow-visible text-foreground">{children}</div>
</section>
);
}
/**
* 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.
*
* 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}
className={cn("rounded-xl px-3 sm:px-4", 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,
disabled = false,
onClick,
}: {
label: 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">Reset to default</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 targetId = hash.replace(/^#/, "") || null;
const clearTargetHash = useCallback(() => {
void navigate({ hash: "", replace: true, resetScroll: false, hashScrollIntoView: false });
}, [navigate]);
return (
<SettingsSearchTargetProvider targetId={targetId} 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-12", className)}>
{children}
</WorkspacePageContainer>
</div>
</SettingsSearchTargetProvider>
);
}
export function scrollToSettingsTarget(targetId: string): boolean {
const target = document.getElementById(targetId);
if (!target) return false;
scrollAndFocusSettingsTarget(target);
return true;
}