@cosmicdrift/kumiko-renderer-web 0.336.1 → 0.338.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.
@@ -233,7 +233,7 @@ function KumikoAppRoot(props) {
233
233
  warnMissingCustomScreens(loaded, props.customScreens);
234
234
  setApp(loaded);
235
235
  }, schemaIsRoleProjected: isFetchMode, ...(isFetchMode && { onUnmountResetSchema: () => setApp(undefined) }), ...(props.translate !== undefined && { translate: props.translate }), ...(props.onRowClick !== undefined && { onRowClick: props.onRowClick }), ...(props.shell !== undefined && { shell: props.shell }) }));
236
- return (_jsx(TokensBoot, { children: _jsxs(LocaleProvider, { resolver: props.localeResolver, fallbackBundles: fallbackBundles, children: [_jsx(DocumentLangSync, { resolver: props.localeResolver }), _jsx(PrimitivesProvider, { value: props.primitives, children: _jsx(ScreenWidthProvider, { width: props.screenWidth ?? "4xl", children: _jsx(AppFeaturesProvider, { features: app?.features ?? EMPTY_FEATURES, children: _jsx(DispatcherProvider, { dispatcher: props.dispatcher, children: _jsx(DraftStorageProvider, { value: props.draftStorage, children: _jsx(LiveEventsProvider, { value: props.liveEvents, children: _jsx(DashboardBodyProvider, { value: WebDashboardBody, children: _jsx(CustomScreensProvider, { value: props.customScreens, children: _jsx(ColumnRenderersProvider, { value: props.columnRenderers, children: _jsx(ContentEditorsProvider, { value: props.contentEditors, children: _jsx(ExtensionSectionsProvider, { value: props.extensionSectionComponents, children: _jsx(NavProvidersProvider, { value: navProviders, entities: navEntities, children: _jsx(ResolversProvider, { resolvers: props.resolvers, children: _jsxs(ToastProvider, { children: [_jsx(UpdateChecker, {}), _jsx(SessionEndedSignalProvider, { signal: props.sessionEndedSignal, children: stackWrappers(props.providers, stackWrappers(props.gates, screenNode)) })] }) }) }) }) }) }) }) }) }) }) }) }) }) })] }) }));
236
+ return (_jsx(TokensBoot, { children: _jsxs(LocaleProvider, { resolver: props.localeResolver, fallbackBundles: fallbackBundles, children: [_jsx(DocumentLangSync, { resolver: props.localeResolver }), _jsx(PrimitivesProvider, { value: props.primitives, children: _jsx(ScreenWidthProvider, { width: props.screenWidth, children: _jsx(AppFeaturesProvider, { features: app?.features ?? EMPTY_FEATURES, children: _jsx(DispatcherProvider, { dispatcher: props.dispatcher, children: _jsx(DraftStorageProvider, { value: props.draftStorage, children: _jsx(LiveEventsProvider, { value: props.liveEvents, children: _jsx(DashboardBodyProvider, { value: WebDashboardBody, children: _jsx(CustomScreensProvider, { value: props.customScreens, children: _jsx(ColumnRenderersProvider, { value: props.columnRenderers, children: _jsx(ContentEditorsProvider, { value: props.contentEditors, children: _jsx(ExtensionSectionsProvider, { value: props.extensionSectionComponents, children: _jsx(NavProvidersProvider, { value: navProviders, entities: navEntities, children: _jsx(ResolversProvider, { resolvers: props.resolvers, children: _jsxs(ToastProvider, { children: [_jsx(UpdateChecker, {}), _jsx(SessionEndedSignalProvider, { signal: props.sessionEndedSignal, children: stackWrappers(props.providers, stackWrappers(props.gates, screenNode)) })] }) }) }) }) }) }) }) }) }) }) }) }) }) })] }) }));
237
237
  }
238
238
  // Sits INSIDE the clientFeature gates (e.g. an auth gate) — a gate that
239
239
  // renders a login placeholder instead of children keeps this component
@@ -288,7 +288,9 @@ export function createKumikoApp(options = {}) {
288
288
  const dispatcher = withSessionEndedDetection(options.dispatcher ?? createLiveDispatcher(), sessionEndedSignal);
289
289
  const draftStorage = options.draftStorage ?? createBrowserDraftStorage();
290
290
  const primitives = { ...defaultPrimitives, ...(options.primitives ?? {}) };
291
- const liveEvents = createEventSourceLiveEvents();
291
+ const liveEvents = createEventSourceLiveEvents({
292
+ onSessionEnded: () => sessionEndedSignal.notify(),
293
+ });
292
294
  // Feature plugins: providers stack outermost (every gate + screen sees
293
295
  // every provider), gates stack between the renderer providers and the
294
296
  // shell/screen. Array order: first element = outermost wrapper.
@@ -307,9 +307,7 @@ function useTimeRange(screen, translate) {
307
307
  if (timeRange === undefined)
308
308
  return { params: {}, control: null };
309
309
  const fromUrl = nav.searchParams[timeRange.id];
310
- const value = timeRange.options.some((o) => o.value === fromUrl)
311
- ? fromUrl
312
- : timeRange.default;
310
+ const value = timeRange.options.find((o) => o.value === fromUrl)?.value ?? timeRange.default;
313
311
  return {
314
312
  // Metric buckets are cut server-side; without the user's zone they default
315
313
  // to UTC while the axis labels are formatted in the user's zone.
@@ -300,18 +300,22 @@ function ActionGlyph({ icon }) {
300
300
  // provider-emitted TreeNode reaches here without going through boot — warn
301
301
  // once per label instead of rendering a dead button.
302
302
  const warnedTreeActionLabels = new Set();
303
- function warnTreeActionDropped(label) {
304
- // skip: already warned for this label — suppresses the repeat, not the warning itself.
305
- if (warnedTreeActionLabels.has(label))
303
+ function warnTreeActionMisconfigured(label, problem) {
304
+ const key = `${label}:${problem}`;
305
+ // skip: already warned for this label and problem — suppresses the repeat, not the warning itself.
306
+ if (warnedTreeActionLabels.has(key))
306
307
  return;
307
- warnedTreeActionLabels.add(label);
308
+ warnedTreeActionLabels.add(key);
308
309
  // biome-ignore lint/suspicious/noConsole: dev-warning for a setup error
309
- console.warn(`[kumiko] Nav action "${label}" has neither "screen" nor "target" set — it will not render.`);
310
+ console.warn(`[kumiko] Nav action "${label}" ${problem}`);
310
311
  }
311
312
  // An action button carries screen XOR target (see TreeAction) — `screen`
312
313
  // renders a KumikoLink to the route, `target` a dispatch button. Same
313
314
  // classes/size either way, only the element changes.
314
315
  function TreeActionControl({ action, ariaLabel, className, dispatch, workspaceId, children, }) {
316
+ if (action.screen !== undefined && action.target !== undefined) {
317
+ warnTreeActionMisconfigured(action.label, 'has both "screen" and "target" set — using "screen", "target" is ignored.');
318
+ }
315
319
  if (action.screen !== undefined) {
316
320
  const screenId = lastSegment(action.screen);
317
321
  return (_jsx(KumikoLink, { to: { ...(workspaceId !== undefined && { workspaceId }), screenId }, "aria-label": ariaLabel, className: className, onClick: (e) => e.stopPropagation(), children: children }));
@@ -323,7 +327,7 @@ function TreeActionControl({ action, ariaLabel, className, dispatch, workspaceId
323
327
  dispatch(target);
324
328
  }, children: children }));
325
329
  }
326
- warnTreeActionDropped(action.label);
330
+ warnTreeActionMisconfigured(action.label, 'has neither "screen" nor "target" set — it will not render.');
327
331
  return null;
328
332
  }
329
333
  // Hover-actions + the "+" affordance, positioned absolute right.
@@ -38,8 +38,10 @@ export async function resizeImageBeforeUpload(file, maxEdge = DEFAULT_MAX_EDGE)
38
38
  const height = Math.round(bitmap.height * scale);
39
39
  const canvas = new OffscreenCanvas(width, height);
40
40
  const ctx = canvas.getContext("2d");
41
- if (ctx === null)
41
+ if (ctx === null) {
42
+ bitmap.close();
42
43
  return file;
44
+ }
43
45
  ctx.drawImage(bitmap, 0, 0, width, height);
44
46
  bitmap.close();
45
47
  const blob = await canvas.convertToBlob({ type: pickOutputType(file.type), quality: 0.85 });
@@ -14,7 +14,7 @@ export declare function BareFormProvider({ children }: {
14
14
  children: ReactNode;
15
15
  }): ReactNode;
16
16
  export declare function ScreenWidthProvider({ width, children, }: {
17
- readonly width: FormWidth;
17
+ readonly width: FormWidth | undefined;
18
18
  readonly children: ReactNode;
19
19
  }): ReactNode;
20
20
  export type FormScreenShellWidth = FormWidth;
@@ -3,7 +3,9 @@ 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
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";
6
- import QRCode from "qrcode";
6
+ // The main entry pulls Node-only deps (yargs/pngjs) under bundlers that ignore
7
+ // package.json#browser (Metro), hence the explicit browser subpath.
8
+ import QRCode from "qrcode/lib/browser.js";
7
9
  import { Children, createContext, Fragment, isValidElement, useContext, useEffect, useId, useRef, useState, } from "react";
8
10
  import { Icon, NAV_ICONS } from "../icons.js";
9
11
  import { cn } from "../lib/cn.js";
@@ -191,25 +193,18 @@ function fieldIconFor(icon) {
191
193
  // `icon` carries a known FIELD_ICONS key. `pl-8` overrides (via
192
194
  // tailwind-merge) only the left padding of the vendored ui/input.tsx —
193
195
  // right padding and other defaults stay untouched.
196
+ const FIELD_ICON_CLASS = "pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground";
194
197
  function withFieldIcon(icon, input) {
195
198
  const Icon = fieldIconFor(icon);
196
199
  if (Icon === undefined)
197
200
  return input;
198
- return (_jsxs("div", { className: "relative", children: [_jsx(Icon, { "aria-hidden": "true", className: "pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" }), input] }));
201
+ return (_jsxs("div", { className: "relative", children: [_jsx(Icon, { "aria-hidden": "true", className: FIELD_ICON_CLASS }), input] }));
199
202
  }
200
- // Mirrors withFieldIcon on the right side: a muted, non-interactive unit
201
- // suffix rendered inside the input's visual box. Pure decoration — never
202
- // focusable, never touches the input's value.
203
203
  // The unit changes the meaning of the value ("58 mi" vs "58 km"), so it is
204
204
  // exposed as the input's description rather than hidden from assistive tech.
205
205
  function unitSuffixId(inputId) {
206
206
  return `${inputId}-unit`;
207
207
  }
208
- function withUnitSuffix(inputId, unit, input) {
209
- if (unit === undefined)
210
- return input;
211
- return (_jsxs("div", { className: "relative", children: [input, _jsx("span", { id: unitSuffixId(inputId), className: "pointer-events-none absolute right-2.5 top-1/2 max-w-[40%] -translate-y-1/2 truncate text-sm text-muted-foreground", children: unit })] }));
212
- }
213
208
  const TRAILING_ACTION_ICONS = { clear: X, undo: Undo2 };
214
209
  // In-field icon button on the right (same look as the date input's calendar
215
210
  // trigger). The wrapped input must reserve the room itself (`pr-9`).
@@ -336,8 +331,13 @@ function DefaultInput(props) {
336
331
  return (_jsx(UiInput, { type: "email", ...common, "data-testid": props.testId, value: props.value, onChange: (e) => props.onChange(e.target.value), ...(props.placeholder !== undefined && { placeholder: props.placeholder }), autoComplete: props.autoComplete ?? "email" }));
337
332
  case "password":
338
333
  return withTrailingAction(props.trailingAction, _jsx(UiInput, { type: "password", ...common, "data-testid": props.testId, value: props.value, onChange: (e) => props.onChange(e.target.value), ...(props.placeholder !== undefined && { placeholder: props.placeholder }), autoComplete: props.autoComplete ?? "current-password", className: props.trailingAction !== undefined ? "pr-9" : undefined }), true);
339
- case "number":
340
- return withUnitSuffix(props.id, props.unit, withFieldIcon(props.icon, _jsx(NumberInput, { id: props.id, name: props.name, value: props.value, onChange: props.onChange, disabled: props.disabled, required: props.required, hasError: props.hasError, testId: props.testId, ...(props.unit !== undefined && { ariaDescribedBy: unitSuffixId(props.id) }), ...(props.locale !== undefined && { locale: props.locale }), ...(props.grouping !== undefined && { grouping: props.grouping }), ...(props.integer !== undefined && { integer: props.integer }), ...(props.placeholder !== undefined && { placeholder: props.placeholder }), className: cn(fieldIconFor(props.icon) !== undefined ? "pl-8" : undefined, props.unit !== undefined ? "pr-8" : undefined) })));
334
+ case "number": {
335
+ const NumberIcon = fieldIconFor(props.icon);
336
+ // Icon, input and unit share this one box, and the grid cell sizes the
337
+ // box rather than the bare input: anchored to a wider wrapper, the unit
338
+ // would sit next to a narrower input instead of inside it.
339
+ return (_jsxs("div", { "data-slot": "number-field", className: "relative", children: [NumberIcon !== undefined && (_jsx(NumberIcon, { "aria-hidden": "true", className: FIELD_ICON_CLASS })), _jsx(NumberInput, { id: props.id, name: props.name, value: props.value, onChange: props.onChange, disabled: props.disabled, required: props.required, hasError: props.hasError, testId: props.testId, ...(props.unit !== undefined && { ariaDescribedBy: unitSuffixId(props.id) }), ...(props.locale !== undefined && { locale: props.locale }), ...(props.grouping !== undefined && { grouping: props.grouping }), ...(props.integer !== undefined && { integer: props.integer }), ...(props.placeholder !== undefined && { placeholder: props.placeholder }), className: cn(NumberIcon !== undefined ? "pl-8" : undefined, props.unit !== undefined ? "pr-8" : undefined) }), props.unit !== undefined && (_jsx("span", { id: unitSuffixId(props.id), className: "pointer-events-none absolute right-2.5 top-1/2 max-w-[40%] -translate-y-1/2 truncate text-sm text-muted-foreground", children: props.unit }))] }));
340
+ }
341
341
  case "range":
342
342
  return (_jsx("input", { type: "range", ...common, min: props.min, max: props.max, step: props.step ?? 1, value: props.value, onChange: (e) => props.onChange(Number(e.target.value)), className: "w-full accent-primary" }));
343
343
  case "boolean":
@@ -658,7 +658,7 @@ function DefaultDataTable({ columns, rows, onRowClick, sort, onSortChange, empty
658
658
  const hasMenu = menuActions !== undefined && menuActions.length > 0;
659
659
  const body = (_jsxs(_Fragment, { children: [_jsxs("div", { className: "flex min-w-0 flex-1 flex-col gap-1", children: [_jsxs("div", { className: "flex min-w-0 items-start justify-between gap-2", children: [cardTitleColumn !== undefined && (_jsx("span", { "data-testid": getCellTestId?.(row, cardTitleColumn.field) ??
660
660
  `cell-${row.id}-${cardTitleColumn.field}`, className: "min-w-0 truncate text-base font-semibold text-foreground", children: cardCell(row, cardTitleColumn) })), showStatus && (_jsx("span", { "data-testid": getCellTestId?.(row, cardStatusColumn.field) ??
661
- `cell-${row.id}-${cardStatusColumn.field}`, className: "shrink-0", children: cardCell(row, cardStatusColumn) }))] }), metaColumns.length > 0 && (_jsx("div", { className: "min-w-0 max-h-10 overflow-hidden text-[13px] leading-5 tabular-nums text-foreground-secondary", children: _jsx("div", { "data-testid": `card-meta-${row.id}`, className: "-ml-3 flex flex-wrap items-center", children: metaColumns.map((col) => (_jsxs("span", { className: "flex min-w-0 max-w-full items-center", children: [_jsx("span", { "aria-hidden": "true", className: "w-3 shrink-0 text-center", children: "\u00B7" }), _jsx("span", { "data-testid": getCellTestId?.(row, col.field) ?? `cell-${row.id}-${col.field}`, className: "min-w-0 truncate", children: isBadgeColumn(col) ? (_jsx("span", { className: "inline-flex align-middle", children: cardCell(row, col) })) : (cardCell(row, col)) })] }, col.field))) }) }))] }), rowIsLink && (_jsx(ChevronRight, { className: "size-4 shrink-0 text-muted-foreground", "aria-hidden": "true" }))] }));
661
+ `cell-${row.id}-${cardStatusColumn.field}`, className: "shrink-0", children: cardCell(row, cardStatusColumn) }))] }), metaColumns.length > 0 && (_jsx("div", { className: "min-w-0 max-h-10 overflow-hidden text-[13px] leading-5 tabular-nums text-foreground-secondary", children: _jsx("div", { "data-testid": `card-meta-${row.id}`, className: "-ms-3 flex flex-wrap items-center", children: metaColumns.map((col) => (_jsxs("span", { className: "flex min-w-0 max-w-full items-center", children: [_jsx("span", { "aria-hidden": "true", className: "w-3 shrink-0 text-center", children: "\u00B7" }), _jsx("span", { "data-testid": getCellTestId?.(row, col.field) ?? `cell-${row.id}-${col.field}`, className: "min-w-0 truncate", children: isBadgeColumn(col) ? (_jsx("span", { className: "inline-flex align-middle", children: cardCell(row, col) })) : (cardCell(row, col)) })] }, col.field))) }) }))] }), rowIsLink && (_jsx(ChevronRight, { className: "size-4 shrink-0 text-muted-foreground", "aria-hidden": "true" }))] }));
662
662
  const rowItem = (_jsxs("li", { "data-testid": rowTestId(row), "data-tone": rowTone?.(row), className: cn("flex min-h-[72px] items-center gap-2 border-b border-border-row py-3", rowToneClass(rowTone?.(row)), isRowExpandable ? "pl-2" : "pl-4", hasMenu ? "pr-2" : "pr-4"), ...(rowIsLink && { onClick: () => onRowClick(row) }), children: [renderExpandToggle(row), rowIsLink ? (_jsx("button", { type: "button", className: "flex min-w-0 flex-1 cursor-pointer items-center gap-2 bg-transparent p-0 text-left", onKeyDown: (e) => {
663
663
  if (e.key !== "Enter")
664
664
  return;
@@ -681,17 +681,21 @@ function DefaultDataTable({ columns, rows, onRowClick, sort, onSortChange, empty
681
681
  }) ?? `Sorted by ${sort.field}`)
682
682
  : (tableTranslate?.("kumiko.list.sort.unsorted") ?? "Unsorted");
683
683
  const SortArrow = sort?.dir === "desc" ? ArrowDown : ArrowUp;
684
- return (_jsxs("div", { "data-testid": testId !== undefined ? `${testId}-cards` : "render-list-cards", className: "flex flex-col", children: [onSortChange !== undefined && sortableColumns.length > 0 && (_jsxs("div", { className: "relative flex h-9 items-center gap-1.5 px-4 text-[13px] text-foreground-secondary", children: [_jsx("span", { "aria-hidden": "true", children: sortLabel }), sort !== undefined && sort !== null && (_jsx(SortArrow, { className: "size-3.5", "aria-hidden": "true" })), _jsxs("select", { "aria-label": tableTranslate?.("kumiko.list.sort.label") ?? "Sort", "data-testid": testId !== undefined ? `${testId}-sort` : "render-list-sort", value: sort !== undefined && sort !== null ? `${sort.field}:${sort.dir}` : "", onChange: (e) => {
685
- const raw = e.target.value;
686
- if (raw === "") {
687
- onSortChange(null);
688
- return;
689
- }
690
- const picked = sortOptions.find((o) => o.value === raw);
691
- if (picked === undefined)
692
- return;
693
- onSortChange({ field: picked.field, dir: picked.dir });
694
- }, className: "absolute inset-0 size-full cursor-pointer opacity-0", children: [_jsx("option", { value: "", children: tableTranslate?.("kumiko.list.sort.unsorted") ?? "Unsorted" }), sortOptions.map((o) => (_jsx("option", { value: o.value, children: o.label }, o.value)))] })] })), _jsx("ul", { className: "m-0 list-none border-t border-border-row p-0", children: tableItems.map((item) => item.kind === "group" ? (_jsx("li", { "data-testid": `row-group-${item.key}`, className: "border-b border-border-row bg-muted/40", children: renderGroupToggle(item) }, `group-${item.key}`)) : (renderCard(item.row))) })] }));
684
+ // URL state can carry a sort on a column that is not sortable; without an
685
+ // option for it the select would claim "Unsorted" while the list is sorted.
686
+ const currentSortValue = sort !== undefined && sort !== null ? `${sort.field}:${sort.dir}` : "";
687
+ const currentSortIsUnlisted = sort !== undefined && sort !== null && !sortOptions.some((o) => o.value === currentSortValue);
688
+ return (_jsx("div", { "data-testid": testId, className: "contents", children: _jsxs("div", { "data-testid": testId !== undefined ? `${testId}-cards` : "render-list-cards", className: "flex flex-col", children: [onSortChange !== undefined && sortableColumns.length > 0 && (_jsxs("div", { className: "relative flex h-9 items-center gap-1.5 px-4 text-[13px] text-foreground-secondary", children: [_jsx("span", { "aria-hidden": "true", children: sortLabel }), sort !== undefined && sort !== null && (_jsx(SortArrow, { className: "size-3.5", "aria-hidden": "true" })), _jsxs("select", { "aria-label": tableTranslate?.("kumiko.list.sort.label") ?? "Sort", "data-testid": testId !== undefined ? `${testId}-sort` : "render-list-sort", value: currentSortValue, onChange: (e) => {
689
+ const raw = e.target.value;
690
+ if (raw === "") {
691
+ onSortChange(null);
692
+ return;
693
+ }
694
+ const picked = sortOptions.find((o) => o.value === raw);
695
+ if (picked === undefined)
696
+ return;
697
+ onSortChange({ field: picked.field, dir: picked.dir });
698
+ }, className: "absolute inset-0 size-full cursor-pointer opacity-0", children: [_jsx("option", { value: "", children: tableTranslate?.("kumiko.list.sort.unsorted") ?? "Unsorted" }), currentSortIsUnlisted && (_jsx("option", { value: currentSortValue, children: `${columns.find((col) => col.field === sort.field)?.label ?? sort.field} ${sort.dir === "desc" ? "↓" : "↑"}` })), sortOptions.map((o) => (_jsx("option", { value: o.value, children: o.label }, o.value)))] })] })), _jsx("ul", { className: "m-0 list-none border-t border-border-row p-0", children: tableItems.map((item) => item.kind === "group" ? (_jsx("li", { "data-testid": `row-group-${item.key}`, className: "border-b border-border-row bg-muted/40", children: renderGroupToggle(item) }, `group-${item.key}`)) : (renderCard(item.row))) })] }) }));
695
699
  }
696
700
  const tableContent = isNarrow ? cardsInner() : tableInner();
697
701
  // Pager wird IMMER unter der Tabelle gerendert (auch bei rows=[]),
@@ -919,7 +923,7 @@ function RowActionsKebab({ row, actions, }) {
919
923
  }, className: cn(action.style === "danger" && "text-destructive focus:text-destructive"), children: action.label }, action.id))) })] }), pendingConfirm !== null && (_jsx(DefaultDialog, { open: true, onOpenChange: (open) => {
920
924
  if (!open)
921
925
  setPendingConfirm(null);
922
- }, title: pendingConfirm.label, ...(pendingConfirm.confirm !== undefined && { description: pendingConfirm.confirm }), confirmLabel: pendingConfirm.label, ...(pendingConfirm.style === "danger" && { variant: "danger" }), onConfirm: async () => {
926
+ }, title: pendingConfirm.label, ...(pendingConfirm.confirm !== undefined && { description: pendingConfirm.confirm }), confirmLabel: pendingConfirm.confirmLabel ?? pendingConfirm.label, ...(pendingConfirm.style === "danger" && { variant: "danger" }), onConfirm: async () => {
923
927
  const action = pendingConfirm;
924
928
  setPendingConfirm(null);
925
929
  await triggerNow(action);
@@ -1182,9 +1186,12 @@ function buildTableItems(rows, grouping, toggledGroups) {
1182
1186
  members.push(row);
1183
1187
  }
1184
1188
  return [...groups].flatMap(([key, members]) => {
1189
+ const label = grouping.headerLabel(key, members);
1190
+ if (label === undefined)
1191
+ return members.map((row) => ({ kind: "row", row }));
1185
1192
  const collapsed = grouping.startsCollapsed(key) !== toggledGroups.has(key);
1186
1193
  return [
1187
- { kind: "group", key, label: grouping.headerLabel(key, members), collapsed },
1194
+ { kind: "group", key, label, collapsed },
1188
1195
  ...(collapsed ? [] : members.map((row) => ({ kind: "row", row }))),
1189
1196
  ];
1190
1197
  });
@@ -1225,7 +1232,8 @@ function DataTableCell({ value, row, field, type, renderer, optionLabels, option
1225
1232
  return (_jsx("span", { "data-empty-cell": "true", className: "text-muted-foreground", children: EMPTY_CELL_PLACEHOLDER }));
1226
1233
  }
1227
1234
  if (typeof renderer === "object" && renderer !== null && "format" in renderer) {
1228
- return applyFormatSpec({ locale, ...renderer }, value, translate);
1235
+ const spec = renderer;
1236
+ return applyFormatSpec({ ...spec, locale: spec.locale ?? locale }, value, translate);
1229
1237
  }
1230
1238
  if (typeof renderer === "function") {
1231
1239
  const fn = renderer;
@@ -1293,9 +1301,10 @@ export function BareFormProvider({ children }) {
1293
1301
  return _jsx(BareFormContext.Provider, { value: true, children: children });
1294
1302
  }
1295
1303
  // App-wide default for FormScreenShell's width when a screen doesn't set its
1296
- // own `layout.width`. Default "4xl" keeps today's behavior for apps that
1297
- // don't opt into `createKumikoApp({ screenWidth })` (fw#2656).
1298
- const ScreenWidthContext = createContext("4xl");
1304
+ // own `layout.width`. Undefined (no `createKumikoApp({ screenWidth })`) lets each
1305
+ // shell keep its own default: "4xl" for FormScreenShell, 640px for the
1306
+ // screen-form column.
1307
+ const ScreenWidthContext = createContext(undefined);
1299
1308
  export function ScreenWidthProvider({ width, children, }) {
1300
1309
  return _jsx(ScreenWidthContext.Provider, { value: width, children: children });
1301
1310
  }
@@ -1307,6 +1316,12 @@ export function ScreenWidthProvider({ width, children, }) {
1307
1316
  // into the URL (fw#2312, fw#2705). Degrading to a <div> when already inside
1308
1317
  // a form avoids the nesting; the captured click still routes to THIS form's
1309
1318
  // onSubmit instead of activating the real ancestor <form>.
1319
+ const SUBMIT_CONTROL_SELECTOR = [
1320
+ "button[type=submit]:not(:disabled)",
1321
+ "button:not([type]):not(:disabled)",
1322
+ "input[type=submit]:not(:disabled)",
1323
+ "input[type=image]:not(:disabled)",
1324
+ ].join(", ");
1310
1325
  function FormRoot({ onSubmit, testId, className, children, }) {
1311
1326
  if (useContext(InsideFormContext)) {
1312
1327
  return (_jsx("div", { "data-kumiko-form-root": "", onKeyDownCapture: (e) => {
@@ -1327,7 +1342,7 @@ function FormRoot({ onSubmit, testId, className, children, }) {
1327
1342
  }, onClickCapture: (e) => {
1328
1343
  // @cast-boundary dom-event-target: closest() needs an Element, and
1329
1344
  // click targets are always one in the browser/happy-dom.
1330
- const submitButton = e.target.closest("button[type=submit], button:not([type])");
1345
+ const submitButton = e.target.closest(SUBMIT_CONTROL_SELECTOR);
1331
1346
  if (submitButton === null)
1332
1347
  return;
1333
1348
  // React events bubble through portals and nested degraded roots; a
@@ -1514,13 +1529,14 @@ function DrawerFormLayout({ onSubmit, subtitle, actions, secondaryActions, summa
1514
1529
  function DefaultForm({ onSubmit, children, title, subtitle, actions, secondaryActions, testId, width, stickyActions, headerRegion, titleAction, fillHeight, chromeless, screenForm, unsavedCount, sectionNav, sideRail, summary, }) {
1515
1530
  const insideDrawer = useInsideDrawer();
1516
1531
  const isEmbeddedForm = useContext(EmbeddedFormContext);
1532
+ const contextWidth = useContext(ScreenWidthContext);
1517
1533
  // Eingebettet (AuthCard etc.): nacktes <form>, gestapelte Felder mit gap —
1518
1534
  // der Container trägt Card/Titel selbst, sonst Card-in-Card.
1519
1535
  if (useContext(BareFormContext)) {
1520
1536
  return (_jsxs(FormRoot, { onSubmit: onSubmit, testId: testId, className: cn("flex flex-col gap-4",
1521
1537
  // Bare forms stack sections without a card; without a divider a
1522
1538
  // section boundary reads as a layout gap, not structure.
1523
- "[&>section:not(:first-child)]:border-t"), children: [_jsx(InsideFormContext.Provider, { value: true, children: children }), (secondaryActions !== undefined || actions !== undefined) && (_jsxs("div", { className: "flex items-center justify-end gap-2", children: [secondaryActions, actions] }))] }));
1539
+ "[&>section:not(:first-child)]:border-t"), children: [headerRegion, _jsx(InsideFormContext.Provider, { value: true, children: children }), (secondaryActions !== undefined || actions !== undefined) && (_jsxs("div", { className: "flex items-center justify-end gap-2", children: [secondaryActions, actions] }))] }));
1524
1540
  }
1525
1541
  if (insideDrawer) {
1526
1542
  return (_jsx(DrawerFormLayout, { onSubmit: onSubmit, subtitle: subtitle, actions: actions, secondaryActions: secondaryActions, summary: summary, testId: testId, children: children }));
@@ -1529,13 +1545,16 @@ function DefaultForm({ onSubmit, children, title, subtitle, actions, secondaryAc
1529
1545
  return (_jsxs(FormRoot, { onSubmit: onSubmit, testId: testId, className: "flex w-full flex-col gap-6", children: [_jsx(FormTitleBlock, { title: title, subtitle: subtitle, titleAction: titleAction, testId: testId, bordered: false, fillHeight: false }), _jsx(FormSections, { chromeless: true, stickyActions: undefined, fillHeight: false, screenForm: true, children: children }), (actions !== undefined || secondaryActions !== undefined) && (_jsx("div", { className: "max-w-4xl border-t border-border", children: _jsx(FormFooter, { actions: actions, secondaryActions: secondaryActions, testId: testId, chromeless: true, stickyActions: undefined, fillHeight: false, ...(unsavedCount !== undefined && { unsavedCount }) }) }))] }));
1530
1546
  }
1531
1547
  const isScreenForm = screenForm === true && fillHeight === true && chromeless !== true;
1548
+ const screenFormWidth = width ?? contextWidth;
1532
1549
  const sections = (_jsx(FormSections, { chromeless: chromeless === true, stickyActions: stickyActions, fillHeight: fillHeight, screenForm: isScreenForm, children: children }));
1533
1550
  const footer = (_jsx(FormFooter, { actions: actions, secondaryActions: secondaryActions, testId: testId, chromeless: chromeless === true, stickyActions: stickyActions, fillHeight: fillHeight, ...(unsavedCount !== undefined && { unsavedCount }), railed: sideRail !== undefined }));
1534
1551
  // Screen form: no card. A padded scroll surface holds the form column and,
1535
1552
  // from xl, the section navigation; the footer stays pinned below it.
1536
1553
  if (isScreenForm) {
1537
1554
  const showsSectionNav = sectionNav !== undefined && sectionNav.length >= SECTION_NAV_MIN_ITEMS;
1538
- return (_jsxs(FormRoot, { onSubmit: onSubmit, testId: testId, className: "flex h-full min-h-0 w-full flex-col", children: [_jsxs("div", { className: "flex min-h-0 flex-1 flex-col lg:flex-row", children: [sideRail, _jsx("div", { "data-testid": testId !== undefined ? `${testId}-scroll` : undefined, className: cn("min-h-0 flex-1 overflow-y-auto", screenPaddingClassName), children: _jsxs("div", { className: "flex gap-18", children: [_jsxs("div", { className: cn("flex w-full min-w-0 flex-col gap-9", width !== undefined ? screenFormColumnWidthClassName[width] : "max-w-[640px]"), children: [_jsx(InsideScreenFormColumnContext.Provider, { value: true, children: headerRegion }), _jsx(FormTitleBlock, { title: title, subtitle: subtitle, titleAction: titleAction, testId: testId, bordered: false, fillHeight: false }), sections] }), showsSectionNav && _jsx(FormSectionNav, { items: sectionNav })] }) })] }), footer] }));
1555
+ return (_jsxs(FormRoot, { onSubmit: onSubmit, testId: testId, className: "flex h-full min-h-0 w-full flex-col", children: [_jsxs("div", { className: "flex min-h-0 flex-1 flex-col lg:flex-row", children: [sideRail, _jsx("div", { "data-testid": testId !== undefined ? `${testId}-scroll` : undefined, className: cn("min-h-0 flex-1 overflow-y-auto", screenPaddingClassName), children: _jsxs("div", { className: "flex gap-18", children: [_jsxs("div", { className: cn("flex w-full min-w-0 flex-col gap-9", screenFormWidth !== undefined
1556
+ ? screenFormColumnWidthClassName[screenFormWidth]
1557
+ : "max-w-[640px]"), children: [_jsx(InsideScreenFormColumnContext.Provider, { value: true, children: headerRegion }), _jsx(FormTitleBlock, { title: title, subtitle: subtitle, titleAction: titleAction, testId: testId, bordered: false, fillHeight: false }), sections] }), showsSectionNav && _jsx(FormSectionNav, { items: sectionNav })] }) })] }), footer] }));
1539
1558
  }
1540
1559
  // Pinned footer (fillHeight + stickyActions): the footer is a full-bleed
1541
1560
  // sibling BELOW the padded shell, so the shell's bottom inset does not float
@@ -1569,7 +1588,7 @@ function DefaultForm({ onSubmit, children, title, subtitle, actions, secondaryAc
1569
1588
  }
1570
1589
  export function FormScreenShell({ children, className, testId, maxWidth, fillHeight, }) {
1571
1590
  const contextWidth = useContext(ScreenWidthContext);
1572
- const width = maxWidth ?? contextWidth;
1591
+ const width = maxWidth ?? contextWidth ?? "4xl";
1573
1592
  return (_jsx("div", { "data-testid": testId, className: cn(screenPaddingClassName, "w-full", screenWidthClassName[width], fillHeight === true && "h-full flex flex-col min-h-0", className), children: children }));
1574
1593
  }
1575
1594
  function DefaultSection({ id, title, subtitle, children, actions, variant = "default", layout = "stacked", testId, icon, }) {
@@ -1619,7 +1638,7 @@ const DRAWER_FIELD_CELL_WIDTH_CLASS = {
1619
1638
  };
1620
1639
  const FIELD_CELL_WIDTH_CLASS = {
1621
1640
  text: "w-full sm:w-60",
1622
- number: "w-fit min-w-24 shrink-0 [&_label]:whitespace-nowrap [&_input]:w-32",
1641
+ number: "w-fit min-w-24 shrink-0 [&_label]:whitespace-nowrap [&_[data-slot=number-field]]:w-32",
1623
1642
  money: "w-40 shrink-0 [&_label]:whitespace-nowrap",
1624
1643
  date: "w-full sm:w-[200px]",
1625
1644
  timestamp: "w-full sm:w-[328px]",
@@ -1656,7 +1675,7 @@ function DefaultGrid({ columns, children, testId, maxRows, flow, list, ariaLabel
1656
1675
  // CSS-Variable; Tailwind-Klasse liest die Variable mit
1657
1676
  // `grid-template-columns: var(--grid-cols)`. Saubere Lösung weil
1658
1677
  // Tailwind JIT keinen dynamischen `grid-cols-${N}` auflösen kann.
1659
- const rowCount = Math.ceil(Children.count(children) / columns);
1678
+ const rowCount = Math.ceil(Children.toArray(children).length / columns);
1660
1679
  const clipped = maxRows !== undefined && rowCount > maxRows;
1661
1680
  // gridAutoRows is a minmax MIN, not a fixed height, so wrapped labels grow
1662
1681
  // their row instead of being clipped mid-row. maxHeight is therefore only
@@ -1672,12 +1691,12 @@ function DefaultGrid({ columns, children, testId, maxRows, flow, list, ariaLabel
1672
1691
  "--grid-cols": `repeat(${columns}, minmax(0, 1fr))`,
1673
1692
  ...(clipped && {
1674
1693
  gridAutoRows: "minmax(var(--kumiko-grid-row-h, 2.5rem), auto)",
1675
- maxHeight: `calc(${maxRows} * var(--kumiko-grid-row-h, 2.5rem) + ${maxRows - 1} * 1rem)`,
1694
+ maxHeight: `calc(${maxRows} * var(--kumiko-grid-row-h, 2.5rem) + ${maxRows - 1} * var(--kumiko-grid-gap, 1rem))`,
1676
1695
  overflowY: "auto",
1677
1696
  }),
1678
1697
  // workaround: duplicate @types/react instances break direct CSSProperties cast
1679
1698
  };
1680
- return (_jsx("div", { ...groupProps, "data-testid": testId, className: "grid gap-4 grid-cols-1 sm:[grid-template-columns:var(--grid-cols)]", style: style, children: children }));
1699
+ return (_jsx("div", { ...groupProps, "data-testid": testId, className: "grid gap-[var(--kumiko-grid-gap,1rem)] grid-cols-1 sm:[grid-template-columns:var(--grid-cols)]", style: style, children: children }));
1681
1700
  }
1682
1701
  function DefaultGridCell({ span, width, children }) {
1683
1702
  const insideDrawerBody = useContext(DrawerBodyContext);
@@ -1824,6 +1843,13 @@ function QrSecretValue({ value }) {
1824
1843
  // create-app.tsx.
1825
1844
  function DefaultSecretReveal({ values, copyLabel, copiedLabel, testId, }) {
1826
1845
  const [copiedIndex, setCopiedIndex] = useState(null);
1846
+ // Labels are not unique per reveal; suffix repeats so React keys stay distinct.
1847
+ const seenLabels = new Map();
1848
+ const rowKeys = values.map((v) => {
1849
+ const occurrence = seenLabels.get(v.label) ?? 0;
1850
+ seenLabels.set(v.label, occurrence + 1);
1851
+ return `${v.label}#${occurrence}`;
1852
+ });
1827
1853
  return (_jsx("div", { "data-testid": testId, className: "flex flex-col gap-3", children: values.map((v, i) => (_jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx("span", { className: "text-sm font-medium text-muted-foreground", children: v.label }), _jsxs("div", { className: "flex items-start gap-2", children: [v.qr === true ? (_jsx(QrSecretValue, { value: v.value })) : v.multiline ? (_jsx("pre", { className: "flex-1 overflow-x-auto whitespace-pre-wrap break-all rounded bg-muted px-3 py-2 font-mono text-sm", children: v.value })) : (_jsx("code", { className: "flex-1 overflow-x-auto whitespace-pre-wrap break-all rounded bg-muted px-3 py-2 font-mono text-sm", children: v.value })), v.copyable && (_jsx(UiButton, { type: "button", variant: "outline", size: "sm", onClick: async () => {
1828
1854
  try {
1829
1855
  await navigator.clipboard.writeText(v.value);
@@ -1832,7 +1858,7 @@ function DefaultSecretReveal({ values, copyLabel, copiedLabel, testId, }) {
1832
1858
  catch {
1833
1859
  // clipboard blocked (non-secure context) — no fallback UI needed here
1834
1860
  }
1835
- }, children: copiedIndex === i ? copiedLabel : copyLabel }))] })] }, v.label))) }));
1861
+ }, children: copiedIndex === i ? copiedLabel : copyLabel }))] })] }, rowKeys[i]))) }));
1836
1862
  }
1837
1863
  export const defaultPrimitives = {
1838
1864
  Button: DefaultButton,
@@ -1,9 +1,12 @@
1
- import type { LiveEventSubscriber } from "@cosmicdrift/kumiko-renderer";
1
+ import { type LiveEventSubscriber } from "@cosmicdrift/kumiko-renderer";
2
2
  export type CreateEventSourceLiveEventsOptions = {
3
3
  /** URL des SSE-Endpoints. Default: /api/sse (das ist wo
4
4
  * createSseRoute im Kumiko-Server mountet). Override wenn der
5
5
  * Mountpath divergiert. */
6
6
  readonly url?: string;
7
+ /** Fires when the server refuses the SSE handshake because the session
8
+ * ended (same 401 codes as the dispatcher path). */
9
+ readonly onSessionEnded?: () => void;
7
10
  };
8
11
  /** Liefert einen `LiveEventSubscriber` der EventSource-backed ist.
9
12
  * Normalerweise einmal im App-Bootstrap gerufen und als value an
@@ -1,3 +1,35 @@
1
+ import { isSessionEndedError, } from "@cosmicdrift/kumiko-renderer";
2
+ function hasErrorCode(body) {
3
+ if (typeof body !== "object" || body === null || !("error" in body))
4
+ return false;
5
+ const { error } = body;
6
+ return (typeof error === "object" && error !== null && "code" in error && typeof error.code === "string");
7
+ }
8
+ // EventSource never exposes the HTTP status, so one plain fetch of the same
9
+ // URL tells a session end apart from an outage. Aborted right after the
10
+ // headers/body so a 200 doesn't hold a second stream open.
11
+ async function isSessionEndedRejection(url) {
12
+ const controller = new AbortController();
13
+ try {
14
+ // guard-allow: same-origin fetch
15
+ const res = await fetch(url, {
16
+ credentials: "same-origin",
17
+ headers: { Accept: "text/event-stream" },
18
+ signal: controller.signal,
19
+ });
20
+ if (res.status !== 401)
21
+ return false;
22
+ const body = await res.json();
23
+ return hasErrorCode(body) && isSessionEndedError({ code: body.error.code, httpStatus: 401 });
24
+ }
25
+ catch {
26
+ // skip: probe failed (offline, unparseable body) — not evidence of a session end
27
+ return false;
28
+ }
29
+ finally {
30
+ controller.abort();
31
+ }
32
+ }
1
33
  /** Liefert einen `LiveEventSubscriber` der EventSource-backed ist.
2
34
  * Normalerweise einmal im App-Bootstrap gerufen und als value an
3
35
  * `<LiveEventsProvider>` durchgereicht — createKumikoApp tut das. */
@@ -32,7 +64,22 @@ export function createEventSourceLiveEvents(options = {}) {
32
64
  // skip: no window/EventSource available (SSR/non-browser), nothing to connect
33
65
  if (typeof window === "undefined" || typeof EventSource === "undefined")
34
66
  return;
35
- source = new EventSource(url);
67
+ const connection = new EventSource(url);
68
+ source = connection;
69
+ const { onSessionEnded } = options;
70
+ // skip: no session-end consumer, a refused handshake needs no probe
71
+ if (onSessionEnded === undefined)
72
+ return;
73
+ connection.addEventListener("error", () => {
74
+ // skip: CONNECTING means a dropped stream the browser retries itself;
75
+ // only a refused handshake (non-200) closes the EventSource for good.
76
+ if (connection.readyState !== EventSource.CLOSED)
77
+ return;
78
+ void isSessionEndedRejection(url).then((sessionEnded) => {
79
+ if (sessionEnded)
80
+ onSessionEnded();
81
+ });
82
+ });
36
83
  };
37
84
  const ensureListenersForEntity = (entityName) => {
38
85
  // skip: not connected yet, listeners get wired once ensureConnected runs
@@ -107,6 +107,10 @@ export function Drawer({ open, onOpenChange, side = "right", title, description,
107
107
  const verticalOffsetStyle = belowHeader && variant === "flush" && !narrow && (side === "left" || side === "right")
108
108
  ? { top: "var(--shell-header-height)", bottom: 0 }
109
109
  : undefined;
110
+ const widthStyle = canResize && !narrow ? { width: effectiveWidthPx, maxWidth: "none" } : customWidthStyle;
111
+ const sheetStyle = widthStyle === undefined && verticalOffsetStyle === undefined
112
+ ? undefined
113
+ : { ...widthStyle, ...verticalOffsetStyle };
110
114
  const handleDrag = usePointerDrag({
111
115
  onStart: () => {
112
116
  setMaximized(false);
@@ -134,10 +138,5 @@ export function Drawer({ open, onOpenChange, side = "right", title, description,
134
138
  // Radix already drops the overlay and the focus trap for a
135
139
  // non-modal Root; only the dismiss-on-outside-interaction is left
136
140
  // to suppress, so a click into the page behind keeps the panel open.
137
- onInteractOutside: modal ? undefined : (event) => event.preventDefault(), showCloseButton: showCloseButton, className: cn(sidePanelClass(side, narrow, variant, belowHeader), fillBody && "gap-0", !narrow && panelClassName), style: {
138
- ...(canResize && !narrow
139
- ? { width: effectiveWidthPx, maxWidth: "none" }
140
- : customWidthStyle),
141
- ...verticalOffsetStyle,
142
- }, children: [canResize && !narrow && (_jsx("button", { type: "button", onClick: () => setMaximized((m) => !m), "aria-pressed": maximized, "aria-label": maximized ? t("kumiko.widget.drawer.restore") : t("kumiko.widget.drawer.maximize"), className: "absolute top-4 right-14 z-10 rounded-xs p-1 text-muted-foreground opacity-70 transition-opacity hover:opacity-100 hover:bg-secondary focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-hidden", children: maximized ? (_jsx(Minimize2Icon, { className: "size-4" })) : (_jsx(Maximize2Icon, { className: "size-4" })) })), (title !== undefined || description !== undefined) && (_jsxs(SheetHeader, { children: [title !== undefined && _jsx(SheetTitle, { children: title }), description !== undefined && _jsx(SheetDescription, { children: description })] })), _jsx("div", { className: fillBody ? "flex min-h-0 flex-1 flex-col" : "flex-1 overflow-y-auto px-4", children: children }), footer !== undefined && (_jsx(SheetFooter, { className: DRAWER_FOOTER_CLASS, children: footer })), canResize && !narrow && (_jsx("div", { role: "separator", "aria-orientation": "vertical", "aria-label": t("kumiko.widget.drawer.resize"), "aria-valuenow": effectiveWidthPx, "aria-valuemin": minWidthPx, "aria-valuemax": effectiveMaxWidthPx(), tabIndex: 0, ...handleDrag, onKeyDown: onHandleKeyDown, className: cn("absolute inset-y-0 z-10 w-1 cursor-col-resize touch-none after:absolute after:inset-y-0 after:left-1/2 after:w-3 after:-translate-x-1/2 hover:bg-border", side === "right" ? "left-0 -translate-x-1/2" : "right-0 translate-x-1/2") }))] }) }));
141
+ onInteractOutside: modal ? undefined : (event) => event.preventDefault(), showCloseButton: showCloseButton, className: cn(sidePanelClass(side, narrow, variant, belowHeader), fillBody && "gap-0", !narrow && panelClassName), style: sheetStyle, children: [canResize && !narrow && (_jsx("button", { type: "button", onClick: () => setMaximized((m) => !m), "aria-pressed": maximized, "aria-label": maximized ? t("kumiko.widget.drawer.restore") : t("kumiko.widget.drawer.maximize"), className: "absolute top-4 right-14 z-10 rounded-xs p-1 text-muted-foreground opacity-70 transition-opacity hover:opacity-100 hover:bg-secondary focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-hidden", children: maximized ? (_jsx(Minimize2Icon, { className: "size-4" })) : (_jsx(Maximize2Icon, { className: "size-4" })) })), (title !== undefined || description !== undefined) && (_jsxs(SheetHeader, { children: [title !== undefined && _jsx(SheetTitle, { children: title }), description !== undefined && _jsx(SheetDescription, { children: description })] })), _jsx("div", { className: fillBody ? "flex min-h-0 flex-1 flex-col" : "flex-1 overflow-y-auto px-4", children: children }), footer !== undefined && (_jsx(SheetFooter, { className: DRAWER_FOOTER_CLASS, children: footer })), canResize && !narrow && (_jsx("div", { role: "separator", "aria-orientation": "vertical", "aria-label": t("kumiko.widget.drawer.resize"), "aria-valuenow": effectiveWidthPx, "aria-valuemin": minWidthPx, "aria-valuemax": effectiveMaxWidthPx(), tabIndex: 0, ...handleDrag, onKeyDown: onHandleKeyDown, className: cn("absolute inset-y-0 z-10 w-1 cursor-col-resize touch-none after:absolute after:inset-y-0 after:left-1/2 after:w-3 after:-translate-x-1/2 hover:bg-border", side === "right" ? "left-0 -translate-x-1/2" : "right-0 translate-x-1/2") }))] }) }));
143
142
  }
@@ -42,6 +42,7 @@ const EDGE_LABEL_KEYS = {
42
42
  bottom: "kumiko.widget.floatingPanel.resizeBottom",
43
43
  left: "kumiko.widget.floatingPanel.resizeLeft",
44
44
  };
45
+ const DRAG_HANDLE_SELECTOR = "[data-drag-handle]";
45
46
  const INTERACTIVE_HEADER_TARGET = "button, a, input, select, textarea, [role=button]";
46
47
  function readLimits(options) {
47
48
  const viewportWidth = window.innerWidth;
@@ -182,8 +183,11 @@ export function FloatingPanel({ title, headerActions, children, storageKey, defa
182
183
  // React events from portalled children (menus) bubble through the header although their DOM is elsewhere.
183
184
  if (!(event.target instanceof Node) || !event.currentTarget.contains(event.target))
184
185
  return;
185
- if (event.target instanceof Element && event.target.closest(INTERACTIVE_HEADER_TARGET))
186
+ if (event.target instanceof Element &&
187
+ event.target.closest(INTERACTIVE_HEADER_TARGET) &&
188
+ !event.target.closest(DRAG_HANDLE_SELECTOR)) {
186
189
  return;
190
+ }
187
191
  moveDrag.onPointerDown(event);
188
192
  };
189
193
  const onGripKeyDown = (event) => {
@@ -212,7 +216,7 @@ export function FloatingPanel({ title, headerActions, children, storageKey, defa
212
216
  top: geometry.y,
213
217
  width: geometry.width,
214
218
  height: geometry.height,
215
- }, children: [_jsxs("div", { ...moveDrag, onPointerDown: onHeaderPointerDown, className: "flex shrink-0 cursor-grab touch-none select-none items-center gap-2 border-b bg-muted px-3 py-2 active:cursor-grabbing", children: [_jsx("button", { type: "button", "aria-label": t("kumiko.widget.floatingPanel.move"), onKeyDown: onGripKeyDown, className: "rounded-xs p-1 text-muted-foreground hover:bg-secondary focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-hidden", children: _jsx(GripVerticalIcon, { className: "size-4" }) }), _jsx("div", { id: titleId, className: "min-w-0 flex-1 truncate font-medium", children: title }), headerActions] }), _jsx("div", { className: "min-h-0 flex-1 overflow-auto p-4", children: children }), EDGES.map((edge) => (_jsx(PanelResizeHandle, { edges: [edge], className: EDGE_HANDLE_CLASS[edge], context: handleContext, separator: { label: t(EDGE_LABEL_KEYS[edge]), geometry, limits } }, edge))), CORNER_HANDLES.map((corner) => (_jsx(PanelResizeHandle, { edges: corner.edges, className: corner.className, context: handleContext }, corner.edges.join("-"))))] }), document.body);
219
+ }, children: [_jsxs("div", { ...moveDrag, onPointerDown: onHeaderPointerDown, className: "flex shrink-0 cursor-grab touch-none select-none items-center gap-2 border-b bg-muted px-3 py-2 active:cursor-grabbing", children: [_jsx("button", { type: "button", "data-drag-handle": "", "aria-label": t("kumiko.widget.floatingPanel.move"), onKeyDown: onGripKeyDown, className: "rounded-xs p-1 text-muted-foreground hover:bg-secondary focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-hidden", children: _jsx(GripVerticalIcon, { className: "size-4" }) }), _jsx("div", { id: titleId, className: "min-w-0 flex-1 truncate font-medium", children: title }), headerActions] }), _jsx("div", { className: "min-h-0 flex-1 overflow-auto p-4", children: children }), EDGES.map((edge) => (_jsx(PanelResizeHandle, { edges: [edge], className: EDGE_HANDLE_CLASS[edge], context: handleContext, separator: { label: t(EDGE_LABEL_KEYS[edge]), geometry, limits } }, edge))), CORNER_HANDLES.map((corner) => (_jsx(PanelResizeHandle, { edges: corner.edges, className: corner.className, context: handleContext }, corner.edges.join("-"))))] }), document.body);
216
220
  }
217
221
  function PanelResizeHandle({ edges, className, context, separator, }) {
218
222
  const drag = usePointerDrag({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer-web",
3
- "version": "0.336.1",
3
+ "version": "0.338.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.336.1",
24
- "@cosmicdrift/kumiko-framework": "0.336.1",
25
- "@cosmicdrift/kumiko-headless": "0.336.1",
26
- "@cosmicdrift/kumiko-renderer": "0.336.1",
27
- "@cosmicdrift/kumiko-types": "0.336.1",
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",
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.336.1"
75
+ "@cosmicdrift/kumiko-locale-de": "0.338.0"
76
76
  },
77
77
  "repository": {
78
78
  "type": "git",
package/src/changes.json CHANGED
@@ -1,4 +1,41 @@
1
1
  [
2
+ {
3
+ "version": "0.337.0",
4
+ "type": "fix",
5
+ "title": "A number field's unit stays inside the field when the label is wider than the input",
6
+ "detail": "Icon, input and unit of a `kind: \"number\"` input share one box (`data-slot=\"number-field\"`), and the form grid's number cell sizes that box to 8rem instead of the bare input. Before, a label wider than the input (a long label, or the \"changed\" marker appearing while editing) widened the cell, and the unit was anchored to the cell's right edge, next to the field.",
7
+ "migration": "No code change needed. Custom CSS that sized number inputs through `[&_input]` inside the number cell targets `[data-slot=number-field]` now."
8
+ },
9
+ {
10
+ "version": "0.337.0",
11
+ "type": "fix",
12
+ "title": "A rejected SSE connection ends the web session",
13
+ "detail": "When the server refuses the live-events EventSource with a session 401, createKumikoApp now raises the same session-ended signal as a 401 from the dispatcher, so the user sees the session-end notice instead of silently losing live updates."
14
+ },
15
+ {
16
+ "version": "0.337.0",
17
+ "type": "fix",
18
+ "title": "Kebab confirmLabel, cell locale fallback, card-list sort select and bare-form headerRegion",
19
+ "detail": "The row-actions kebab confirm dialog uses `confirmLabel` like the inline path. A list cell with `renderer.locale` explicitly set to `undefined` falls back to the app locale. The narrow-viewport card list keeps the bare `testId` on a wrapper, and its sort select shows a sort that targets a non-sortable column instead of \"Unsorted\". A bare form renders its `headerRegion`. The dashboard time range no longer needs a type assertion."
20
+ },
21
+ {
22
+ "version": "0.337.0",
23
+ "type": "fix",
24
+ "title": "FloatingPanel grip pointer drag, Drawer style undefined, resize bitmap release, nav action screen+target warning",
25
+ "detail": "The FloatingPanel grip button drags the panel with the pointer again. Drawer passes `style` as `undefined` when no width or offset applies. Image resize releases the decoded bitmap when no 2d context is available. Nav tree actions that set both `screen` and `target` warn once that `target` is ignored."
26
+ },
27
+ {
28
+ "version": "0.337.0",
29
+ "type": "fix",
30
+ "title": "Secret reveal keys, nested-form input submit routing, Grid maxRows/gap, RTL card meta, browser QR import",
31
+ "detail": "Secret reveal rows with equal labels get distinct React keys. A nested form's submit routing also covers `input[type=submit]` and `input[type=image]`. `Grid` ignores null and boolean children when computing `maxRows`, and its gap is the themeable `--kumiko-grid-gap` variable shared with the clip height. Card meta rows clip at the inline start so RTL layouts hide the leading separator too. The QR secret value imports `qrcode/lib/browser.js` so Metro does not pull Node-only dependencies."
32
+ },
33
+ {
34
+ "version": "0.337.0",
35
+ "type": "fix",
36
+ "title": "screenWidth setting now widens the default entityEdit form too",
37
+ "detail": "`createKumikoApp({ screenWidth })` now also applies to the default entityEdit form, which previously stayed at a fixed 640px column. Without the setting the column keeps its 640px width."
38
+ },
2
39
  {
3
40
  "version": "0.336.0",
4
41
  "type": "improvement",
package/src/styles.css CHANGED
@@ -278,6 +278,7 @@
278
278
 
279
279
  :root {
280
280
  --kumiko-grid-row-h: 2.5rem;
281
+ --kumiko-grid-gap: 1rem;
281
282
  /* 0 = no ShellHeader mounted (e.g. a Drawer used outside a shell) — a
282
283
  consumer offsetting by this variable then gets today's edge-to-edge
283
284
  behavior for free. */