@eifi1/ui-kit 0.51.5 → 0.51.7

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.
Files changed (129) hide show
  1. package/README.md +9 -7
  2. package/dist/admin/admin-action-confirm.js +2 -4
  3. package/dist/admin/admin-action-confirm.js.map +1 -1
  4. package/dist/auth/complete-name-dialog.d.ts +3 -2
  5. package/dist/auth/complete-name-dialog.js +1 -4
  6. package/dist/auth/complete-name-dialog.js.map +1 -1
  7. package/dist/billing/cancel-request.d.ts +11 -2
  8. package/dist/billing/cancel-request.js +15 -3
  9. package/dist/billing/cancel-request.js.map +1 -1
  10. package/dist/billing/cancel-subscription.d.ts +2 -2
  11. package/dist/billing/cancel-subscription.js +2 -7
  12. package/dist/billing/cancel-subscription.js.map +1 -1
  13. package/dist/billing/plan-switch.d.ts +2 -2
  14. package/dist/billing/plan-switch.js +2 -4
  15. package/dist/billing/plan-switch.js.map +1 -1
  16. package/dist/components/amount-input.d.ts +9 -4
  17. package/dist/components/amount-input.js +2 -3
  18. package/dist/components/amount-input.js.map +1 -1
  19. package/dist/components/calculator.d.ts +21 -2
  20. package/dist/components/calculator.js +53 -14
  21. package/dist/components/calculator.js.map +1 -1
  22. package/dist/components/combobox-core.d.ts +8 -5
  23. package/dist/components/combobox-core.js +3 -4
  24. package/dist/components/combobox-core.js.map +1 -1
  25. package/dist/components/combobox.js +2 -2
  26. package/dist/components/combobox.js.map +1 -1
  27. package/dist/components/data-table-mobile.d.ts +24 -0
  28. package/dist/components/data-table-mobile.js +20 -1
  29. package/dist/components/data-table-mobile.js.map +1 -1
  30. package/dist/components/data-table.d.ts +13 -5
  31. package/dist/components/data-table.js +35 -9
  32. package/dist/components/data-table.js.map +1 -1
  33. package/dist/components/dialog-frame.d.ts +2 -1
  34. package/dist/components/dialog-frame.js.map +1 -1
  35. package/dist/components/entity-combobox.js +4 -2
  36. package/dist/components/entity-combobox.js.map +1 -1
  37. package/dist/components/inline-entity-combobox.js +4 -3
  38. package/dist/components/inline-entity-combobox.js.map +1 -1
  39. package/dist/components/list.js +1 -1
  40. package/dist/components/list.js.map +1 -1
  41. package/dist/components/modal.d.ts +24 -0
  42. package/dist/components/modal.js +9 -3
  43. package/dist/components/modal.js.map +1 -1
  44. package/dist/components/money-draft.js +7 -5
  45. package/dist/components/money-draft.js.map +1 -1
  46. package/dist/components/money-field.d.ts +2 -2
  47. package/dist/components/money-field.js.map +1 -1
  48. package/dist/components/multi-entity-combobox.js +2 -1
  49. package/dist/components/multi-entity-combobox.js.map +1 -1
  50. package/dist/components/number-field.d.ts +6 -4
  51. package/dist/components/number-field.js +3 -2
  52. package/dist/components/number-field.js.map +1 -1
  53. package/dist/components/number-input.d.ts +4 -1
  54. package/dist/components/number-input.js +3 -2
  55. package/dist/components/number-input.js.map +1 -1
  56. package/dist/components/numpad-sheet.js.map +1 -1
  57. package/dist/components/reauth-dialog.d.ts +4 -5
  58. package/dist/components/reauth-dialog.js +1 -4
  59. package/dist/components/reauth-dialog.js.map +1 -1
  60. package/dist/components/series-chart-axes.d.ts +7 -0
  61. package/dist/components/series-chart-axes.js +1 -0
  62. package/dist/components/series-chart-axes.js.map +1 -1
  63. package/dist/components/tabs.js +12 -10
  64. package/dist/components/tabs.js.map +1 -1
  65. package/dist/components/treemap.d.ts +24 -2
  66. package/dist/components/treemap.js +40 -4
  67. package/dist/components/treemap.js.map +1 -1
  68. package/dist/eslint/index.js +1 -1
  69. package/dist/hooks/use-dismiss.js.map +1 -1
  70. package/dist/hooks/use-overlay-history.d.ts +7 -1
  71. package/dist/hooks/use-overlay-history.js +16 -2
  72. package/dist/hooks/use-overlay-history.js.map +1 -1
  73. package/dist/lib/calc.d.ts +7 -1
  74. package/dist/lib/calc.js +3 -1
  75. package/dist/lib/calc.js.map +1 -1
  76. package/dist/lib/clipboard-minus.d.ts +2 -2
  77. package/dist/lib/clipboard-minus.js +1 -1
  78. package/dist/lib/clipboard-minus.js.map +1 -1
  79. package/dist/lib/decimal-marks.d.ts +7 -1
  80. package/dist/lib/decimal-marks.js +5 -2
  81. package/dist/lib/decimal-marks.js.map +1 -1
  82. package/dist/lib/format.d.ts +5 -3
  83. package/dist/lib/format.js +12 -0
  84. package/dist/lib/format.js.map +1 -1
  85. package/dist/lib/text-match.d.ts +9 -0
  86. package/dist/lib/text-match.js +4 -0
  87. package/dist/lib/text-match.js.map +1 -1
  88. package/dist/theme/contrast.d.ts +2 -2
  89. package/dist/tour/tour.d.ts +3 -1
  90. package/dist/tour/tour.js +9 -3
  91. package/dist/tour/tour.js.map +1 -1
  92. package/dist/tour/use-tour-url-sync.js +2 -2
  93. package/dist/tour/use-tour-url-sync.js.map +1 -1
  94. package/package.json +3 -3
  95. package/src/admin/admin-action-confirm.tsx +4 -5
  96. package/src/auth/complete-name-dialog.tsx +5 -7
  97. package/src/billing/cancel-request.ts +28 -6
  98. package/src/billing/cancel-subscription.tsx +4 -11
  99. package/src/billing/plan-switch.tsx +5 -8
  100. package/src/components/amount-input.tsx +14 -7
  101. package/src/components/calculator.tsx +98 -17
  102. package/src/components/combobox-core.tsx +10 -7
  103. package/src/components/combobox.tsx +5 -6
  104. package/src/components/data-table-mobile.tsx +49 -1
  105. package/src/components/data-table.tsx +66 -11
  106. package/src/components/dialog-frame.tsx +2 -1
  107. package/src/components/entity-combobox.tsx +4 -1
  108. package/src/components/inline-entity-combobox.tsx +5 -3
  109. package/src/components/list.tsx +4 -1
  110. package/src/components/modal.tsx +39 -3
  111. package/src/components/money-draft.ts +16 -5
  112. package/src/components/money-field.tsx +2 -2
  113. package/src/components/multi-entity-combobox.tsx +1 -0
  114. package/src/components/number-field.tsx +14 -7
  115. package/src/components/number-input.tsx +9 -3
  116. package/src/components/numpad-sheet.tsx +4 -3
  117. package/src/components/reauth-dialog.tsx +6 -10
  118. package/src/components/series-chart-axes.ts +8 -0
  119. package/src/components/tabs.tsx +33 -17
  120. package/src/components/treemap.tsx +69 -7
  121. package/src/hooks/use-dismiss.ts +3 -2
  122. package/src/hooks/use-overlay-history.ts +38 -2
  123. package/src/lib/calc.ts +13 -2
  124. package/src/lib/clipboard-minus.ts +14 -4
  125. package/src/lib/decimal-marks.ts +12 -3
  126. package/src/lib/format.ts +30 -3
  127. package/src/lib/text-match.ts +12 -0
  128. package/src/tour/tour.tsx +21 -4
  129. package/src/tour/use-tour-url-sync.ts +7 -3
@@ -2,7 +2,7 @@ import { useLayoutEffect, useMemo, useRef } from "react";
2
2
  import { ChevronLeft, ChevronRight, Plus, X } from "lucide-react";
3
3
  import type { ComponentPropsWithoutRef, KeyboardEvent, ReactNode, Ref } from "react";
4
4
  import { cn } from "../lib/cn";
5
- import { FOCUS_RING_WIDTH } from "./focus-ring";
5
+ import { FOCUS_RING } from "./focus-ring";
6
6
  import { scrollIntoStrip, useStripFade } from "../lib/strip-fade";
7
7
  import { horizontalStep } from "../lib/direction";
8
8
  import { usePhoneLayout } from "../hooks/use-breakpoint";
@@ -205,9 +205,7 @@ const TABLIST_CLASSES = "flex gap-1 overflow-x-auto overflow-y-hidden border-b b
205
205
  // selection. `--bg-active` is the kit's own selected-row fill (the sidebar's), so the
206
206
  // admin page's nav and the app's own sidebar mark "you are here" the same way.
207
207
  const TABLIST_VERTICAL_CLASSES = "flex flex-col gap-0.5";
208
- const TAB_VERTICAL_CLASSES =
209
- FOCUS_RING_WIDTH +
210
- " flex w-full items-center rounded-md px-3 py-2 text-start text-sm font-medium transition-colors focus:outline-none focus-visible:ring-strong";
208
+ const TAB_VERTICAL_CLASSES = `flex w-full items-center rounded-md px-3 py-2 text-start text-sm font-medium transition-colors focus:outline-none ${FOCUS_RING}`;
211
209
  const TAB_VERTICAL_ACTIVE_CLASSES = "bg-active text-primary";
212
210
  const TAB_VERTICAL_INACTIVE_CLASSES = "text-muted hover:bg-hover hover:text-primary";
213
211
 
@@ -215,9 +213,14 @@ const TAB_VERTICAL_INACTIVE_CLASSES = "text-muted hover:bg-hover hover:text-prim
215
213
  // links · … · 31 Punkte") made one tab 448px wide in a 390px strip, and a tab wider
216
214
  // than the strip cannot be scrolled into view — its end was simply off screen
217
215
  // (lenkbank L3). The label and detail truncate inside it instead; see `inner`.
218
- const TAB_CLASSES =
219
- FOCUS_RING_WIDTH +
220
- " max-w-full whitespace-nowrap px-3 py-2 text-sm font-medium transition-colors border-b-2 -mb-px focus:outline-none focus-visible:ring-strong";
216
+ //
217
+ // Keyboard focus on a tab, a side-nav row and the add button is the kit's one focus frame
218
+ // (text-size §5), in brand. Until 0.51.7 it rang in `ring-strong`: 2.54:1 on the light
219
+ // page and 2.76:1 on a card, under WCAG 1.4.11's 3:1 (refactor audit 2026-10-30, DSP-8;
220
+ // NavPills made the same move in 0.34, DOC-7). INSET on a tab: the strip scrolls
221
+ // (`overflow-x-auto overflow-y-hidden`), so a ring outside the tab is cut off along its
222
+ // top and bottom, and at a cut end.
223
+ const TAB_CLASSES = `max-w-full whitespace-nowrap px-3 py-2 text-sm font-medium transition-colors border-b-2 -mb-px focus:outline-none ${FOCUS_RING} focus-visible:ring-inset`;
221
224
  const TAB_ACTIVE_CLASSES = "border-[var(--text-primary)] text-primary";
222
225
  const TAB_INACTIVE_CLASSES = "border-transparent text-muted hover:text-secondary hover:border-strong";
223
226
 
@@ -225,9 +228,7 @@ const TAB_INACTIVE_CLASSES = "border-transparent text-muted hover:text-secondary
225
228
  // `onRemove`).
226
229
  const TAB_REMOVABLE_CLASSES = "pe-8";
227
230
  const TAB_EMPTY_CLASSES = "-mx-1.5 rounded border border-dashed border-strong px-1.5";
228
- const TAB_ADD_CLASSES =
229
- FOCUS_RING_WIDTH +
230
- " inline-flex items-center gap-1 whitespace-nowrap rounded-md px-3 py-2 text-sm font-medium text-muted transition-colors hover:text-primary focus:outline-none focus-visible:ring-strong disabled:cursor-not-allowed disabled:opacity-50 [&_svg]:size-4";
231
+ const TAB_ADD_CLASSES = `inline-flex items-center gap-1 whitespace-nowrap rounded-md px-3 py-2 text-sm font-medium text-muted transition-colors hover:text-primary focus:outline-none ${FOCUS_RING} disabled:cursor-not-allowed disabled:opacity-50 [&_svg]:size-4`;
231
232
  // `relative`: the phone's visually hidden label needs a positioned ancestor (see the
232
233
  // sr-only rule in CONTRIBUTING); `max-md:px-2` squares the lone "+".
233
234
  const TAB_ADD_PHONE_CLASSES = "relative max-md:px-2";
@@ -262,6 +263,8 @@ export function Tabs<T extends string>({
262
263
  const cue = overflowCue && !vertical;
263
264
  const cueStart = cue && (fade.overflow === "start" || fade.overflow === "both");
264
265
  const cueEnd = cue && (fade.overflow === "end" || fade.overflow === "both");
266
+ // The cue gutters (see `stripBox`): there only while the strip overflows at all.
267
+ const gutters = cue && fade.overflow !== undefined;
265
268
  // Three quarters of the visible width toward `side`; an RTL strip runs leftward.
266
269
  const onCue = (side: "start" | "end") => {
267
270
  const strip = stripRef.current;
@@ -333,15 +336,25 @@ export function Tabs<T extends string>({
333
336
  // "80 km," under the add button. Only the strip scrolls (`scrollBy` on it, not
334
337
  // `scrollIntoView`, which would scroll the page as well), and only as far as needed
335
338
  // to clear the fade.
339
+ //
340
+ // And again when the cue gutters appear (refactor audit 2026-10-30, DSP-16). They come a
341
+ // render after the strip is first measured as overflowing — on mount, or with the very
342
+ // tab that tipped it over — and take 24px off each end, so a tab scrolled clear before
343
+ // them ended 24px past the visible end, under the fade and the ›.
336
344
  useLayoutEffect(() => {
337
345
  const strip = stripRef.current;
338
346
  const tab = strip?.querySelector<HTMLElement>('[role="tab"][aria-selected="true"]');
339
347
  if (strip && tab) scrollIntoStrip(strip, tab);
340
- }, [active]);
348
+ }, [active, gutters]);
341
349
  const isRemovable = (tab: TabItem<T>) => onRemove !== undefined && tab.removable !== false;
342
350
  // Whether Delete reaches this tab: every removable tab, or only the open one.
343
351
  const deletesOnKey = (tab: TabItem<T>) => isRemovable(tab) && (removeOn === "every" || tab.id === active);
344
352
  const nameOf = (tab: TabItem<T>) => tab.name ?? (typeof tab.label === "string" ? tab.label : tab.id);
353
+ // The strip's one tab stop (see `tabIndex` below): the open tab, or the first while
354
+ // `active` names none. "No sheet open" is a real state — KS's sheet strips pass `""` —
355
+ // and with the stop on the selection alone every tab was -1, so the keyboard could not
356
+ // reach the one control that opens a sheet (refactor audit 2026-10-30, DSP-1).
357
+ const stop = tabs.some((t) => t.id === active) ? active : tabs[0]?.id;
345
358
 
346
359
  // Where focus goes once a removal the strip asked for has happened. The caller
347
360
  // owns `tabs` and may take its time (a confirmation, a request), so this waits
@@ -370,10 +383,14 @@ export function Tabs<T extends string>({
370
383
  if (focused && focused !== document.body && !strip.contains(focused) && focused !== removeRef.current) return;
371
384
  // The removed tab was open: focus follows the caller's new selection, which is
372
385
  // also the strip's tab stop. It was not: its neighbour, as the ARIA pattern has it.
373
- const target = !pending.wasActive && tabs.some((t) => t.id === pending.neighbour) ? pending.neighbour : active;
386
+ // The caller opened nothing in its place (KS drops the sheet and the selection in one
387
+ // render): the neighbour too, else the stop — not an index of -1 and the page (DSP-1).
388
+ const present = (id: T | undefined) => tabs.some((t) => t.id === id);
389
+ const target =
390
+ pending.wasActive && present(active) ? active : present(pending.neighbour) ? pending.neighbour : stop;
374
391
  const index = tabs.findIndex((t) => t.id === target);
375
392
  strip.querySelectorAll<HTMLElement>('[role="tab"]')[index]?.focus();
376
- }, [tabs, active]);
393
+ }, [tabs, active, stop]);
377
394
 
378
395
  // Arrow keys walk the strip in DOM order (ARIA tabs pattern). Home/End jump
379
396
  // to the ends. Focus only — activation stays on click/Enter/Space, because a tab
@@ -462,8 +479,8 @@ export function Tabs<T extends string>({
462
479
  // strip back on the next visit in a state they never chose. The cost is that
463
480
  // Tab out and back returns to the open tab rather than to the last one
464
481
  // looked at; the ARIA pattern allows either, and this one needs no second
465
- // piece of state shadowing `active`.
466
- tabIndex: isActive ? 0 : -1,
482
+ // piece of state shadowing `active`. With nothing open, the first tab (`stop`).
483
+ tabIndex: tab.id === stop ? 0 : -1,
467
484
  // Only the open tab: see `panelId`.
468
485
  "aria-controls": isActive ? panelId : undefined,
469
486
  id: isActive && panelId ? `${panelId}-tab` : undefined,
@@ -612,8 +629,7 @@ export function Tabs<T extends string>({
612
629
  ) : null;
613
630
  // The box the cues sit in, ‹ in the gutter before the strip and › in the one after it
614
631
  // (see `gutterAt`). There whether or not the strip overflows right now, so a resize
615
- // never re-parents it; the gutters only while it overflows at all.
616
- const gutters = cue && fade.overflow !== undefined;
632
+ // never re-parents it; the gutters only while it overflows at all (`gutters`).
617
633
  const stripBox = cue ? (
618
634
  <div className={cn("relative flex min-w-0", !onAdd && "flex-1", gutters && "px-[24px]")}>
619
635
  {tablist}
@@ -111,6 +111,44 @@ export interface TreemapProps extends Omit<ComponentPropsWithoutRef<"div">, "chi
111
111
  * so a caller colouring a legend beside the map is guaranteed the same list.
112
112
  */
113
113
  colors?: readonly string[];
114
+ /**
115
+ * How tiles are parted (0.51.6, keksdose dev #642, Marcel: "The borders do not look
116
+ * nice when changing from tile to tile. Can this be uniform black?" — and then, on the
117
+ * contrast rule that would go with it: follow the contrast setting).
118
+ *
119
+ * - `"auto"` (default): `"uniform"` at Standard contrast, `"contrast"` while More
120
+ * contrast is in force (`<html data-contrast="more">`: the reader chose it, or chose
121
+ * System on a device that asks for more). Followed live.
122
+ * - `"uniform"`: one 2px gutter in the page's ground colour (`--bg-page`, near-black
123
+ * in the dark theme) round every tile, and nothing else. Tiles always abut, so the
124
+ * gutter parts them; each tile is named by its label.
125
+ * - `"contrast"`: the 0.49 rule. The gutter is the card's colour (`--bg-surface`),
126
+ * and a tile whose hue is under 3:1 against the card gets a 1px ink edge inside it
127
+ * (WCAG 1.4.11, `useChartEdge`). In the dark theme that ink is light, so some tiles
128
+ * carry a white outline and their neighbours none — the unevenness #642 saw, and
129
+ * the price of the edge for a reader who asked for it.
130
+ */
131
+ edges?: TreemapEdges;
132
+ }
133
+
134
+ /** {@link TreemapProps.edges}. */
135
+ export type TreemapEdges = "auto" | "uniform" | "contrast";
136
+
137
+ // `<html data-contrast>`, followed live: what `edges="auto"` keys on. Read here rather
138
+ // than through theme/contrast, whose store would come into the chart entry with it.
139
+ function subscribeContrast(onChange: () => void): () => void {
140
+ if (typeof MutationObserver === "undefined" || typeof document === "undefined") return () => {};
141
+ const observer = new MutationObserver(onChange);
142
+ observer.observe(document.documentElement, { attributes: true, attributeFilter: ["data-contrast"] });
143
+ return () => observer.disconnect();
144
+ }
145
+ const readMoreContrast = () =>
146
+ typeof document !== "undefined" && document.documentElement.getAttribute("data-contrast") === "more";
147
+
148
+ /** The edges a tile draws: `auto` resolved against the contrast in force. */
149
+ function useTileEdges(edges: TreemapEdges): "uniform" | "contrast" {
150
+ const more = useSyncExternalStore(subscribeContrast, readMoreContrast, () => false);
151
+ return edges === "auto" ? (more ? "contrast" : "uniform") : edges;
114
152
  }
115
153
 
116
154
  // ── The categorical ramp, as concrete hex ──────────────────────────────────
@@ -218,6 +256,8 @@ export interface TreemapCellProps {
218
256
  * the label stands in the tile's top RIGHT corner and is shaped right-to-left, so a
219
257
  * clipped name ends in its ellipsis on the reading end. Default `ltr`. */
220
258
  dir?: Direction;
259
+ /** See {@link TreemapProps.edges}. Default `auto`. */
260
+ edges?: TreemapEdges;
221
261
  }
222
262
 
223
263
  /**
@@ -245,11 +285,13 @@ export function TreemapCell({
245
285
  redactNames,
246
286
  nodeNote,
247
287
  dir = "ltr",
288
+ edges: edgesProp = "auto",
248
289
  }: TreemapCellProps) {
249
290
  // Before the early return: a hook has to run on every render of the cell.
250
291
  const tokenHex = useChartTokenHex();
251
292
  const { scale } = useTextSize();
252
293
  const edgeFor = useChartEdge();
294
+ const edges = useTileEdges(edgesProp);
253
295
  // Depth 0 is recharts' synthetic root, which spans the whole chart.
254
296
  if (!depth) return null;
255
297
  // Whole pixels: a rect on a half pixel renders its edge across two half-lit
@@ -261,9 +303,10 @@ export function TreemapCell({
261
303
  const tileFill = fill ?? "var(--chart-1)";
262
304
  const measured = measurableFill(tileFill, tokenHex);
263
305
  const ink = labelColor ?? (measured ? textOn(measured) : "currentColor");
264
- // A tile in a hue under 3:1 against the card gets a 1px ink edge inside the gutter
265
- // (WCAG 1.4.11, anyplot's rule); a hue that clears it stays bare.
266
- const edge = measured ? edgeFor(measured) : undefined;
306
+ // `contrast`: a tile in a hue under 3:1 against the card gets a 1px ink edge inside
307
+ // the gutter (WCAG 1.4.11, anyplot's rule); a hue that clears it stays bare.
308
+ // `uniform`: no per-tile edge; every tile is parted by the same gutter.
309
+ const edge = edges === "contrast" && measured ? edgeFor(measured) : undefined;
267
310
  // The halo only on an OPAQUE fill we could measure. A translucent fill (a
268
311
  // `color-mix(… transparent)`) composited twice — once as the tile, once as the halo
269
312
  // over it — draws a darker ring round every glyph, which read as doubled text on the
@@ -309,9 +352,19 @@ export function TreemapCell({
309
352
  // for as long as the tile has keyboard focus.
310
353
  className={activate ? "outline-none [&:focus-visible>rect]:stroke-[var(--brand)]" : undefined}
311
354
  >
312
- {/* The 2px gutter is the card showing through, not a white grid — on a dark
313
- surface white lines glare harder than the tiles. */}
314
- <rect x={px} y={py} width={pw} height={ph} fill={tileFill} stroke="var(--bg-surface)" strokeWidth={2} rx={3} />
355
+ {/* The 2px gutter: the page's ground (`uniform`, the same dark line round every
356
+ tile in the dark theme) or the card showing through (`contrast`). Never a white
357
+ grid: on a dark surface white lines glare harder than the tiles. */}
358
+ <rect
359
+ x={px}
360
+ y={py}
361
+ width={pw}
362
+ height={ph}
363
+ fill={tileFill}
364
+ stroke={edges === "contrast" ? "var(--bg-surface)" : "var(--bg-page)"}
365
+ strokeWidth={2}
366
+ rx={3}
367
+ />
315
368
  {edge && pw > 3 && ph > 3 && (
316
369
  <rect
317
370
  data-edge=""
@@ -418,6 +471,7 @@ export function Treemap({
418
471
  nodeNote,
419
472
  maxTiles,
420
473
  colors,
474
+ edges = "auto",
421
475
  style,
422
476
  ...rest
423
477
  }: TreemapProps) {
@@ -471,7 +525,15 @@ export function Treemap({
471
525
  dataKey="size"
472
526
  fill="var(--chart-1)"
473
527
  isAnimationActive={false}
474
- content={<TreemapCell onNodeClick={onNodeClick} redactNames={redactNames} nodeNote={nodeNote} dir={dir} />}
528
+ content={
529
+ <TreemapCell
530
+ onNodeClick={onNodeClick}
531
+ redactNames={redactNames}
532
+ nodeNote={nodeNote}
533
+ dir={dir}
534
+ edges={edges}
535
+ />
536
+ }
475
537
  >
476
538
  <ChartTooltip content={<Content hideLabel valueFormatter={format} />} />
477
539
  </RechartsTreemap>
@@ -42,8 +42,9 @@ import { isComposing } from "../lib/ime";
42
42
  * draft, InlineEditField cancelling its edit, the date picker's month grid going back to
43
43
  * the days. Those call {@link claimEscape}, and no layer is handed the key. The only
44
44
  * other claim is the layer owner's OWN handler — a dialog's `onKeyDown` prop calling
45
- * `preventDefault()` on Escape (a busy reauth dialog holding itself open, Kurvenschmiede's
46
- * gear wizard asking first), through {@link runOwnKeyDown}. A `preventDefault()`
45
+ * `preventDefault()` on Escape (Kurvenschmiede's gear wizard asking first), through
46
+ * {@link runOwnKeyDown}. A dialog holding itself open while its request runs passes
47
+ * Modal's `busy` instead (0.51.7, DUP-3), which holds the backdrop and Back as well. A `preventDefault()`
47
48
  * anywhere else claims nothing: a search box keeping Chromium from clearing itself
48
49
  * must not hold a dialog open (FullBleedDialog's 0.8.0 fix, which Modal never had).
49
50
  *
@@ -71,6 +71,8 @@ const SENTINEL_KEY = "__hbUiOverlayHistory";
71
71
  interface OverlayEntry {
72
72
  id: string;
73
73
  close: () => void;
74
+ /** The overlay refuses to close right now — see the `held` option. */
75
+ held: () => boolean;
74
76
  }
75
77
 
76
78
  const stack: OverlayEntry[] = [];
@@ -510,6 +512,11 @@ function closeOnPop() {
510
512
  // is no longer ours: either the layer below (a different overlay's marker) or
511
513
  // the plain page entry (none at all).
512
514
  if (currentSentinel() === top.id) return;
515
+ // Checked before anything below moves: a held overlay keeps its place and its record.
516
+ if (top.held()) {
517
+ putBack(top.id);
518
+ return;
519
+ }
513
520
  stack.pop();
514
521
  // The browser consumed this entry, so the overlay's own cleanup has nothing left
515
522
  // to unwind and nothing to record as owed.
@@ -521,6 +528,29 @@ function closeOnPop() {
521
528
  top.close();
522
529
  }
523
530
 
531
+ /**
532
+ * A Back press the top overlay refuses (a busy `Modal`, DUP-3). A popstate cannot be
533
+ * refused — the browser has already left our entry — so the entry is put back: what the
534
+ * page wrote into the address while the overlay was up is carried down first (#412), as
535
+ * for a close, and the same marker is pushed again over it, as the mount pushed it. Left
536
+ * on the page entry, the overlay would stay up with no entry of its own, and the Back
537
+ * after its request would leave the page under it.
538
+ *
539
+ * A push and not `history.go(1)` back onto the old entry: a traversal lands a task later
540
+ * and fires nothing at all if a listener has pushed meanwhile, and a programmatic pop
541
+ * counted for it would then swallow the user's next real Back.
542
+ */
543
+ function putBack(id: string): void {
544
+ const record = pushed.find((p) => p.id === id);
545
+ if (record && !record.dead) carryOnto(record.seen, record);
546
+ const prev: unknown = window.history.state;
547
+ window.history.pushState({ ...(prev && typeof prev === "object" ? prev : {}), [SENTINEL_KEY]: id }, "");
548
+ standing = id;
549
+ // The address the entry shows now is the one the user last acted on: the cleanup's
550
+ // owner's-close check (#584) must not read the carried address as a rewrite.
551
+ if (record) record.href = record.seen = currentHref();
552
+ }
553
+
524
554
  function ensureListening() {
525
555
  if (listening || typeof window === "undefined") return;
526
556
  window.addEventListener("popstate", handlePop);
@@ -536,20 +566,26 @@ function ensureListening() {
536
566
  * @param open whether the overlay is currently showing. Components that mount
537
567
  * only while open (the common case) pass `true`.
538
568
  * @param onClose the overlay's own close handler — the same one Escape calls.
569
+ * @param options.held the overlay refuses to close right now (a `Modal` whose request
570
+ * is in flight, DUP-3): Back does not call `onClose`, and the entry the
571
+ * press took is put back, so the overlay still has one for the next
572
+ * Back. Read when Back is pressed. Default false.
539
573
  */
540
- export function useOverlayHistory(open: boolean, onClose: () => void): void {
574
+ export function useOverlayHistory(open: boolean, onClose: () => void, { held = false }: { held?: boolean } = {}): void {
541
575
  // The listener fires outside React's render, so it reads the latest handler
542
576
  // through a ref rather than through the closure the effect captured.
543
577
  const closeRef = useRef(onClose);
578
+ const heldRef = useRef(held);
544
579
  useEffect(() => {
545
580
  closeRef.current = onClose;
581
+ heldRef.current = held;
546
582
  });
547
583
 
548
584
  useEffect(() => {
549
585
  if (!open || typeof window === "undefined") return;
550
586
  ensureListening();
551
587
  const id = `overlay-${(nextId += 1)}`;
552
- const entry: OverlayEntry = { id, close: () => closeRef.current() };
588
+ const entry: OverlayEntry = { id, close: () => closeRef.current(), held: () => heldRef.current };
553
589
  stack.push(entry);
554
590
  const prev = window.history.state;
555
591
  const marked = { ...(prev && typeof prev === "object" ? prev : {}), [SENTINEL_KEY]: id };
package/src/lib/calc.ts CHANGED
@@ -244,9 +244,20 @@ export function commitExpression(raw: string): string {
244
244
  * keystroke-by-keystroke but stray characters can't be entered. A lone leading
245
245
  * `-` is kept — in a plain field it is simply a negative amount; in a field whose
246
246
  * caller owns the sign it is read off again by {@link splitLeadingSign}. The text
247
- * is resolved to a plain number on blur/Enter via {@link commitExpression}. */
247
+ * is resolved to a plain number on blur/Enter via {@link commitExpression}.
248
+ *
249
+ * The typographic minus and the two dashes become "-" first, as in
250
+ * {@link evaluateExpression}: U+2212 is what the kit's own `formatMoney` and
251
+ * `formatNumber` print for every negative since 0.49, and stripping it with the
252
+ * stray characters turned a pasted "−1.234,50 €" into a positive amount (refactor
253
+ * audit 2026-10-30, INP-3). */
248
254
  export function sanitizeLive(raw: string): string {
249
- return dropExtraDots(raw.replace(/,/g, ".").replace(/[^0-9.+\-*/×÷()]/g, ""));
255
+ return dropExtraDots(
256
+ raw
257
+ .replace(/[−–—]/g, "-")
258
+ .replace(/,/g, ".")
259
+ .replace(/[^0-9.+\-*/×÷()]/g, ""),
260
+ );
250
261
  }
251
262
 
252
263
  /**
@@ -5,8 +5,8 @@
5
5
  * as a number: a figure copied out of a kit table and pasted into Excel landed as text.
6
6
  * So while a selection inside the page is copied, every U+2212 standing directly before a
7
7
  * number — optionally after a currency code or symbol and spaces: "−CHF 1,250.50",
8
- * "−€2,000", "−12%" — becomes the ASCII "-" in the clipboard, in `text/plain` AND in
9
- * `text/html` (Excel pastes the HTML flavour, as cells). The page keeps "−".
8
+ * "−€2,000", "−US$12", "−12%" — becomes the ASCII "-" in the clipboard, in `text/plain` AND
9
+ * in `text/html` (Excel pastes the HTML flavour, as cells). The page keeps "−".
10
10
  *
11
11
  * A copy with no such minus is left to the browser untouched, and so is any copy out of an
12
12
  * input, a textarea or an editable element: what the person typed is theirs.
@@ -15,8 +15,18 @@
15
15
  * the one part every app mounts, the signed-out pages included.
16
16
  */
17
17
 
18
- /** U+2212, then (optionally) a currency code or symbol, then spaces, then a digit. */
19
- const MINUS_BEFORE_NUMBER = /\u2212(?=(?:[A-Z]{3}|\p{Sc})?[\s\u00a0\u202f]*\d)/gu;
18
+ /**
19
+ * U+2212, then up to five letters, currency symbols or dots, then spaces, then a digit.
20
+ *
21
+ * Five of them, not "a code or one symbol": English and Chinese write a currency with
22
+ * letters before the symbol — "−US$", "−CA$", "−A$", "−NZ$", "−HK$", "−R$", "−MX$",
23
+ * "−JP¥", "−CN¥" — and `narrowSymbol` with letters alone ("−kr", "−zł", "−Kč"). Those kept
24
+ * U+2212 in the clipboard, and Excel pasted them as text: 35 of 351 locale × currency
25
+ * pairs at the default display, every one English or Chinese, and 136 of 1428 with
26
+ * `narrowSymbol` (refactor audit 2026-10-30, DSP-11). A minus with no digit after it
27
+ * ("x−y", "a − b") is still left alone.
28
+ */
29
+ const MINUS_BEFORE_NUMBER = /\u2212(?=[\p{L}\p{Sc}.]{0,5}[\s\u00a0\u202f]*\d)/gu;
20
30
 
21
31
  /**
22
32
  * `text` with each U+2212 that stands before a number (in the sense above) as the ASCII
@@ -37,7 +37,13 @@ export function decimalMark(locale: string | undefined): "," | "." {
37
37
  * field's settle then made that 1.23, final. This decides per operand, from the
38
38
  * typist's own text, before that fold:
39
39
  *
40
- * - both marks present: the one that is not the locale's is grouping, and goes;
40
+ * - both marks present: the LAST one is the decimal mark, whichever the locale's is, and
41
+ * the other is grouping and goes. The order says the convention the figure was written
42
+ * in — "1.234,56" German, "1,234.56" English — and a figure pasted from another app or
43
+ * locale keeps it. Taking the non-locale mark as grouping wherever it stood read
44
+ * "1.234,56" in a "." locale (en, de-CH) as 1.23456, which a money field then settled
45
+ * to 1.23 (refactor audit 2026-10-30, INP-3). Typed in the locale's own convention, its
46
+ * mark comes last and nothing changes;
41
47
  * - in a "," locale, only "." present: grouping when it cannot be a decimal as a
42
48
  * German reader writes it — twice ("1.234.567"), or in thousands shape ("1.234",
43
49
  * "12.500") — otherwise a decimal ("1.5", typed by a dot-decimal habit). A first
@@ -53,10 +59,13 @@ export function decimalMark(locale: string | undefined): "," | "." {
53
59
  export function normalizeTypedMarks(raw: string, mark: "," | "."): string {
54
60
  const other = mark === "," ? "." : ",";
55
61
  return raw.replace(/[0-9.,]+/g, (operand) => {
56
- const hasMark = operand.includes(mark);
57
62
  const count = operand.split(other).length - 1;
58
63
  if (count === 0) return operand;
59
- const grouping = hasMark ? true : count > 1 || (mark === "," && /^[1-9]\d{0,2}(\.\d{3})+$/.test(operand));
64
+ if (operand.includes(mark)) {
65
+ const grouping = operand.lastIndexOf(mark) > operand.lastIndexOf(other) ? other : mark;
66
+ return operand.split(grouping).join("");
67
+ }
68
+ const grouping = count > 1 || (mark === "," && /^[1-9]\d{0,2}(\.\d{3})+$/.test(operand));
60
69
  return grouping ? operand.split(other).join("") : operand;
61
70
  });
62
71
  }
package/src/lib/format.ts CHANGED
@@ -335,8 +335,8 @@ export function formatDate(
335
335
  }
336
336
 
337
337
  export interface FormatRelativeTimeOptions extends BaseOptions {
338
- /** The zone of the date printed past `absoluteAfterDays` (0.46). Left out: the
339
- * device's. */
338
+ /** The zone of the date printed past `absoluteAfterDays` (0.46), and (0.51.7) the zone
339
+ * whose calendar days "yesterday" and "2 days ago" count. Left out: the device's. */
340
340
  timeZone?: string;
341
341
  /** The moment it is measured from. Default: now. */
342
342
  now?: DateInput;
@@ -370,6 +370,20 @@ const RELATIVE_STEPS: Array<[number, Intl.RelativeTimeFormatUnit, number]> = [
370
370
  [Infinity, "year", 365.25 * DAY],
371
371
  ];
372
372
 
373
+ /**
374
+ * `d`'s calendar day in `timeZone` (left out: the device's), as a whole number of days:
375
+ * that day's UTC midnight over a day's length. Two of them subtract to whole calendar
376
+ * days — a 23- or 25-hour DST day still counts one.
377
+ */
378
+ function dayNumber(d: Date, timeZone: string | undefined): number {
379
+ if (!timeZone) return Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()) / DAY;
380
+ // `en-US`: Gregorian, Latin digits — the parts are read back as numbers.
381
+ const fields: Intl.DateTimeFormatOptions = { timeZone, year: "numeric", month: "numeric", day: "numeric" };
382
+ const parts = dateFormatter("en-US", fields).formatToParts(d);
383
+ const part = (type: Intl.DateTimeFormatPartTypes) => Number(parts.find((p) => p.type === type)?.value);
384
+ return Date.UTC(part("year"), part("month") - 1, part("day")) / DAY;
385
+ }
386
+
373
387
  const relativeFormatters = new Map<string, Intl.RelativeTimeFormat>();
374
388
  function relativeFormatter(
375
389
  locale: string | undefined,
@@ -389,7 +403,9 @@ function relativeFormatter(
389
403
  * How long ago (or until) `value` is, in `locale`'s words, through
390
404
  * `Intl.RelativeTimeFormat`: "3 hours ago", "vor 3 Stunden", "il y a 3 heures",
391
405
  * "in 2 days", and under 45 seconds "now". The unit is picked by size — seconds,
392
- * minutes, hours, days, weeks, months, years — and the count is rounded.
406
+ * minutes, hours, days, weeks, months, years — and the count is rounded; days are
407
+ * CALENDAR days (0.51.7), in `timeZone` when given: "yesterday" is the day before today,
408
+ * however few hours ago that was.
393
409
  */
394
410
  export function formatRelativeTime(value: DateInput, options: FormatRelativeTimeOptions = {}): string {
395
411
  const d = toDate(value);
@@ -402,6 +418,17 @@ export function formatRelativeTime(value: DateInput, options: FormatRelativeTime
402
418
  }
403
419
  const rtf = relativeFormatter(options.locale, options.numeric ?? "auto", options.style ?? "long");
404
420
  const [, unit, size] = RELATIVE_STEPS.find(([limit]) => abs < limit)!;
421
+ if (unit === "day") {
422
+ // By the calendar, not in 24-hour units (refactor audit 2026-10-30, DSP-7): rounded
423
+ // elapsed time made 00:10 seen at 23:50 "yesterday" and 01:00 yesterday seen at 23:30
424
+ // "2 days ago", beside an exact date (DateMark's second line, the feedback thread's
425
+ // tooltip) that said otherwise. Counted in `timeZone`, the zone that date is printed
426
+ // in; a bare "YYYY-MM-DD" is the day it names wherever it is read, as in formatDate.
427
+ // Still the same day (22 hours and more since just after midnight): in hours.
428
+ const bareDay = typeof value === "string" && BARE_ISO_DAY.test(value);
429
+ const days = dayNumber(d, bareDay ? undefined : options.timeZone) - dayNumber(now, options.timeZone);
430
+ return days === 0 ? rtf.format(Math.round(diff / HOUR), "hour") : rtf.format(days, "day");
431
+ }
405
432
  // Under 45 seconds is "now" rather than "12 seconds ago": a feed that ticks through
406
433
  // the seconds is noise, and nothing reads a timestamp to the second.
407
434
  const count = unit === "second" ? 0 : Math.round(diff / size);
@@ -32,6 +32,18 @@ export function foldForSearch(text: string, locale?: string): string {
32
32
  .replace(/ß/g, "ss");
33
33
  }
34
34
 
35
+ /**
36
+ * Whether two names are one name as a picker's search reads them: folded
37
+ * ({@link foldForSearch}) and trimmed — "cafe" is Café, " MIGROS " is Migros. For "does
38
+ * the typed text name a record that exists" (a create row, InlineEntityCombobox's blur
39
+ * commit), which compared with plain `toLowerCase` while the list beside it folded, so
40
+ * "cafe" listed Café as a hit and offered "Create “cafe”" too — a duplicate payee or shop
41
+ * chain one Enter away (refactor audit 2026-10-30, DUP-5). Internal: not in the barrel.
42
+ */
43
+ export function sameName(a: string, b: string, locale?: string): boolean {
44
+ return foldForSearch(a.trim(), locale) === foldForSearch(b.trim(), locale);
45
+ }
46
+
35
47
  /**
36
48
  * A picker's query as its words, each folded (0.51.4, keksdose's /prices shop picker):
37
49
  * "Migros Zürich" → `["migros", "zurich"]`. An empty or blank query has no words.
package/src/tour/tour.tsx CHANGED
@@ -140,7 +140,9 @@ export interface TourStep {
140
140
  /** Runs before the step is shown — e.g. navigate to a route, open a panel.
141
141
  * May be async; the step waits for it, then locates the target. Receives the
142
142
  * {@link TourStepContext} (0.50); a step entered by "Guide me" sees `via: "at"` and
143
- * should leave an open form alone. */
143
+ * should leave an open form alone. One that throws or rejects is logged to the
144
+ * console, and the step is located and shown anyway. Enter and the arrows wait for
145
+ * it; Escape does not. */
144
146
  beforeStep?: (ctx: TourStepContext) => void | Promise<void>;
145
147
  }
146
148
 
@@ -481,7 +483,10 @@ export function TourProvider({
481
483
  const last = Math.max(0, s.length - 1);
482
484
  floorRef.current = opts?.at === undefined ? 0 : resolveAt(s, opts.at);
483
485
  const via: TourStepContext["via"] = opts?.at === undefined ? "start" : "at";
484
- const wanted = Math.min(Math.max(floorRef.current, opts?.startIndex ?? floorRef.current), last);
486
+ // Floored, as `at` is (FLW-9): `steps[1.5]` is undefined, and `forward` threw on
487
+ // its `when` — an edited `?tourStep=2.5` was enough.
488
+ const asked = opts?.startIndex === undefined ? floorRef.current : Math.floor(opts.startIndex);
489
+ const wanted = Math.min(Math.max(floorRef.current, asked), last);
485
490
  let to = forward(s, wanted, via);
486
491
  if (to < 0) to = backward(s, wanted, via);
487
492
  // Not one step of the run is shown: there is nothing to tour.
@@ -561,7 +566,7 @@ export function TourProvider({
561
566
  (i: number) => {
562
567
  const s = stepsRef.current;
563
568
  if (!s || s.length === 0) return;
564
- const wanted = Math.min(Math.max(floorRef.current, i), s.length - 1);
569
+ const wanted = Math.min(Math.max(floorRef.current, Math.floor(i)), s.length - 1);
565
570
  let to = forward(s, wanted, "goto");
566
571
  if (to < 0) to = backward(s, wanted, "goto");
567
572
  if (to >= 0) arrive(s, to, "goto");
@@ -990,7 +995,15 @@ function TourOverlay({
990
995
  setLocated({ index, step, rect: el ? spotRectOf(el, step) : null, dir: dirOf(el), layer, fullScreen });
991
996
  };
992
997
  void (async () => {
993
- await step.beforeStep?.(stepContext(gates, via));
998
+ try {
999
+ await step.beforeStep?.(stepContext(gates, via));
1000
+ } catch (error) {
1001
+ // The step is shown anyway (FLW-5): left unlocated, the tour stayed `active`
1002
+ // with no card, no scrim and no Escape layer, and everything keyed on "a tour
1003
+ // runs" (the `?tour=` mirror, the apps' PWA-update hold) stayed on with it. The
1004
+ // target may well be there regardless; if not, the finder gives up and centres.
1005
+ console.error(error);
1006
+ }
994
1007
  if (cancelled) return;
995
1008
  if (!step.target) {
996
1009
  settle(null);
@@ -1318,6 +1331,10 @@ function TourOverlay({
1318
1331
  if (!ready) onSkip();
1319
1332
  return;
1320
1333
  }
1334
+ // Nothing on screen to move on from: a step whose `beforeStep` is still running
1335
+ // (a route loading) is not passed over by an Enter or a → pressed on the bare
1336
+ // page (FLW-5). Escape, above, still leaves.
1337
+ if (!ready) return;
1321
1338
  if (e.key === "Enter") {
1322
1339
  if (step.awaitClick || step.awaitInput || ownsKey(e.target, "Enter")) return;
1323
1340
  e.preventDefault();
@@ -94,10 +94,11 @@ const NavigationContext: Context<NavigationProbe> = (() => {
94
94
  }
95
95
  })();
96
96
 
97
- /** The step a link asks for, 0-based: `tourStep` is 1-based (keksdose), an alias 0-based. */
97
+ /** The step a link asks for, 0-based: `tourStep` is 1-based (keksdose), an alias 0-based.
98
+ * Floored, as the aliases always were (FLW-9): `?tourStep=2.5` was index 1.5. */
98
99
  function stepOf(params: URLSearchParams, aliases: readonly string[]): number {
99
100
  const step = Number(params.get("tourStep"));
100
- if (params.has("tourStep")) return Number.isFinite(step) && step > 0 ? step - 1 : 0;
101
+ if (params.has("tourStep")) return Number.isFinite(step) && step >= 1 ? Math.floor(step) - 1 : 0;
101
102
  for (const alias of aliases) {
102
103
  const value = Number(params.get(alias));
103
104
  if (params.has(alias)) return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
@@ -166,7 +167,10 @@ export function useTourUrlSync({ launch, ready = true, stepAliases = [] }: UseTo
166
167
  launchingRef.current = true;
167
168
  void Promise.resolve()
168
169
  .then(() => launchRef.current(pending.id, { startIndex: pending.startIndex }, startRef.current, live.engine))
169
- .catch(() => undefined)
170
+ // Said, as `TourProvider`'s own `launch` path says it (FLW-9): swallowed, a launch
171
+ // that failed was a tour that silently did not start — in kastlan after its
172
+ // launcher had already turned practice mode on.
173
+ .catch((error: unknown) => console.error(error))
170
174
  .finally(() => {
171
175
  launchingRef.current = false;
172
176
  setSettled((n) => n + 1);