@cosmicdrift/kumiko-renderer-web 0.338.0 → 0.339.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2 @@
1
+ export type DefaultColorScheme = "auto" | "light" | "dark";
2
+ export declare const DEFAULT_COLOR_SCHEME_VARIABLE = "--kumiko-default-color-scheme";
@@ -0,0 +1,3 @@
1
+ // Written by createThemePlugin, read by the browser tokens hook. A separate
2
+ // module keeps tailwindcss/plugin out of the client bundle.
3
+ export const DEFAULT_COLOR_SCHEME_VARIABLE = "--kumiko-default-color-scheme";
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export type { AppPrimitives, AppSchema, AppTokens, BannerProps, ButtonProps, CardProps, ColorTokens, CorePrimitives, CoreTokens, DataTableProps, DispatcherProviderProps, FeatureSchema, FieldProps, FontTokens, FormProps, GridCellProps, GridProps, HeadingProps, InputProps, KumikoScreenProps, LiveEvent, LiveEventSubscriber, LiveEventsProviderProps, LocaleProviderProps, NavApi, NavProviderProps, NavRoute, NavTarget, PrimitivesProviderProps, PrimitivesRegistry, RadiusTokens, RenderEditChangeState, RenderEditControls, RenderEditProps, RenderFieldProps, RenderListProps, SectionProps, ShadowTokens, SpacingTokens, TextProps, ThemeMode, Tokens, TokensApi, TokensProviderProps, TranslationBundle, TranslationsByLocale, UseFormOptions, UseFormResult, UseQueryOptions, UseQueryResult, UserRolesProviderProps, WorkspaceSchema, } from "@cosmicdrift/kumiko-renderer";
1
+ export type { AppPrimitives, AppSchema, AppTokens, BannerProps, ButtonProps, CardProps, ColorTokens, CorePrimitives, CoreTokens, DataTableProps, DispatcherProviderProps, FeatureSchema, FieldProps, FontTokens, FormProps, GridCellProps, GridProps, HeadingProps, InputProps, KumikoScreenProps, LiveEvent, LiveEventSubscriber, LiveEventsProviderProps, LocaleProviderProps, NavApi, NavProviderProps, NavRoute, NavTarget, PrimitivesProviderProps, PrimitivesRegistry, RadiusTokens, RenderEditChangeState, RenderEditControls, RenderEditProps, RenderFieldProps, RenderListProps, SectionProps, ShadowTokens, SpacingTokens, TextProps, ThemeMode, ThemePreference, Tokens, TokensApi, TokensProviderProps, TranslationBundle, TranslationsByLocale, UseFormOptions, UseFormResult, UseQueryOptions, UseQueryResult, UserRolesProviderProps, WorkspaceSchema, } from "@cosmicdrift/kumiko-renderer";
2
2
  export { createStaticLocaleResolver, cssVarTokens, DispatcherProvider, formatPath, KumikoScreen, LiveEventsProvider, LocaleProvider, NavProvider, PrimitivesProvider, parsePath, qualifyScreenId, RenderEdit, RenderField, RenderList, synthesizeActionFormEntity, synthesizeActionFormScreen, TokensProvider, UserRolesProvider, useDispatcher, useDispatcherStatus, useForm, useLiveEvents, useLocale, useNav, usePrimitives, useQuery, useStore, useStoreSelector, useTokenController, useTokens, useTranslation, useUserRoles, } from "@cosmicdrift/kumiko-renderer";
3
3
  export { createBrowserLocaleResolver } from "./app/browser-locale.js";
4
4
  export type { ClientFeatureDefinition } from "./app/client-plugin.js";
@@ -67,5 +67,7 @@ export { createEventSourceLiveEvents } from "./sse/live-events.js";
67
67
  export { applyTokensToCssVars, defaultTokens, lightTokens, useBrowserTokensApi, } from "./tokens.js";
68
68
  export { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "./ui/resizable.js";
69
69
  export { SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarProvider } from "./ui/sidebar.js";
70
- export type { AiTextAreaProps, AiTextFieldProps, BooleanFieldProps, ComparisonMetric, DateFieldProps, DrawerProps, FeedRow, FileFieldProps, FloatingPanelGeometry, FloatingPanelProps, InfinityListProps, InfinityListSelection, NumberFieldProps, PhotoSlotSpec, PhotoSlotsProps, PlanCardActionSlot, PlanCardPrice, PlanCardProps, PlanGridProps, ProgressListRow, PublicShellProps, PublicShellVariant, QueryTableColumn, QueryTableProps, RangeFieldProps, ResultColumn, SelectFieldProps, SideBySideTableCell, SideBySideTableColumn, SideBySideTableRow, StatDelta, StatTone, StatusBadgeTone, StatusBarEntry, StatusTone, TextareaFieldProps, TextFieldProps, TimeseriesPoint, UploadZoneProps, } from "./widgets/index.js";
70
+ export type { KumikoBuild } from "./version/update-checker.js";
71
+ export { readLoadedBuild } from "./version/update-checker.js";
72
+ export type { AiTextAreaProps, AiTextFieldProps, BooleanFieldProps, ComparisonMetric, DateFieldProps, DrawerProps, FeedRow, FileFieldProps, FloatingPanelGeometry, FloatingPanelProps, InfinityListProps, InfinityListSelection, NumberFieldProps, PhotoSlotSpec, PhotoSlotsProps, PlanCardActionSlot, PlanCardPrice, PlanCardProps, PlanGridProps, ProgressListRow, PublicShellProps, PublicShellVariant, QueryTableColumn, QueryTableProps, RangeFieldProps, ResultColumn, SelectFieldProps, SideBySideTableCell, SideBySideTableColumn, SideBySideTableRow, StatDelta, StatTone, StatusBadgeTone, StatusBarEntry, StatusTone, TextareaFieldProps, TextFieldProps, TimeseriesPoint, TimeseriesReferenceLine, UploadZoneProps, } from "./widgets/index.js";
71
73
  export { AiTextArea, AiTextField, BooleanField, CollapsibleSection, ComparisonTable, DateField, DetailList, Drawer, EmptyState, ErrorState, FeedList, FileField, FloatingPanel, InfinityList, LoadingState, MiniStat, ModeSwitch, MoneyField, NumberField, PercentField, PhotoSlots, PlanCard, PlanGrid, ProgressBar, ProgressList, PublicShell, QueryTable, RangeField, ResultPanel, ResultTable, SectionCard, SelectField, SideBySideTable, Sparkline, STATUS_TONE_TEXT, StatCard, StatusBadge, StatusBarChart, StepBar, smoothPath, TextareaField, TextField, TimeseriesChart, UploadZone, useDraft, } from "./widgets/index.js";
package/dist/index.js CHANGED
@@ -46,4 +46,5 @@ export { createEventSourceLiveEvents } from "./sse/live-events.js";
46
46
  export { applyTokensToCssVars, defaultTokens, lightTokens, useBrowserTokensApi, } from "./tokens.js";
47
47
  export { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "./ui/resizable.js";
48
48
  export { SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarProvider } from "./ui/sidebar.js";
49
+ export { readLoadedBuild } from "./version/update-checker.js";
49
50
  export { AiTextArea, AiTextField, BooleanField, CollapsibleSection, ComparisonTable, DateField, DetailList, Drawer, EmptyState, ErrorState, FeedList, FileField, FloatingPanel, InfinityList, LoadingState, MiniStat, ModeSwitch, MoneyField, NumberField, PercentField, PhotoSlots, PlanCard, PlanGrid, ProgressBar, ProgressList, PublicShell, QueryTable, RangeField, ResultPanel, ResultTable, SectionCard, SelectField, SideBySideTable, Sparkline, STATUS_TONE_TEXT, StatCard, StatusBadge, StatusBarChart, StepBar, smoothPath, TextareaField, TextField, TimeseriesChart, UploadZone, useDraft, } from "./widgets/index.js";
@@ -12,13 +12,17 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
12
12
  // itself; an app that doesn't want an icon import gets the globe
13
13
  // unicode glyph (🌐) as default.
14
14
  import { useLocale, useTranslation } from "@cosmicdrift/kumiko-renderer";
15
- import { useMemo } from "react";
15
+ import { useMemo, useState } from "react";
16
16
  import { cn } from "../lib/cn.js";
17
17
  import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuTrigger, } from "../primitives/dropdown-menu.js";
18
18
  export function LanguageSwitcher({ locales, icon = "🌐", label, testId, }) {
19
19
  const resolver = useLocale();
20
20
  const t = useTranslation();
21
21
  const resolvedLabel = label ?? t("kumiko.nav.language");
22
+ // Portal into the surrounding landmark: content under document.body would sit outside every
23
+ // landmark and fail axe's "region" rule.
24
+ const [trigger, setTrigger] = useState(null);
25
+ const landmark = trigger?.closest("header, nav, main, aside, [role='banner']");
22
26
  const activeLocale = resolver.locale();
23
27
  // Matches either exact ("de-DE") or the language root ("de") against
24
28
  // the available options. So the switcher shows "German" active when
@@ -35,5 +39,5 @@ export function LanguageSwitcher({ locales, icon = "🌐", label, testId, }) {
35
39
  return null;
36
40
  }
37
41
  const setLocale = resolver.setLocale;
38
- return (_jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsxs("button", { type: "button", "aria-label": resolvedLabel, title: resolvedLabel, "data-testid": testId, className: cn("inline-flex h-8 items-center gap-1.5 rounded-md border bg-background px-2 text-sm", "hover:bg-accent hover:text-accent-foreground", "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"), children: [_jsx("span", { "aria-hidden": "true", children: icon }), _jsx("span", { className: "uppercase text-xs text-muted-foreground", children: activeOption?.code ?? activeLocale.slice(0, 2) })] }) }), _jsx(DropdownMenuContent, { align: "end", className: "min-w-[10rem]", "aria-label": resolvedLabel, children: locales.map((opt) => (_jsx(DropdownMenuCheckboxItem, { checked: opt === activeOption, onSelect: () => setLocale(opt.code), children: _jsx("span", { className: "truncate", children: opt.label }) }, opt.code))) })] }));
42
+ return (_jsxs(DropdownMenu, { modal: false, children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsxs("button", { ref: setTrigger, type: "button", "aria-label": resolvedLabel, title: resolvedLabel, "data-testid": testId, className: cn("inline-flex h-8 items-center gap-1.5 rounded-md border bg-background px-2 text-sm", "hover:bg-accent hover:text-accent-foreground", "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"), children: [_jsx("span", { "aria-hidden": "true", children: icon }), _jsx("span", { className: "uppercase text-xs text-muted-foreground", children: activeOption?.code ?? activeLocale.slice(0, 2) })] }) }), _jsx(DropdownMenuContent, { align: "end", className: "min-w-[10rem]", "aria-label": resolvedLabel, portalContainer: landmark instanceof HTMLElement ? landmark : undefined, children: locales.map((opt) => (_jsx(DropdownMenuCheckboxItem, { checked: opt === activeOption, onSelect: () => setLocale(opt.code), children: _jsx("span", { className: "truncate", children: opt.label }) }, opt.code))) })] }));
39
43
  }
@@ -1,9 +1,15 @@
1
+ import { type ThemePreference } from "@cosmicdrift/kumiko-renderer";
1
2
  export type ThemeToggleLabelOptions = {
3
+ /** Title while the next step is the light theme. */
2
4
  readonly titleInDark?: string | undefined;
5
+ /** Title while the next step is the dark theme. */
3
6
  readonly titleInLight?: string | undefined;
7
+ /** Title while the next step is the automatic theme. */
8
+ readonly titleForAuto?: string | undefined;
4
9
  };
5
- export declare function useThemeToggleLabel({ titleInDark, titleInLight }: ThemeToggleLabelOptions): {
6
- readonly isDark: boolean;
10
+ export declare function nextThemePreference(current: ThemePreference): ThemePreference;
11
+ export declare function useThemeToggleLabel({ titleInDark, titleInLight, titleForAuto, }: ThemeToggleLabelOptions): {
12
+ readonly nextPreference: ThemePreference;
7
13
  readonly title: string;
8
- readonly toggleMode: () => void;
14
+ readonly cyclePreference: () => void;
9
15
  };
@@ -1,11 +1,35 @@
1
- import { useOptionalTranslation, useTokenController } from "@cosmicdrift/kumiko-renderer";
2
- export function useThemeToggleLabel({ titleInDark, titleInLight }) {
3
- const { mode, toggleMode } = useTokenController();
1
+ import { useOptionalTranslation, useTokenController, } from "@cosmicdrift/kumiko-renderer";
2
+ // light -> dark -> auto -> light: a click describes and applies the NEXT step.
3
+ export function nextThemePreference(current) {
4
+ if (current === "light")
5
+ return "dark";
6
+ if (current === "dark")
7
+ return "auto";
8
+ return "light";
9
+ }
10
+ export function useThemeToggleLabel({ titleInDark, titleInLight, titleForAuto, }) {
11
+ const { mode, preference = mode, setPreference, toggleMode } = useTokenController();
4
12
  // Optional: the toggle also renders outside a LocaleProvider (samples, tests).
5
13
  const translate = useOptionalTranslation();
6
- const isDark = mode === "dark";
7
- const title = isDark
8
- ? (titleInDark ?? translate?.("kumiko.theme.light") ?? "Light theme")
9
- : (titleInLight ?? translate?.("kumiko.theme.dark") ?? "Dark theme");
10
- return { isDark, title, toggleMode };
14
+ // A TokensApi without setPreference is a plain light/dark implementation: no auto step.
15
+ const nextPreference = setPreference
16
+ ? nextThemePreference(preference)
17
+ : mode === "dark"
18
+ ? "light"
19
+ : "dark";
20
+ const titles = {
21
+ light: titleInDark ?? translate?.("kumiko.theme.light") ?? "Light theme",
22
+ dark: titleInLight ?? translate?.("kumiko.theme.dark") ?? "Dark theme",
23
+ auto: titleForAuto ?? translate?.("kumiko.theme.auto") ?? "Automatic theme",
24
+ };
25
+ return {
26
+ nextPreference,
27
+ title: titles[nextPreference],
28
+ cyclePreference: () => {
29
+ if (setPreference)
30
+ setPreference(nextPreference);
31
+ else
32
+ toggleMode();
33
+ },
34
+ };
11
35
  }
@@ -2,9 +2,11 @@ import type { ReactNode } from "react";
2
2
  export type ThemeMenuItemProps = {
3
3
  readonly lightIcon?: ReactNode;
4
4
  readonly darkIcon?: ReactNode;
5
+ readonly autoIcon?: ReactNode;
5
6
  readonly titleInDark?: string;
6
7
  readonly titleInLight?: string;
8
+ readonly titleForAuto?: string;
7
9
  readonly testId?: string;
8
10
  };
9
11
  /** Theme toggle as a dropdown entry, for a user menu that carries the shell controls. */
10
- export declare function ThemeMenuItem({ lightIcon, darkIcon, titleInDark, titleInLight, testId, }: ThemeMenuItemProps): ReactNode;
12
+ export declare function ThemeMenuItem({ lightIcon, darkIcon, autoIcon, titleInDark, titleInLight, titleForAuto, testId, }: ThemeMenuItemProps): ReactNode;
@@ -2,10 +2,15 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { DropdownMenuItem } from "../primitives/dropdown-menu.js";
3
3
  import { useThemeToggleLabel } from "./theme-label.js";
4
4
  /** Theme toggle as a dropdown entry, for a user menu that carries the shell controls. */
5
- export function ThemeMenuItem({ lightIcon = "☀", darkIcon = "☾", titleInDark, titleInLight, testId, }) {
6
- const { isDark, title, toggleMode } = useThemeToggleLabel({ titleInDark, titleInLight });
5
+ export function ThemeMenuItem({ lightIcon = "☀", darkIcon = "☾", autoIcon = "◐", titleInDark, titleInLight, titleForAuto, testId, }) {
6
+ const { nextPreference, title, cyclePreference } = useThemeToggleLabel({
7
+ titleInDark,
8
+ titleInLight,
9
+ titleForAuto,
10
+ });
11
+ const icons = { light: lightIcon, dark: darkIcon, auto: autoIcon };
7
12
  return (_jsxs(DropdownMenuItem, { "data-testid": testId, onSelect: (event) => {
8
13
  event.preventDefault();
9
- toggleMode();
10
- }, children: [_jsx("span", { "aria-hidden": "true", children: isDark ? lightIcon : darkIcon }), _jsx("span", { children: title })] }));
14
+ cyclePreference();
15
+ }, children: [_jsx("span", { "aria-hidden": "true", children: icons[nextPreference] }), _jsx("span", { children: title })] }));
11
16
  }
@@ -1,15 +1,17 @@
1
1
  import { type ReactNode } from "react";
2
2
  export type ThemeToggleProps = {
3
- /** Icon für den hellen Modus (wird angezeigt WENN aktuell dark →
4
- * Klick wechselt zu light). Default: ☀ */
3
+ /** Icon for the step to light mode. Default: ☀ */
5
4
  readonly lightIcon?: ReactNode;
6
- /** Icon für den dunklen Modus (wird angezeigt WENN aktuell light →
7
- * Klick wechselt zu dark). Default: ☾ */
5
+ /** Icon for the step to dark mode. Default: ☾ */
8
6
  readonly darkIcon?: ReactNode;
9
- /** Title/aria-label im dunklen Modus. Default: i18n `kumiko.theme.light` */
7
+ /** Icon for the step to auto mode. Default: ◐ */
8
+ readonly autoIcon?: ReactNode;
9
+ /** Title/aria-label for the step to light mode. Default: i18n `kumiko.theme.light` */
10
10
  readonly titleInDark?: string;
11
- /** Title/aria-label im hellen Modus. Default: i18n `kumiko.theme.dark` */
11
+ /** Title/aria-label for the step to dark mode. Default: i18n `kumiko.theme.dark` */
12
12
  readonly titleInLight?: string;
13
+ /** Title/aria-label for the step to auto mode. Default: i18n `kumiko.theme.auto` */
14
+ readonly titleForAuto?: string;
13
15
  readonly testId?: string;
14
16
  };
15
- export declare function ThemeToggle({ lightIcon, darkIcon, titleInDark, titleInLight, testId, }: ThemeToggleProps): ReactNode;
17
+ export declare function ThemeToggle({ lightIcon, darkIcon, autoIcon, titleInDark, titleInLight, titleForAuto, testId, }: ThemeToggleProps): ReactNode;
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- // ThemeToggle — Button der useTokenController().toggleMode() aufruft.
2
+ // ThemeToggle — Button der die Theme-Wahl weiterschaltet (hell → dunkel → auto).
3
3
  // Icon-Slots als Props, damit renderer-web keine Icon-Lib als Hard-
4
4
  // Dependency zieht. Default: Unicode-Glyphs (☀ / ☾) — funktionieren in
5
5
  // jedem Browser, jede App kann lucide/heroicons/eigene SVG via Props
@@ -7,12 +7,16 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
7
7
  import { useContext } from "react";
8
8
  import { HeaderOverflowMenuContext, headerOverflowMenuItemClass } from "./header-overflow-menu.js";
9
9
  import { useThemeToggleLabel } from "./theme-label.js";
10
- export function ThemeToggle({ lightIcon = "☀", darkIcon = "☾", titleInDark, titleInLight, testId, }) {
10
+ export function ThemeToggle({ lightIcon = "☀", darkIcon = "☾", autoIcon = "◐", titleInDark, titleInLight, titleForAuto, testId, }) {
11
11
  const inOverflowMenu = useContext(HeaderOverflowMenuContext);
12
- const { isDark, title, toggleMode } = useThemeToggleLabel({ titleInDark, titleInLight });
13
- const icon = isDark ? lightIcon : darkIcon;
12
+ const { nextPreference, title, cyclePreference } = useThemeToggleLabel({
13
+ titleInDark,
14
+ titleInLight,
15
+ titleForAuto,
16
+ });
17
+ const icon = { light: lightIcon, dark: darkIcon, auto: autoIcon }[nextPreference];
14
18
  if (inOverflowMenu) {
15
- return (_jsxs("button", { type: "button", role: "menuitem", onClick: toggleMode, className: headerOverflowMenuItemClass, "data-testid": testId, children: [_jsx("span", { "aria-hidden": "true", children: icon }), _jsx("span", { children: title })] }));
19
+ return (_jsxs("button", { type: "button", role: "menuitem", onClick: cyclePreference, className: headerOverflowMenuItemClass, "data-testid": testId, children: [_jsx("span", { "aria-hidden": "true", children: icon }), _jsx("span", { children: title })] }));
16
20
  }
17
- return (_jsx("button", { type: "button", onClick: toggleMode, className: "inline-flex h-8 w-8 items-center justify-center rounded-md border bg-background text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", title: title, "aria-label": title, "data-testid": testId, children: icon }));
21
+ return (_jsx("button", { type: "button", onClick: cyclePreference, className: "inline-flex h-8 w-8 items-center justify-center rounded-md border bg-background text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", title: title, "aria-label": title, "data-testid": testId, children: icon }));
18
22
  }
@@ -3,7 +3,10 @@ import type { ComponentPropsWithoutRef, ReactNode } from "react";
3
3
  export declare const DropdownMenu: import("react").FC<Primitive.DropdownMenuProps>;
4
4
  export declare const DropdownMenuTrigger: import("react").ForwardRefExoticComponent<Primitive.DropdownMenuTriggerProps & import("react").RefAttributes<HTMLButtonElement>>;
5
5
  export declare const DropdownMenuPortal: import("react").FC<Primitive.DropdownMenuPortalProps>;
6
- export declare function DropdownMenuContent({ className, sideOffset, ...props }: ComponentPropsWithoutRef<typeof Primitive.Content>): ReactNode;
6
+ export declare function DropdownMenuContent({ className, sideOffset, portalContainer, ...props }: ComponentPropsWithoutRef<typeof Primitive.Content> & {
7
+ /** Mount point of the portal. Default: document.body, which sits outside every landmark. */
8
+ readonly portalContainer?: HTMLElement | null;
9
+ }): ReactNode;
7
10
  export declare function DropdownMenuItem({ className, ...props }: ComponentPropsWithoutRef<typeof Primitive.Item>): ReactNode;
8
11
  export declare function DropdownMenuCheckboxItem({ className, children, checked, ...props }: ComponentPropsWithoutRef<typeof Primitive.CheckboxItem>): ReactNode;
9
12
  export declare const DropdownMenuRadioGroup: import("react").ForwardRefExoticComponent<Primitive.DropdownMenuRadioGroupProps & import("react").RefAttributes<HTMLDivElement>>;
@@ -23,8 +23,8 @@ const contentClass = "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-pop
23
23
  "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 " +
24
24
  "data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 " +
25
25
  "data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2";
26
- export function DropdownMenuContent({ className, sideOffset = 4, ...props }) {
27
- return (_jsx(Primitive.Portal, { children: _jsx(Primitive.Content, { sideOffset: sideOffset, className: cn(contentClass, className), ...props }) }));
26
+ export function DropdownMenuContent({ className, sideOffset = 4, portalContainer, ...props }) {
27
+ return (_jsx(Primitive.Portal, { container: portalContainer, children: _jsx(Primitive.Content, { sideOffset: sideOffset, className: cn(contentClass, className), ...props }) }));
28
28
  }
29
29
  const itemClass = "relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm " +
30
30
  "outline-none transition-colors " +
@@ -2,7 +2,7 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
2
2
  import { applyFormatSpec, isSafeHref } from "@cosmicdrift/kumiko-headless";
3
3
  import { InsideDrawerProvider, needsActionConfirm, STICKY_PRIMARY_ACTION_PROP, shouldRenderActionsIconOnly, statusToneForOptionTone, statusToneForValue, useColumnRenderer, useInsideDrawer, useOptionalLocale, useOptionalTranslation, useTranslation, WriteFailedError, } from "@cosmicdrift/kumiko-renderer";
4
4
  import { cva } from "class-variance-authority";
5
- import { ArrowDown, ArrowUp, ArrowUpDown, Building, CalendarDays, ChevronDown, ChevronLeft, ChevronRight, Globe, Hash, KeyRound, Link, Loader2, Lock, Mail, MapPin, MoreHorizontal, Phone, Search, SlidersHorizontal, Tag, Undo2, User, X, } from "lucide-react";
5
+ import { ArrowDown, ArrowUp, ArrowUpDown, Building, CalendarDays, Check, ChevronDown, ChevronLeft, ChevronRight, Globe, Hash, KeyRound, Link, Loader2, Lock, Mail, MapPin, MoreHorizontal, Phone, Search, SlidersHorizontal, Tag, Undo2, User, X, } from "lucide-react";
6
6
  // The main entry pulls Node-only deps (yargs/pngjs) under bundlers that ignore
7
7
  // package.json#browser (Metro), hence the explicit browser subpath.
8
8
  import QRCode from "qrcode/lib/browser.js";
@@ -144,7 +144,7 @@ const DrawerBodyContext = createContext(false);
144
144
  function DefaultField({ id, label, required, issues, labelAppendix, fieldAppendix, appendixPlacement, description, status, children, layout, accent, hideLabel, testId, changed, }) {
145
145
  const t = useTranslation();
146
146
  const hasError = issues !== undefined && issues.length > 0;
147
- const labelEl = (_jsxs(UiLabel, { id: fieldLabelId(id), htmlFor: id, className: cn("min-w-0 gap-0 text-[13px]", hasError ? "text-destructive" : "text-foreground", hideLabel === true && "sr-only"), children: [_jsx("span", { className: "truncate", title: typeof label === "string" ? label : undefined, children: label }), required === true && (_jsx("span", { "data-required": true, className: "ml-0.5 shrink-0 text-destructive", children: "*" }))] }));
147
+ const labelEl = (_jsxs(UiLabel, { id: fieldLabelId(id), htmlFor: id, className: cn("min-w-0 gap-0 text-[13px]", hasError ? "text-destructive" : "text-foreground", hideLabel === true && "sr-only"), children: [_jsx("span", { className: layout === "inline" ? undefined : "truncate", title: typeof label === "string" ? label : undefined, children: label }), required === true && (_jsx("span", { "data-required": true, className: "ml-0.5 shrink-0 text-destructive", children: "*" }))] }));
148
148
  const errorsEl = hasError ? (_jsx("div", { role: "alert", "data-testid": testId !== undefined ? `${testId}-errors` : undefined, className: "text-xs text-destructive", children: issues.map((issue) => (_jsx("div", { children: t(issue.i18nKey, issue.params) }, `${issue.path}:${issue.code}`))) })) : null;
149
149
  // Inline (boolean/checkbox): Control links, Label rechts — shadcn-Muster.
150
150
  if (layout === "inline") {
@@ -787,8 +787,18 @@ function isEmptyCellValue(value) {
787
787
  return (value === null || value === undefined || (typeof value === "string" && value.trim() === ""));
788
788
  }
789
789
  // A boolean false renders as an empty string unless the column's format spec names a falseLabel.
790
+ // Projection lists synthesize every column as type "text", so a boolean format spec
791
+ // marks a boolean column on its own.
792
+ function isBooleanColumn(type, renderer) {
793
+ if (type === "boolean")
794
+ return true;
795
+ return (typeof renderer === "object" &&
796
+ renderer !== null &&
797
+ "format" in renderer &&
798
+ renderer.format === "boolean");
799
+ }
790
800
  function isUnlabeledFalse(type, value, renderer) {
791
- if (type !== "boolean" || value !== false)
801
+ if (value !== false || !isBooleanColumn(type, renderer))
792
802
  return false;
793
803
  if (renderer === undefined)
794
804
  return true;
@@ -798,10 +808,24 @@ function isUnlabeledFalse(type, value, renderer) {
798
808
  renderer.format === "boolean" &&
799
809
  !("falseLabel" in renderer));
800
810
  }
811
+ function isUnlabeledBooleanFormat(renderer) {
812
+ return (typeof renderer === "object" &&
813
+ renderer !== null &&
814
+ "format" in renderer &&
815
+ renderer.format === "boolean" &&
816
+ !("trueLabel" in renderer) &&
817
+ !("falseLabel" in renderer));
818
+ }
819
+ function BooleanCell({ value, translate, }) {
820
+ const label = value
821
+ ? (translate?.("kumiko.boolean.yes") ?? "Yes")
822
+ : (translate?.("kumiko.boolean.no") ?? "No");
823
+ return (_jsxs("span", { "data-boolean-cell": value ? "true" : "false", children: [value ? (_jsx(Check, { "aria-hidden": "true", className: "h-4 w-4" })) : (_jsx("span", { "aria-hidden": "true", className: "text-muted-foreground", children: EMPTY_CELL_PLACEHOLDER })), _jsx("span", { className: "sr-only", children: label })] }));
824
+ }
801
825
  // True when the cell would render the default "✓": no renderer, or a boolean
802
826
  // format spec that names no trueLabel of its own.
803
827
  function showsBareCheckMark(type, value, renderer) {
804
- if (type !== "boolean" || value !== true)
828
+ if (value !== true || !isBooleanColumn(type, renderer))
805
829
  return false;
806
830
  if (renderer === undefined)
807
831
  return true;
@@ -1162,13 +1186,13 @@ function humanizeSlug(slug) {
1162
1186
  }
1163
1187
  // Tooltip-Text für truncated Cells — bei Hover zeigt der Browser den
1164
1188
  // vollen Text. Skipping für Object/Array (das ist nicht user-readable);
1165
- // Number/Boolean stringifyt der Browser ohnehin korrekt.
1189
+ // Number stringifyt der Browser ohnehin korrekt; booleans get no raw true/false tooltip.
1166
1190
  function cellTitle(value) {
1167
1191
  if (value === null || value === undefined)
1168
1192
  return undefined;
1169
1193
  if (typeof value === "string")
1170
1194
  return value.length > 0 ? value : undefined;
1171
- if (typeof value === "number" || typeof value === "boolean")
1195
+ if (typeof value === "number")
1172
1196
  return String(value);
1173
1197
  return undefined;
1174
1198
  }
@@ -1228,6 +1252,9 @@ function DataTableCell({ value, row, field, type, renderer, optionLabels, option
1228
1252
  if (isEmptyCellValue(value) && typeof renderer !== "function" && componentRef === undefined) {
1229
1253
  return (_jsx("span", { "data-empty-cell": "true", className: "text-muted-foreground", children: EMPTY_CELL_PLACEHOLDER }));
1230
1254
  }
1255
+ if (typeof value === "boolean" && isUnlabeledBooleanFormat(renderer)) {
1256
+ return _jsx(BooleanCell, { value: value, translate: translate });
1257
+ }
1231
1258
  if (isUnlabeledFalse(type, value, renderer)) {
1232
1259
  return (_jsx("span", { "data-empty-cell": "true", className: "text-muted-foreground", children: EMPTY_CELL_PLACEHOLDER }));
1233
1260
  }
@@ -1,3 +1,3 @@
1
1
  import type { LightboxProps } from "@cosmicdrift/kumiko-renderer";
2
2
  import type { ReactNode } from "react";
3
- export declare function DefaultLightbox({ open, onOpenChange, src, alt, testId, }: LightboxProps): ReactNode;
3
+ export declare function DefaultLightbox(props: LightboxProps): ReactNode;
@@ -1,9 +1,35 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useTranslation } from "@cosmicdrift/kumiko-renderer";
3
3
  import * as DialogPrimitive from "@radix-ui/react-dialog";
4
+ import { ChevronLeft, ChevronRight } from "lucide-react";
4
5
  import { cn } from "../lib/cn.js";
5
6
  import { ModalShell } from "./modal-shell.js";
6
- export function DefaultLightbox({ open, onOpenChange, src, alt, testId, }) {
7
+ const NAV_BUTTON_CLASS = "absolute top-1/2 -translate-y-1/2 flex size-9 items-center justify-center rounded-full border border-border bg-card text-foreground opacity-80 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring";
8
+ export function DefaultLightbox(props) {
9
+ const { open, onOpenChange, testId } = props;
7
10
  const t = useTranslation();
8
- return (_jsxs(ModalShell, { open: open, onOpenChange: onOpenChange, testId: testId, closeLabel: t("kumiko.dialog.close"), noAriaDescription: true, contentClassName: cn("border-0 bg-transparent p-0 shadow-none", "max-w-[95vw] max-h-[90vh] w-auto"), children: [_jsx(DialogPrimitive.Title, { className: "sr-only", children: alt }), _jsx("img", { src: src, alt: alt, className: "block max-h-[85vh] max-w-[90vw] w-auto rounded-lg border border-border bg-card shadow-lg" })] }));
11
+ const images = "images" in props ? props.images : [{ src: props.src, alt: props.alt }];
12
+ const onIndexChange = "images" in props ? props.onIndexChange : undefined;
13
+ const total = images.length;
14
+ // Clamped here so a stale index after the image set shrinks never renders blank.
15
+ const requestedIndex = "images" in props ? props.index : 0;
16
+ const index = Math.min(Math.max(requestedIndex, 0), total - 1);
17
+ const current = images[index];
18
+ if (current === undefined)
19
+ return null;
20
+ const canNavigate = onIndexChange !== undefined && total > 1;
21
+ const goTo = (target) => {
22
+ onIndexChange?.((target + total) % total);
23
+ };
24
+ const handleKeyDown = (event) => {
25
+ if (event.key === "ArrowRight") {
26
+ event.preventDefault();
27
+ goTo(index + 1);
28
+ }
29
+ else if (event.key === "ArrowLeft") {
30
+ event.preventDefault();
31
+ goTo(index - 1);
32
+ }
33
+ };
34
+ return (_jsxs(ModalShell, { open: open, onOpenChange: onOpenChange, testId: testId, closeLabel: t("kumiko.dialog.close"), noAriaDescription: true, ...(canNavigate && { onContentKeyDown: handleKeyDown }), contentClassName: cn("border-0 bg-transparent p-0 shadow-none", "max-w-[95vw] max-h-[90vh] w-auto"), children: [_jsx(DialogPrimitive.Title, { className: "sr-only", children: current.alt }), _jsx("img", { src: current.src, alt: current.alt, className: "block max-h-[85vh] max-w-[90vw] w-auto rounded-lg border border-border bg-card shadow-lg" }), canNavigate && (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", "aria-label": t("kumiko.lightbox.previous"), onClick: () => goTo(index - 1), className: cn(NAV_BUTTON_CLASS, "left-2"), children: _jsx(ChevronLeft, { className: "size-5" }) }), _jsx("button", { type: "button", "aria-label": t("kumiko.lightbox.next"), onClick: () => goTo(index + 1), className: cn(NAV_BUTTON_CLASS, "right-2"), children: _jsx(ChevronRight, { className: "size-5" }) }), _jsx("div", { className: "absolute bottom-2 left-1/2 -translate-x-1/2 rounded-full bg-card px-3 py-1 text-xs text-foreground shadow-sm", children: t("kumiko.lightbox.position", { current: index + 1, total }) })] }))] }));
9
35
  }
@@ -1,4 +1,4 @@
1
- import type { ReactNode } from "react";
1
+ import type { KeyboardEvent, ReactNode } from "react";
2
2
  export type ModalShellProps = {
3
3
  readonly open: boolean;
4
4
  readonly onOpenChange: (open: boolean) => void;
@@ -12,5 +12,6 @@ export type ModalShellProps = {
12
12
  /** Lets the caller take over Radix's initial-focus target — call
13
13
  * `event.preventDefault()` and focus the desired element yourself. */
14
14
  readonly onOpenAutoFocus?: (event: Event) => void;
15
+ readonly onContentKeyDown?: (event: KeyboardEvent<HTMLDivElement>) => void;
15
16
  };
16
- export declare function ModalShell({ open, onOpenChange, children, testId, contentClassName, noAriaDescription, closeLabel, showCloseButton, onOpenAutoFocus, }: ModalShellProps): ReactNode;
17
+ export declare function ModalShell({ open, onOpenChange, children, testId, contentClassName, noAriaDescription, closeLabel, showCloseButton, onOpenAutoFocus, onContentKeyDown, }: ModalShellProps): ReactNode;
@@ -4,6 +4,6 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
4
4
  import * as DialogPrimitive from "@radix-ui/react-dialog";
5
5
  import { X } from "lucide-react";
6
6
  import { cn } from "../lib/cn.js";
7
- export function ModalShell({ open, onOpenChange, children, testId, contentClassName, noAriaDescription, closeLabel, showCloseButton = true, onOpenAutoFocus, }) {
8
- return (_jsx(DialogPrimitive.Root, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogPrimitive.Portal, { children: [_jsx(DialogPrimitive.Overlay, { className: "fixed inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" }), _jsxs(DialogPrimitive.Content, { "data-testid": testId, ...(noAriaDescription && { "aria-describedby": undefined }), ...(onOpenAutoFocus !== undefined && { onOpenAutoFocus }), className: cn("fixed left-[50%] top-[50%] z-50 translate-x-[-50%] translate-y-[-50%]", "data-[state=open]:animate-in data-[state=closed]:animate-out", "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", "data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95", contentClassName), children: [children, showCloseButton && closeLabel !== undefined && (_jsx(DialogPrimitive.Close, { asChild: true, children: _jsx("button", { type: "button", "aria-label": closeLabel, className: "absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none", children: _jsx(X, { className: "size-4" }) }) }))] })] }) }));
7
+ export function ModalShell({ open, onOpenChange, children, testId, contentClassName, noAriaDescription, closeLabel, showCloseButton = true, onOpenAutoFocus, onContentKeyDown, }) {
8
+ return (_jsx(DialogPrimitive.Root, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogPrimitive.Portal, { children: [_jsx(DialogPrimitive.Overlay, { className: "fixed inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" }), _jsxs(DialogPrimitive.Content, { "data-testid": testId, ...(noAriaDescription && { "aria-describedby": undefined }), ...(onOpenAutoFocus !== undefined && { onOpenAutoFocus }), ...(onContentKeyDown !== undefined && { onKeyDown: onContentKeyDown }), className: cn("fixed left-[50%] top-[50%] z-50 translate-x-[-50%] translate-y-[-50%]", "data-[state=open]:animate-in data-[state=closed]:animate-out", "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", "data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95", contentClassName), children: [children, showCloseButton && closeLabel !== undefined && (_jsx(DialogPrimitive.Close, { asChild: true, children: _jsx("button", { type: "button", "aria-label": closeLabel, className: "absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none", children: _jsx(X, { className: "size-4" }) }) }))] })] }) }));
9
9
  }
@@ -1,4 +1,5 @@
1
1
  import plugin from "tailwindcss/plugin";
2
+ import { type DefaultColorScheme } from "./default-color-scheme.js";
2
3
  export declare const FRAMEWORK_COLOR_NAMES: readonly ["background", "foreground", "foreground-secondary", "foreground-disabled", "card", "card-foreground", "popover", "popover-foreground", "primary", "primary-foreground", "secondary", "secondary-foreground", "muted", "muted-foreground", "accent", "accent-foreground", "destructive", "destructive-foreground", "border", "border-strong", "border-row", "input", "ring", "sidebar", "sidebar-foreground", "sidebar-primary", "sidebar-primary-foreground", "sidebar-accent", "sidebar-accent-foreground", "sidebar-muted", "sidebar-input", "sidebar-border", "sidebar-ring", "status-ok", "status-ok-surface", "status-warn", "status-warn-surface", "status-bad", "status-bad-surface", "status-critical", "status-critical-surface", "status-neutral", "status-neutral-surface", "status-active", "status-active-surface", "syntax-key", "syntax-string", "syntax-number", "syntax-literal", "promo", "promo-foreground", "promo-accent", "promo-accent-foreground"];
3
4
  export type FrameworkColorName = (typeof FRAMEWORK_COLOR_NAMES)[number];
4
5
  /** A plain string applies to both modes; `dark` falls back to `light`. */
@@ -7,6 +8,8 @@ export type ThemeColorValue = string | {
7
8
  readonly dark?: string;
8
9
  };
9
10
  export type AppTheme = {
11
+ /** Scheme for visitors without a stored choice. "auto" follows the OS setting. */
12
+ readonly defaultColorScheme?: DefaultColorScheme;
10
13
  readonly colors?: Readonly<Partial<Record<FrameworkColorName | (string & {}), ThemeColorValue>>>;
11
14
  /** Base radius; the sm/md/lg/xl scale derives from it. */
12
15
  readonly radius?: string;
@@ -1,4 +1,5 @@
1
1
  import plugin from "tailwindcss/plugin";
2
+ import { DEFAULT_COLOR_SCHEME_VARIABLE } from "./default-color-scheme.js";
2
3
  export const FRAMEWORK_COLOR_NAMES = [
3
4
  "background",
4
5
  "foreground",
@@ -87,6 +88,9 @@ function buildColorDeclarations(colors) {
87
88
  }
88
89
  function buildModeInvariantDeclarations(theme) {
89
90
  const modeInvariant = {};
91
+ if (theme.defaultColorScheme !== undefined) {
92
+ modeInvariant[DEFAULT_COLOR_SCHEME_VARIABLE] = theme.defaultColorScheme;
93
+ }
90
94
  if (theme.radius !== undefined)
91
95
  modeInvariant["--radius"] = theme.radius;
92
96
  if (theme.fonts?.sans !== undefined)
package/dist/tokens.d.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  import { type Tokens, type TokensApi } from "@cosmicdrift/kumiko-renderer";
2
2
  export declare const THEME_STORAGE_KEY = "kumiko:theme";
3
- /** Liest die persistierte Theme-Wahl und setzt die `.dark`-Class. Wird
4
- * beim ersten useBrowserTokensApi-Mount aufgerufen; das Inline-Script
5
- * in der Host-HTML (siehe Header-Kommentar) macht dasselbe synchron
6
- * vor dem ersten Paint. */
3
+ /** Reads the persisted theme choice (or the app default) and sets the `.dark`
4
+ * class. Called on the first useBrowserTokensApi mount; the inline script in
5
+ * the host HTML (see header comment) does the same synchronously before the
6
+ * first paint. */
7
7
  export declare function applyStoredThemeMode(): void;
8
8
  /** Nur für Tests: der once-per-page-load-Guard ist ein Module-Singleton —
9
9
  * ohne Reset wäre der Mount-Restore-Pfad nach der ersten Render im
package/dist/tokens.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { createStore } from "@cosmicdrift/kumiko-headless";
2
2
  import { cssVarTokens, } from "@cosmicdrift/kumiko-renderer";
3
- import { useState, useSyncExternalStore } from "react";
3
+ import { useEffect, useState, useSyncExternalStore } from "react";
4
+ import { DEFAULT_COLOR_SCHEME_VARIABLE } from "./default-color-scheme.js";
4
5
  // Web-spezifische TokensApi-Impl. Theme-Toggle via `.dark`-Class auf
5
6
  // <html>. Die echten Farben leben in styles.css; hier ist nur die
6
7
  // JS-Seite die den class-switch triggert und React-Consumer mit
@@ -11,56 +12,105 @@ import { useState, useSyncExternalStore } from "react";
11
12
  // jedem Class-Wechsel hochzählen. So bleibt die DOM-Klasse die einzige
12
13
  // Wahrheit — readCurrentMode liest sie frisch bei jedem getSnapshot.
13
14
  //
14
- // Persistenz: die Wahl landet in localStorage (THEME_STORAGE_KEY) und
15
- // wird beim ersten Hook-Mount restored — ohne das war der Toggle nach
16
- // jedem Reload weg ("dark/light geht nicht", Prod-Bug 2026-06-07).
17
- // Gegen FOUC gehört zusätzlich ein synchrones Inline-Script in die
18
- // Host-HTML, VOR dem Stylesheet-Link:
15
+ // Persistence: the choice is stored in localStorage (THEME_STORAGE_KEY) and
16
+ // restored on the first hook mount; without it the toggle was lost on every
17
+ // reload (prod bug 2026-06-07).
18
+ // Preference: "auto" follows prefers-color-scheme live (matchMedia listener),
19
+ // "light"/"dark" are explicit choices and win. Without a stored choice,
20
+ // AppTheme.defaultColorScheme (CSS variable) applies, otherwise the HTML state.
19
21
  //
20
- // <script>try{if(localStorage.getItem("kumiko:theme")==="dark")
22
+ // Against FOUC the host HTML also needs a synchronous inline script BEFORE
23
+ // the stylesheet link (nonce/hash under a strict CSP):
24
+ //
25
+ // <script>try{var s=localStorage.getItem("kumiko:theme");
26
+ // if(s==="dark"||(s==="auto"&&matchMedia("(prefers-color-scheme: dark)").matches))
21
27
  // document.documentElement.classList.add("dark")}catch(e){}</script>
22
28
  const themeTick = createStore(0);
23
29
  export const THEME_STORAGE_KEY = "kumiko:theme";
30
+ const DARK_SCHEME_QUERY = "(prefers-color-scheme: dark)";
31
+ function isThemePreference(value) {
32
+ return value === "auto" || value === "light" || value === "dark";
33
+ }
24
34
  function readCurrentMode() {
25
35
  if (typeof document === "undefined")
26
36
  return "dark";
27
37
  return document.documentElement.classList.contains("dark") ? "dark" : "light";
28
38
  }
39
+ function systemPrefersDark() {
40
+ return typeof window.matchMedia === "function" && window.matchMedia(DARK_SCHEME_QUERY).matches;
41
+ }
42
+ function resolveMode(preference) {
43
+ if (preference === "auto")
44
+ return systemPrefersDark() ? "dark" : "light";
45
+ return preference;
46
+ }
47
+ function readDefaultPreference() {
48
+ const declared = getComputedStyle(document.documentElement)
49
+ .getPropertyValue(DEFAULT_COLOR_SCHEME_VARIABLE)
50
+ .trim();
51
+ return isThemePreference(declared) ? declared : readCurrentMode();
52
+ }
53
+ function readStoredPreference() {
54
+ try {
55
+ const stored = window.localStorage.getItem(THEME_STORAGE_KEY);
56
+ return isThemePreference(stored) ? stored : undefined;
57
+ }
58
+ catch {
59
+ // skip: localStorage can throw (private mode); without a stored choice
60
+ // the default stays.
61
+ return undefined;
62
+ }
63
+ }
64
+ // Module singleton like storedModeApplied: null until the first read, so the
65
+ // import stays safe without a DOM (SSR/tests).
66
+ let currentPreference = null;
67
+ function readCurrentPreference() {
68
+ if (currentPreference === null) {
69
+ if (typeof document === "undefined")
70
+ return "dark";
71
+ currentPreference = readStoredPreference() ?? readDefaultPreference();
72
+ }
73
+ return currentPreference;
74
+ }
29
75
  // @wrapper-known semantic-alias
30
76
  function notifyThemeChange() {
31
77
  themeTick.setState((t) => t + 1);
32
78
  }
33
79
  // @wrapper-known semantic-alias
34
- function persistMode(mode) {
80
+ function persistPreference(preference) {
35
81
  try {
36
- window.localStorage.setItem(THEME_STORAGE_KEY, mode);
82
+ window.localStorage.setItem(THEME_STORAGE_KEY, preference);
37
83
  }
38
84
  catch {
39
85
  // skip: localStorage kann werfen (Private-Mode/Quota) — Theme bleibt
40
86
  // dann sessionbasiert, der Class-Toggle hat trotzdem funktioniert.
41
87
  }
42
88
  }
43
- /** Liest die persistierte Theme-Wahl und setzt die `.dark`-Class. Wird
44
- * beim ersten useBrowserTokensApi-Mount aufgerufen; das Inline-Script
45
- * in der Host-HTML (siehe Header-Kommentar) macht dasselbe synchron
46
- * vor dem ersten Paint. */
89
+ function applyPreference(preference) {
90
+ currentPreference = preference;
91
+ document.documentElement.classList.toggle("dark", resolveMode(preference) === "dark");
92
+ notifyThemeChange();
93
+ }
94
+ /** Reads the persisted theme choice (or the app default) and sets the `.dark`
95
+ * class. Called on the first useBrowserTokensApi mount; the inline script in
96
+ * the host HTML (see header comment) does the same synchronously before the
97
+ * first paint. */
47
98
  export function applyStoredThemeMode() {
48
99
  // skip: no document (SSR/non-DOM context), nothing to apply
49
100
  if (typeof document === "undefined")
50
101
  return;
51
- let stored = null;
52
- try {
53
- stored = window.localStorage.getItem(THEME_STORAGE_KEY);
54
- }
55
- catch {
56
- // skip: localStorage kann werfen (Private-Mode) — ohne gespeicherte
57
- // Wahl bleibt der Server-/HTML-Default stehen.
58
- }
59
- // skip: no valid persisted mode stored, keep server/HTML default
60
- if (stored !== "dark" && stored !== "light")
102
+ const stored = readStoredPreference();
103
+ if (stored !== undefined) {
104
+ applyPreference(stored);
105
+ // skip: a stored choice wins over the app default
61
106
  return;
62
- document.documentElement.classList.toggle("dark", stored === "dark");
63
- notifyThemeChange();
107
+ }
108
+ // Without a stored choice only an app-declared default changes the HTML state.
109
+ const declared = getComputedStyle(document.documentElement)
110
+ .getPropertyValue(DEFAULT_COLOR_SCHEME_VARIABLE)
111
+ .trim();
112
+ if (isThemePreference(declared))
113
+ applyPreference(declared);
64
114
  }
65
115
  let storedModeApplied = false;
66
116
  /** Nur für Tests: der once-per-page-load-Guard ist ein Module-Singleton —
@@ -68,6 +118,7 @@ let storedModeApplied = false;
68
118
  * Testfile strukturell unerreichbar. */
69
119
  export function __resetStoredModeAppliedForTests() {
70
120
  storedModeApplied = false;
121
+ currentPreference = null;
71
122
  }
72
123
  /** Hook der eine TokensApi für den Browser baut. Wird von
73
124
  * createKumikoApp genutzt; App-Code der einen eigenen Token-State
@@ -87,24 +138,47 @@ export function useBrowserTokensApi() {
87
138
  return null;
88
139
  });
89
140
  const mode = useSyncExternalStore(themeTick.subscribe, readCurrentMode, () => "dark");
141
+ const preference = useSyncExternalStore(themeTick.subscribe, readCurrentPreference, () => "dark");
142
+ // kumiko-lint-ignore no-raw-hooks listener lifecycle, no data fetching
143
+ useEffect(() => {
144
+ // skip: without matchMedia, auto keeps the mode resolved at apply time
145
+ if (typeof window.matchMedia !== "function")
146
+ return;
147
+ const query = window.matchMedia(DARK_SCHEME_QUERY);
148
+ const followSystem = () => {
149
+ if (readCurrentPreference() === "auto")
150
+ applyPreference("auto");
151
+ };
152
+ // The OS scheme may have changed between the first apply and this mount.
153
+ followSystem();
154
+ query.addEventListener("change", followSystem);
155
+ return () => query.removeEventListener("change", followSystem);
156
+ }, []);
90
157
  return {
91
158
  tokens: cssVarTokens,
92
159
  mode,
160
+ preference,
161
+ setPreference: (next) => {
162
+ // skip: no document (SSR/non-DOM context), nothing to apply
163
+ if (typeof document === "undefined")
164
+ return;
165
+ persistPreference(next);
166
+ applyPreference(next);
167
+ },
93
168
  setMode: (next) => {
94
169
  // skip: no document (SSR/non-DOM context), nothing to toggle
95
170
  if (typeof document === "undefined")
96
171
  return;
97
- document.documentElement.classList.toggle("dark", next === "dark");
98
- persistMode(next);
99
- notifyThemeChange();
172
+ persistPreference(next);
173
+ applyPreference(next);
100
174
  },
101
175
  toggleMode: () => {
102
176
  // skip: no document (SSR/non-DOM context), nothing to toggle
103
177
  if (typeof document === "undefined")
104
178
  return;
105
- const nowDark = document.documentElement.classList.toggle("dark");
106
- persistMode(nowDark ? "dark" : "light");
107
- notifyThemeChange();
179
+ const next = readCurrentMode() === "dark" ? "light" : "dark";
180
+ persistPreference(next);
181
+ applyPreference(next);
108
182
  },
109
183
  };
110
184
  }
@@ -1,14 +1,9 @@
1
1
  import { type ReactNode } from "react";
2
- type KumikoBuild = {
2
+ export type KumikoBuild = {
3
3
  readonly id: string;
4
4
  readonly builtAt: string;
5
5
  };
6
- declare global {
7
- interface Window {
8
- __KUMIKO_BUILD__?: KumikoBuild;
9
- }
10
- }
6
+ export declare function readLoadedBuild(): KumikoBuild | undefined;
11
7
  export declare function shouldShowUpdate(loadedId: string | undefined, server: KumikoBuild | null): boolean;
12
8
  export declare function isKumikoBuild(v: unknown): v is Pick<KumikoBuild, "id">;
13
9
  export declare function UpdateChecker(): ReactNode;
14
- export {};
@@ -11,6 +11,15 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
11
11
  import { useTranslation } from "@cosmicdrift/kumiko-renderer";
12
12
  import { useEffect, useState } from "react";
13
13
  import { cn } from "../lib/cn.js";
14
+ export function readLoadedBuild() {
15
+ if (typeof document === "undefined")
16
+ return undefined;
17
+ const meta = document.querySelector('meta[name="kumiko-build"]');
18
+ const id = meta?.getAttribute("content");
19
+ if (!id)
20
+ return undefined;
21
+ return { id, builtAt: meta?.getAttribute("data-built-at") ?? "" };
22
+ }
14
23
  // Korrektheitsgrenze der Update-Erkennung: ein Banner NUR bei echtem
15
24
  // ID-Drift. Kein geladener Stand (Dev/altes Bundle) oder kein Server-Stand
16
25
  // (Fetch-Fehler/kaputtes JSON → null) → nie ein Fake-Banner.
@@ -43,7 +52,7 @@ async function fetchServerBuild() {
43
52
  }
44
53
  export function UpdateChecker() {
45
54
  const t = useTranslation();
46
- const loaded = typeof window !== "undefined" ? window.__KUMIKO_BUILD__ : undefined;
55
+ const loaded = readLoadedBuild();
47
56
  const [hasUpdate, setHasUpdate] = useState(false);
48
57
  useEffect(() => {
49
58
  const loadedId = loaded?.id;
@@ -35,10 +35,17 @@ export type TimeseriesPoint = {
35
35
  /** null = Ausfall/kein Wert → fällt auf die Grundlinie (sichtbarer Einbruch). */
36
36
  readonly value: number | null;
37
37
  };
38
+ export type TimeseriesReferenceLine = {
39
+ /** Same unit as the point values (e.g. a p95 or SLO threshold in ms). */
40
+ readonly value: number;
41
+ /** Translated by the caller. */
42
+ readonly label: string;
43
+ readonly tone?: StatusTone;
44
+ };
38
45
  /** Zeitreihen-Linien-Chart (geglättete Linie + Flächen-Verlauf). x-Achse =
39
46
  * ZEIT im Fenster windowStartMs..windowEndMs, nicht Index — 5 Min Daten in
40
47
  * einem 30-Tage-Fenster ergeben ehrlich einen schmalen Streifen rechts. */
41
- export declare function TimeseriesChart({ points, windowStartMs, windowEndMs, tone, ariaLabel, axisLabels, markers, formatMarkerTime, emptyContent, testId, }: {
48
+ export declare function TimeseriesChart({ points, windowStartMs, windowEndMs, tone, ariaLabel, axisLabels, markers, formatMarkerTime, referenceLines, emptyContent, testId, }: {
42
49
  readonly points: readonly TimeseriesPoint[];
43
50
  readonly windowStartMs: number;
44
51
  readonly windowEndMs: number;
@@ -53,6 +60,9 @@ export declare function TimeseriesChart({ points, windowStartMs, windowEndMs, to
53
60
  /** Numbered pins on the x-axis plus a numbered list below the chart. */
54
61
  readonly markers?: readonly ChartMarker[];
55
62
  readonly formatMarkerTime?: (atMs: number) => string;
63
+ /** Dashed horizontal threshold lines (e.g. p95); they extend the y-scale
64
+ * when above the data maximum. Non-finite and negative values are dropped. */
65
+ readonly referenceLines?: readonly TimeseriesReferenceLine[];
56
66
  /** Rendert statt des Charts wenn <2 Messwerte vorliegen. */
57
67
  readonly emptyContent?: ReactNode;
58
68
  readonly testId?: string;
@@ -64,20 +64,23 @@ export function smoothPath(pts) {
64
64
  /** Zeitreihen-Linien-Chart (geglättete Linie + Flächen-Verlauf). x-Achse =
65
65
  * ZEIT im Fenster windowStartMs..windowEndMs, nicht Index — 5 Min Daten in
66
66
  * einem 30-Tage-Fenster ergeben ehrlich einen schmalen Streifen rechts. */
67
- export function TimeseriesChart({ points, windowStartMs, windowEndMs, tone = "ok", ariaLabel, axisLabels, markers, formatMarkerTime, emptyContent, testId, }) {
67
+ export function TimeseriesChart({ points, windowStartMs, windowEndMs, tone = "ok", ariaLabel, axisLabels, markers, formatMarkerTime, referenceLines, emptyContent, testId, }) {
68
68
  const gradientId = useId();
69
+ const descId = useId();
69
70
  const chartWidth = 300;
70
71
  const chartHeight = 64;
71
72
  const values = points.map((p) => p.value).filter((v) => v !== null);
72
73
  if (values.length < 2) {
73
74
  return (_jsx("div", { className: "flex h-16 items-center justify-center text-[13px] text-muted-foreground", children: emptyContent }));
74
75
  }
75
- const maxValue = Math.max(...values, 1);
76
+ const drawableLines = (referenceLines ?? []).filter((line) => Number.isFinite(line.value) && line.value >= 0);
77
+ const maxValue = Math.max(...values, ...drawableLines.map((line) => line.value), 1);
78
+ const yOf = (value) => chartHeight - (value / maxValue) * chartHeight;
76
79
  const span = Math.max(1, windowEndMs - windowStartMs);
77
80
  const xOf = (atMs) => Math.max(0, Math.min(1, (atMs - windowStartMs) / span)) * chartWidth;
78
81
  const chartPoints = points.map((p) => ({
79
82
  x: xOf(p.atMs),
80
- y: p.value === null ? chartHeight : chartHeight - (p.value / maxValue) * chartHeight,
83
+ y: p.value === null ? chartHeight : yOf(p.value),
81
84
  }));
82
85
  const linePath = smoothPath(chartPoints);
83
86
  const firstPoint = chartPoints[0];
@@ -86,7 +89,11 @@ export function TimeseriesChart({ points, windowStartMs, windowEndMs, tone = "ok
86
89
  ? `${linePath} L ${lastPoint.x.toFixed(1)} ${chartHeight} L ${firstPoint.x.toFixed(1)} ${chartHeight} Z`
87
90
  : "";
88
91
  const color = TONE_VAR[tone];
89
- return (_jsxs("div", { "data-testid": testId, className: STATUS_TONE_TEXT[tone], children: [_jsxs("svg", { viewBox: `0 0 ${chartWidth} ${chartHeight}`, preserveAspectRatio: "none", className: "block h-16 w-full", role: "img", "aria-label": ariaLabel, children: [_jsx("title", { children: ariaLabel }), _jsx("defs", { children: _jsxs("linearGradient", { id: gradientId, x1: "0", y1: "0", x2: "0", y2: "1", children: [_jsx("stop", { offset: "0%", stopColor: color, stopOpacity: 0.3 }), _jsx("stop", { offset: "100%", stopColor: color, stopOpacity: 0.02 })] }) }), _jsx("path", { d: areaPath, fill: `url(#${gradientId})`, stroke: "none" }), _jsx("path", { d: linePath, fill: "none", stroke: color, strokeWidth: 1.5, strokeLinejoin: "round", vectorEffect: "non-scaling-stroke" })] }), axisLabels !== undefined && (_jsxs("div", { className: "mt-1 flex justify-between text-[11px] text-muted-foreground", children: [_jsx("span", { children: axisLabels.start }), axisLabels.mid !== undefined && _jsx("span", { children: axisLabels.mid }), _jsx("span", { children: axisLabels.end })] })), markers !== undefined && markers.length > 0 && (_jsxs("div", { className: "text-foreground", children: [_jsx(MarkerPins, { markers: markers, windowStartMs: windowStartMs, windowEndMs: windowEndMs }), _jsx("div", { className: "mt-2", children: _jsx(MarkerLegend, { markers: markers, formatMarkerTime: formatMarkerTime }) })] }))] }));
92
+ const hasReferenceLines = drawableLines.length > 0;
93
+ const svg = (_jsxs("svg", { viewBox: `0 0 ${chartWidth} ${chartHeight}`, preserveAspectRatio: "none", className: "block h-16 w-full", role: "img", "aria-label": ariaLabel, "aria-describedby": hasReferenceLines ? descId : undefined, children: [_jsx("title", { children: ariaLabel }), hasReferenceLines && _jsx("desc", { id: descId, children: drawableLines.map((l) => l.label).join(", ") }), _jsx("defs", { children: _jsxs("linearGradient", { id: gradientId, x1: "0", y1: "0", x2: "0", y2: "1", children: [_jsx("stop", { offset: "0%", stopColor: color, stopOpacity: 0.3 }), _jsx("stop", { offset: "100%", stopColor: color, stopOpacity: 0.02 })] }) }), _jsx("path", { d: areaPath, fill: `url(#${gradientId})`, stroke: "none" }), _jsx("path", { d: linePath, fill: "none", stroke: color, strokeWidth: 1.5, strokeLinejoin: "round", vectorEffect: "non-scaling-stroke" }), drawableLines.map((line) => (_jsx("line", { "data-reference-line": "", x1: 0, x2: chartWidth, y1: yOf(line.value), y2: yOf(line.value), stroke: TONE_VAR[line.tone ?? "muted"], strokeWidth: 1, strokeDasharray: "4 3", vectorEffect: "non-scaling-stroke" }, `${line.value}:${line.label}`)))] }));
94
+ return (_jsxs("div", { "data-testid": testId, className: STATUS_TONE_TEXT[tone], children: [hasReferenceLines ? (_jsxs("div", { className: "relative", children: [svg, drawableLines.map((line) => (_jsx("span", { "aria-hidden": "true", className: cn("absolute right-0 rounded bg-background/80 px-1 text-[11px]",
95
+ // A line near the top would push its label above the chart box.
96
+ yOf(line.value) >= chartHeight / 4 && "-translate-y-full", STATUS_TONE_TEXT[line.tone ?? "muted"]), style: { top: `${(yOf(line.value) / chartHeight) * 100}%` }, children: line.label }, `${line.value}:${line.label}`)))] })) : (svg), axisLabels !== undefined && (_jsxs("div", { className: "mt-1 flex justify-between text-[11px] text-muted-foreground", children: [_jsx("span", { children: axisLabels.start }), axisLabels.mid !== undefined && _jsx("span", { children: axisLabels.mid }), _jsx("span", { children: axisLabels.end })] })), markers !== undefined && markers.length > 0 && (_jsxs("div", { className: "text-foreground", children: [_jsx(MarkerPins, { markers: markers, windowStartMs: windowStartMs, windowEndMs: windowEndMs }), _jsx("div", { className: "mt-2", children: _jsx(MarkerLegend, { markers: markers, formatMarkerTime: formatMarkerTime }) })] }))] }));
90
97
  }
91
98
  const CHART_TONE_VAR = {
92
99
  positive: "var(--color-status-ok)",
@@ -1,5 +1,5 @@
1
1
  export { AiTextArea, type AiTextAreaProps, AiTextField, type AiTextFieldProps, } from "./ai-text-field.js";
2
- export { type ChartMarker, type ChartSeries, type ChartTone, SegmentBarChart, type SegmentBarRow, StackedAreaChart, StackedBarChart, StatusBarChart, type StatusBarEntry, smoothPath, TimeseriesChart, type TimeseriesPoint, } from "./charts.js";
2
+ export { type ChartMarker, type ChartSeries, type ChartTone, SegmentBarChart, type SegmentBarRow, StackedAreaChart, StackedBarChart, StatusBarChart, type StatusBarEntry, smoothPath, TimeseriesChart, type TimeseriesPoint, type TimeseriesReferenceLine, } from "./charts.js";
3
3
  export { CollapsibleSection } from "./collapsible-section.js";
4
4
  export { type DashboardListColumn, DashboardListTable } from "./dashboard-list.js";
5
5
  export { DetailList } from "./detail-list.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer-web",
3
- "version": "0.338.0",
3
+ "version": "0.339.0",
4
4
  "description": "Web-platform bindings for @cosmicdrift/kumiko-renderer. HTML default-primitives, browser history-based navigation, EventSource-backed live events, and a one-call createKumikoApp that mounts the whole stack via react-dom.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -20,11 +20,11 @@
20
20
  }
21
21
  },
22
22
  "dependencies": {
23
- "@cosmicdrift/kumiko-dispatcher-live": "0.338.0",
24
- "@cosmicdrift/kumiko-framework": "0.338.0",
25
- "@cosmicdrift/kumiko-headless": "0.338.0",
26
- "@cosmicdrift/kumiko-renderer": "0.338.0",
27
- "@cosmicdrift/kumiko-types": "0.338.0",
23
+ "@cosmicdrift/kumiko-dispatcher-live": "0.339.0",
24
+ "@cosmicdrift/kumiko-framework": "0.339.0",
25
+ "@cosmicdrift/kumiko-headless": "0.339.0",
26
+ "@cosmicdrift/kumiko-renderer": "0.339.0",
27
+ "@cosmicdrift/kumiko-types": "0.339.0",
28
28
  "@radix-ui/react-dialog": "^1.1.15",
29
29
  "@radix-ui/react-dropdown-menu": "^2.1.16",
30
30
  "@radix-ui/react-label": "^2.1.8",
@@ -72,7 +72,7 @@
72
72
  "@types/react-dom": "^19.2.3",
73
73
  "jsdom": "^29.1.1",
74
74
  "tailwindcss": "^4.3.0",
75
- "@cosmicdrift/kumiko-locale-de": "0.338.0"
75
+ "@cosmicdrift/kumiko-locale-de": "0.339.0"
76
76
  },
77
77
  "repository": {
78
78
  "type": "git",
package/src/changes.json CHANGED
@@ -1,4 +1,39 @@
1
1
  [
2
+ {
3
+ "version": "0.339.0",
4
+ "type": "fix",
5
+ "title": "Columns with format boolean show a check mark with the accessible name Yes, or a dash with No, instead of raw true/false",
6
+ "detail": "format boolean renders a localized Yes/No instead of raw true/false",
7
+ "migration": "No code change needed."
8
+ },
9
+ {
10
+ "version": "0.339.0",
11
+ "type": "fix",
12
+ "title": "Inline field labels wrap instead of truncating",
13
+ "detail": "`Field layout=\"inline\"` no longer puts its label in a single truncated line. Long checkbox labels such as the checkout consent texts wrap, so the dialog stays inside the viewport and its buttons stay reachable.",
14
+ "migration": "keine"
15
+ },
16
+ {
17
+ "version": "0.339.0",
18
+ "type": "fix",
19
+ "title": "The open LanguageSwitcher no longer hides the app root with aria-hidden and renders its menu inside the surrounding header landmark",
20
+ "detail": "LanguageSwitcher is axe-clean while open",
21
+ "migration": "No code change needed."
22
+ },
23
+ {
24
+ "version": "0.339.0",
25
+ "type": "improvement",
26
+ "title": "Theme preference auto follows the OS color scheme live; ThemeToggle and ThemeMenuItem step through light, dark and auto, a stored choice wins, and defineAppTheme accepts defaultColorScheme",
27
+ "detail": "Theme mode auto follows prefers-color-scheme",
28
+ "migration": "Apps with a theme-restore inline script in their host HTML must update it. The toggle can now store \"auto\", which an old script that only checks for \"dark\" treats as light, so dark-mode users see a light flash on load. Use the script from the comment in renderer-web tokens.ts, which also handles \"auto\" via matchMedia. Under a strict CSP, update the script hash or keep the nonce."
29
+ },
30
+ {
31
+ "version": "0.339.0",
32
+ "type": "improvement",
33
+ "title": "TimeseriesChart draws optional reference lines",
34
+ "detail": "`TimeseriesChart` accepts an optional `referenceLines` prop that draws dashed horizontal threshold lines (for example a p95 or an SLO target) with an accessible label. A line above the data maximum extends the y-scale.",
35
+ "migration": "No code change needed. Pass `referenceLines={[{ value, label, tone }]}` to draw a dashed threshold such as p95 or an SLO target."
36
+ },
2
37
  {
3
38
  "version": "0.337.0",
4
39
  "type": "fix",