@cosmicdrift/kumiko-renderer 0.383.0 → 0.384.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.
@@ -68,11 +68,11 @@ function formatDateCell(value, type, opts) {
68
68
  // No `locale` fallback to a browser API here (headless has none) — passing
69
69
  // `undefined` to Intl.NumberFormat resolves the runtime's default locale,
70
70
  // the same value a browser's navigator.language-based guess would produce.
71
- function formatNumberCell(value, locale, grouping = true) {
71
+ function formatNumberCell(value, locale, grouping = true, fractionDigits = {}) {
72
72
  if (typeof value !== "number" || !Number.isFinite(value))
73
73
  return String(value);
74
74
  try {
75
- return new Intl.NumberFormat(locale, { useGrouping: grouping }).format(value);
75
+ return new Intl.NumberFormat(locale, { useGrouping: grouping, ...fractionDigits }).format(value);
76
76
  }
77
77
  catch {
78
78
  // Malformed BCP-47 locale tag — mirrors formatDateCell's fallback above.
@@ -152,6 +152,25 @@ function stringSpec(spec, key) {
152
152
  const v = spec[key];
153
153
  return typeof v === "string" ? v : undefined;
154
154
  }
155
+ const MAX_FRACTION_DIGITS = 20;
156
+ function fractionDigitSpec(spec, key) {
157
+ const v = spec[key];
158
+ return typeof v === "number" && Number.isInteger(v) && v >= 0 && v <= MAX_FRACTION_DIGITS
159
+ ? v
160
+ : undefined;
161
+ }
162
+ // Out-of-range or inverted options fall back to the Intl default instead of
163
+ // throwing: a bad schema option must not crash the screen it renders in.
164
+ function fractionDigitsSpec(spec) {
165
+ const min = fractionDigitSpec(spec, "minimumFractionDigits");
166
+ const max = fractionDigitSpec(spec, "maximumFractionDigits");
167
+ if (min !== undefined && max !== undefined && min > max)
168
+ return {};
169
+ return {
170
+ ...(min !== undefined && { minimumFractionDigits: min }),
171
+ ...(max !== undefined && { maximumFractionDigits: max }),
172
+ };
173
+ }
155
174
  function booleanSpec(spec, key) {
156
175
  const v = spec[key];
157
176
  return typeof v === "boolean" ? v : undefined;
@@ -197,7 +216,7 @@ export function applyFormatSpec(spec, value, translate) {
197
216
  case "number":
198
217
  case "decimal":
199
218
  case "bigInt":
200
- return formatNumberCell(value, stringSpec(spec, "locale"), booleanSpec(spec, "grouping") ?? true);
219
+ return formatNumberCell(value, stringSpec(spec, "locale"), booleanSpec(spec, "grouping") ?? true, spec.format === "bigInt" ? {} : fractionDigitsSpec(spec));
201
220
  case "unit":
202
221
  return formatUnitCell(value, stringSpec(spec, "unit"), stringSpec(spec, "locale"), unitDisplaySpec(spec));
203
222
  case "priority": {
@@ -42,6 +42,12 @@ function computeWriteFormSectionViewModel(sectionSpec, screenId, values, transla
42
42
  ...(sectionSpec.actions !== undefined && { actions: sectionSpec.actions }),
43
43
  };
44
44
  }
45
+ // A section whose only content is an action (a conditional field plus a delete button) stays on the screen.
46
+ function hasVisibleContent(fields, actions, values) {
47
+ if (fields.some((field) => field.visible))
48
+ return true;
49
+ return (actions ?? []).some((action) => action.visible === undefined || evalFieldCondition(action.visible, values));
50
+ }
45
51
  function wasEmptiedByWriteExclusion(declaredFieldSpecs, remainingFields) {
46
52
  return declaredFieldSpecs.length > 0 && remainingFields.length === 0;
47
53
  }
@@ -459,7 +465,7 @@ export function computeEditViewModel(input) {
459
465
  });
460
466
  // The boot-validator rejects declared-empty sections (screens.ts), but a
461
467
  // create-excluded field list can still empty one here: then `visible` is false.
462
- const visible = fields.some((field) => field.visible);
468
+ const visible = hasVisibleContent(fields, sectionSpec.actions, values);
463
469
  // Tabs mode renders this section as its own panel, where two columns reads
464
470
  // better than the single-column default a stacked form keeps. Applies to
465
471
  // entityEdit tabs as well since fw#3134, not just projectionDetail.
@@ -0,0 +1,13 @@
1
+ import type { ReactNode } from "react";
2
+ export type CalendarDayPickerProps = {
3
+ readonly selected: Date | undefined;
4
+ readonly onSelect: (d: Date | undefined) => void;
5
+ readonly startMonth: Date;
6
+ readonly endMonth: Date;
7
+ readonly disabledMatchers: readonly ({
8
+ before: Date;
9
+ } | {
10
+ after: Date;
11
+ })[];
12
+ };
13
+ export default function CalendarDayPicker({ selected, onSelect, startMonth, endMonth, disabledMatchers, }: CalendarDayPickerProps): ReactNode;
@@ -0,0 +1,38 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { DayPicker } from "react-day-picker";
3
+ import { cn } from "../lib/cn.js";
4
+ const dropdownSelectClass = "rounded-sm border border-input bg-transparent px-1.5 py-0.5 text-sm font-medium " +
5
+ "cursor-pointer focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring";
6
+ // rdp v9's default dropdown renders an aria-hidden <span> with the same label
7
+ // next to the <select>, hidden only by rdp's own style.css. Our token classes
8
+ // replace that CSS, so the label showed twice; a bare <select> avoids it
9
+ // regardless of CSS.
10
+ function SelectDropdown({ options, className, ...selectProps }) {
11
+ return (_jsx("select", { className: cn(dropdownSelectClass, className), ...selectProps, children: options?.map((opt) => (_jsx("option", { value: opt.value, disabled: opt.disabled, children: opt.label }, opt.value))) }));
12
+ }
13
+ // Month/year selects come from SelectDropdown (components prop), so only the dropdowns container is styled here.
14
+ const dayPickerClasses = {
15
+ root: "rdp-root",
16
+ months: "flex flex-col gap-2",
17
+ month: "flex flex-col gap-2",
18
+ month_caption: "flex justify-center items-center h-7 text-sm font-medium",
19
+ dropdowns: "flex items-center gap-1 text-sm font-medium",
20
+ nav: "flex items-center gap-1 absolute right-1 top-2",
21
+ button_previous: "inline-flex h-7 w-7 items-center justify-center rounded-sm hover:bg-accent",
22
+ button_next: "inline-flex h-7 w-7 items-center justify-center rounded-sm hover:bg-accent",
23
+ weekdays: "flex",
24
+ weekday: "w-8 text-xs font-normal text-muted-foreground",
25
+ week: "flex mt-1",
26
+ day: "w-8 h-8 p-0 text-sm",
27
+ day_button: "inline-flex h-8 w-8 items-center justify-center rounded-sm hover:bg-accent " +
28
+ "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
29
+ selected: "[&_button]:bg-primary [&_button]:text-primary-foreground [&_button]:hover:bg-primary/90",
30
+ today: "[&_button]:underline",
31
+ outside: "text-muted-foreground/50",
32
+ disabled: "text-muted-foreground/30 pointer-events-none",
33
+ };
34
+ export default function CalendarDayPicker({ selected, onSelect, startMonth, endMonth, disabledMatchers, }) {
35
+ return (_jsx(DayPicker, { mode: "single", captionLayout: "dropdown", startMonth: startMonth, endMonth: endMonth, selected: selected,
36
+ // Without defaultMonth DayPicker opens on today, not on the chosen date.
37
+ defaultMonth: selected, ...(disabledMatchers.length > 0 && { disabled: [...disabledMatchers] }), onSelect: (d) => onSelect(d), classNames: dayPickerClasses, components: { Dropdown: SelectDropdown } }));
38
+ }
@@ -1,4 +1,4 @@
1
- import type { ReactNode } from "react";
1
+ import { type ReactNode } from "react";
2
2
  export type CalendarPopoverProps = {
3
3
  readonly selected: Date | undefined;
4
4
  readonly onSelect: (d: Date | undefined) => void;
@@ -11,8 +11,11 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
11
11
  // teilen beide dieselbe Kalender-Mechanik ohne ihre Eingabe-Form zu teilen.
12
12
  import * as PopoverPrimitive from "@radix-ui/react-popover";
13
13
  import { Calendar as CalendarIcon } from "lucide-react";
14
- import { DayPicker } from "react-day-picker";
14
+ import { lazy, Suspense } from "react";
15
15
  import { cn } from "../lib/cn.js";
16
+ const CalendarDayPicker = lazy(() => import("./calendar-day-picker.js"));
17
+ // Same footprint as the loaded calendar card, so opening the popover does not jump.
18
+ const CALENDAR_PLACEHOLDER_CLASS = "h-[17rem] w-56";
16
19
  export const inFieldIconButtonClass = "absolute inset-y-0 right-0 inline-flex w-9 items-center justify-center rounded-md " +
17
20
  "text-muted-foreground transition-colors hover:text-foreground " +
18
21
  "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring " +
@@ -35,42 +38,5 @@ export function CalendarPopover({ selected, onSelect, min, max, disabled, hasErr
35
38
  ...(min !== undefined ? [{ before: min }] : []),
36
39
  ...(max !== undefined ? [{ after: max }] : []),
37
40
  ];
38
- return (_jsxs(PopoverPrimitive.Root, { children: [_jsx(PopoverPrimitive.Trigger, { asChild: true, children: _jsx("button", { type: "button", disabled: disabled, "aria-label": triggerLabel, "aria-invalid": hasError === true ? true : undefined, className: cn(inFieldIconButtonClass, hasError === true && "text-destructive"), children: _jsx(CalendarIcon, { className: "size-4", "aria-hidden": "true" }) }) }), _jsx(PopoverPrimitive.Portal, { children: _jsx(PopoverPrimitive.Content, { className: popoverClass, align: "end", sideOffset: 4, children: _jsx(DayPicker, { mode: "single", captionLayout: "dropdown", startMonth: startMonth, endMonth: endMonth, selected: selected,
39
- // Ohne defaultMonth zeigt DayPicker today statt selected —
40
- // unintuitiv wenn der User schon ein Datum gewählt hat.
41
- defaultMonth: selected, ...(disabledMatchers.length > 0 && { disabled: disabledMatchers }), onSelect: (d) => onSelect(d), classNames: dayPickerClasses, components: { Dropdown: SelectDropdown } }) }) })] }));
41
+ return (_jsxs(PopoverPrimitive.Root, { children: [_jsx(PopoverPrimitive.Trigger, { asChild: true, children: _jsx("button", { type: "button", disabled: disabled, "aria-label": triggerLabel, "aria-invalid": hasError === true ? true : undefined, className: cn(inFieldIconButtonClass, hasError === true && "text-destructive"), children: _jsx(CalendarIcon, { className: "size-4", "aria-hidden": "true" }) }) }), _jsx(PopoverPrimitive.Portal, { children: _jsx(PopoverPrimitive.Content, { className: popoverClass, align: "end", sideOffset: 4, children: _jsx(Suspense, { fallback: _jsx("div", { className: CALENDAR_PLACEHOLDER_CLASS, "aria-hidden": "true" }), children: _jsx(CalendarDayPicker, { selected: selected, onSelect: onSelect, startMonth: startMonth, endMonth: endMonth, disabledMatchers: disabledMatchers }) }) }) })] }));
42
42
  }
43
- const dropdownSelectClass = "rounded-sm border border-input bg-transparent px-1.5 py-0.5 text-sm font-medium " +
44
- "cursor-pointer focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring";
45
- // rdp v9s Default-Dropdown rendert NEBEN dem <select> ein aria-hidden
46
- // <span> mit demselben Label — sichtbar nur, wenn rdps eigene style.css
47
- // das <select> transparent darüberlegt. Da wir die rdp-Klassen mit eigenen
48
- // Tokens überschreiben, greift diese Positionierung nicht → Label doppelt
49
- // (#369-Folgebug). Ein nacktes <select> ohne Begleit-Span vermeidet das
50
- // CSS-unabhängig — eine Quelle für den sichtbaren Wert.
51
- function SelectDropdown({ options, className, ...selectProps }) {
52
- return (_jsx("select", { className: cn(dropdownSelectClass, className), ...selectProps, children: options?.map((opt) => (_jsx("option", { value: opt.value, disabled: opt.disabled, children: opt.label }, opt.value))) }));
53
- }
54
- // classNames-Map für react-day-picker v9 — überschreibt die default-
55
- // Klassen mit Tailwind/shadcn-Tokens. Die Monat-/Jahr-Selects rendert
56
- // SelectDropdown (components-Prop), daher hier nur der dropdowns-Container.
57
- const dayPickerClasses = {
58
- root: "rdp-root",
59
- months: "flex flex-col gap-2",
60
- month: "flex flex-col gap-2",
61
- month_caption: "flex justify-center items-center h-7 text-sm font-medium",
62
- dropdowns: "flex items-center gap-1 text-sm font-medium",
63
- nav: "flex items-center gap-1 absolute right-1 top-2",
64
- button_previous: "inline-flex h-7 w-7 items-center justify-center rounded-sm hover:bg-accent",
65
- button_next: "inline-flex h-7 w-7 items-center justify-center rounded-sm hover:bg-accent",
66
- weekdays: "flex",
67
- weekday: "w-8 text-xs font-normal text-muted-foreground",
68
- week: "flex mt-1",
69
- day: "w-8 h-8 p-0 text-sm",
70
- day_button: "inline-flex h-8 w-8 items-center justify-center rounded-sm hover:bg-accent " +
71
- "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
72
- selected: "[&_button]:bg-primary [&_button]:text-primary-foreground [&_button]:hover:bg-primary/90",
73
- today: "[&_button]:underline",
74
- outside: "text-muted-foreground/50",
75
- disabled: "text-muted-foreground/30 pointer-events-none",
76
- };
@@ -29,7 +29,7 @@ function ThumbnailChain({ fileId, variant, className, testId, }) {
29
29
  if (src === undefined) {
30
30
  return (_jsx("div", { "data-testid": testId, className: cn("bg-muted text-muted-foreground flex items-center justify-center rounded-md border", className), children: _jsx(ImageIcon, { className: "size-1/3" }) }));
31
31
  }
32
- return (_jsx("img", { src: src, alt: "", "data-testid": testId, onError: () => setFailedCount((n) => n + 1), className: cn("bg-muted rounded-md border object-contain", className) }));
32
+ return (_jsx("img", { src: src, alt: "", loading: "lazy", decoding: "async", "data-testid": testId, onError: () => setFailedCount((n) => n + 1), className: cn("bg-muted rounded-md border object-contain", className) }));
33
33
  }
34
34
  export function FileThumbnail({ fileId, variant, className = "h-24 w-32", testId, }) {
35
35
  return (_jsx(ThumbnailChain, { fileId: fileId, variant: variant, className: className, testId: testId }, `${fileId}:${variant ?? ""}`));
@@ -42,6 +42,71 @@ function parseFileMeta(value) {
42
42
  const mimeType = "mimeType" in value && typeof value.mimeType === "string" ? value.mimeType : undefined;
43
43
  return { fileName: value.fileName, mimeType };
44
44
  }
45
+ const META_BATCH_SIZE = 100;
46
+ const META_CACHE_LIMIT = 500;
47
+ const metaCache = new Map();
48
+ let pendingMetaIds = new Map();
49
+ function parseBatchMeta(json) {
50
+ const metaById = new Map();
51
+ if (typeof json !== "object" || json === null || !("files" in json))
52
+ return metaById;
53
+ if (!Array.isArray(json.files))
54
+ return metaById;
55
+ for (const file of json.files) {
56
+ const meta = parseFileMeta(file);
57
+ if (meta !== null && typeof file === "object" && file !== null && "id" in file) {
58
+ if (typeof file.id === "string")
59
+ metaById.set(file.id, meta);
60
+ }
61
+ }
62
+ return metaById;
63
+ }
64
+ async function fetchMetaBatch(ids, resolvers) {
65
+ let metaById = null;
66
+ try {
67
+ const res = await fetch(`/api/files/meta?ids=${ids.map(encodeURIComponent).join(",")}`);
68
+ if (res.ok)
69
+ metaById = parseBatchMeta(await res.json());
70
+ }
71
+ catch {
72
+ // skip: a failed lookup shows the fallback label, like a missing file
73
+ }
74
+ for (const id of ids) {
75
+ // A transport failure must not stick in the cache; an id the server left out is a final answer.
76
+ if (metaById === null)
77
+ metaCache.delete(id);
78
+ resolvers.get(id)?.(metaById?.get(id) ?? null);
79
+ }
80
+ }
81
+ function flushPendingMetaIds() {
82
+ const batch = pendingMetaIds;
83
+ pendingMetaIds = new Map();
84
+ const ids = [...batch.keys()];
85
+ for (let start = 0; start < ids.length; start += META_BATCH_SIZE) {
86
+ void fetchMetaBatch(ids.slice(start, start + META_BATCH_SIZE), batch);
87
+ }
88
+ }
89
+ function loadFileMeta(fileId) {
90
+ const cached = metaCache.get(fileId);
91
+ if (cached)
92
+ return cached;
93
+ const promise = new Promise((resolve) => {
94
+ if (pendingMetaIds.size === 0)
95
+ queueMicrotask(flushPendingMetaIds);
96
+ pendingMetaIds.set(fileId, resolve);
97
+ });
98
+ if (metaCache.size >= META_CACHE_LIMIT) {
99
+ // An id still waiting for its batch keeps its entry, or its promise would never settle.
100
+ for (const key of metaCache.keys()) {
101
+ if (pendingMetaIds.has(key))
102
+ continue;
103
+ metaCache.delete(key);
104
+ break;
105
+ }
106
+ }
107
+ metaCache.set(fileId, promise);
108
+ return promise;
109
+ }
45
110
  export function useFileMeta(fileId) {
46
111
  const [meta, setMeta] = useState(null);
47
112
  const validId = isUuid(fileId);
@@ -50,13 +115,10 @@ export function useFileMeta(fileId) {
50
115
  setMeta(null);
51
116
  if (!validId)
52
117
  return;
53
- fetch(`${fileOriginalUrl(fileId)}/meta`)
54
- .then((res) => (res.ok ? res.json() : null))
55
- .then((json) => {
118
+ void loadFileMeta(fileId).then((loaded) => {
56
119
  if (!cancelled)
57
- setMeta(parseFileMeta(json));
58
- })
59
- .catch(() => { });
120
+ setMeta(loaded);
121
+ });
60
122
  return () => {
61
123
  cancelled = true;
62
124
  };
@@ -4,9 +4,6 @@ import { PII_ERASED_SENTINEL } from "@cosmicdrift/kumiko-types/kms-adapter-types
4
4
  import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
5
5
  import { cva } from "class-variance-authority";
6
6
  import { ArrowDown, ArrowUp, ArrowUpDown, Building, CalendarDays, Check, ChevronDown, ChevronLeft, ChevronRight, Globe, Hash, KeyRound, Link, Loader2, Lock, Mail, MapPin, MoreHorizontal, Phone, Search, SlidersHorizontal, Tag, Undo2, User, X, } from "lucide-react";
7
- // The main entry pulls Node-only deps (yargs/pngjs) under bundlers that ignore
8
- // package.json#browser (Metro), hence the explicit browser subpath.
9
- import QRCode from "qrcode/lib/browser.js";
10
7
  import { Children, createContext, Fragment, isValidElement, useContext, useEffect, useId, useRef, useState, } from "react";
11
8
  import { fieldIssueText } from "../../field-issue-text.js";
12
9
  import { applyFormatSpec, EMPTY_VALUE_PLACEHOLDER, isSafeHref } from "../../headless/index.js";
@@ -1971,7 +1968,11 @@ function QrSecretValue({ value }) {
1971
1968
  useEffect(() => {
1972
1969
  let cancelled = false;
1973
1970
  setFailed(false);
1974
- QRCode.toString(value, { type: "svg", errorCorrectionLevel: "H" })
1971
+ // Loaded on first QR render, not with the primitives. The main entry pulls
1972
+ // Node-only deps (yargs/pngjs) under bundlers that ignore package.json#browser
1973
+ // (Metro), hence the explicit browser subpath.
1974
+ import("qrcode/lib/browser.js")
1975
+ .then(({ default: QRCode }) => QRCode.toString(value, { type: "svg", errorCorrectionLevel: "H" }))
1975
1976
  .then((result) => {
1976
1977
  if (!cancelled)
1977
1978
  setSvg(result);
@@ -20,7 +20,8 @@ import { DEFAULT_COLOR_SCHEME_VARIABLE } from "./default-color-scheme.js";
20
20
  // AppTheme.defaultColorScheme (CSS variable) applies, otherwise the HTML state.
21
21
  //
22
22
  // Against FOUC the host HTML also needs a synchronous inline script BEFORE
23
- // the stylesheet link (nonce/hash under a strict CSP):
23
+ // the stylesheet link. Under a strict CSP its hash is the sha256 in the build's
24
+ // csp-hashes.json, which runProdApp also hands to hostDispatch as deps.cspScriptHashes:
24
25
  //
25
26
  // <script>try{var s=localStorage.getItem("kumiko:theme");
26
27
  // if(s==="dark"||(s==="auto"&&matchMedia("(prefers-color-scheme: dark)").matches))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer",
3
- "version": "0.383.0",
3
+ "version": "0.384.0",
4
4
  "description": "React renderer for Kumiko screens. The root export holds the platform-agnostic logic (primitives contract, hooks, KumikoScreen, navigation and SSE abstractions); ./headless the DOM-free UI logic, ./live the HTTP dispatcher, ./web the browser bindings (HTML primitives, history navigation, EventSource live events, createKumikoApp).",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -48,8 +48,8 @@
48
48
  }
49
49
  },
50
50
  "dependencies": {
51
- "@cosmicdrift/kumiko-framework": "0.383.0",
52
- "@cosmicdrift/kumiko-types": "0.383.0",
51
+ "@cosmicdrift/kumiko-framework": "0.384.0",
52
+ "@cosmicdrift/kumiko-types": "0.384.0",
53
53
  "@radix-ui/react-dialog": "^1.1.15",
54
54
  "@radix-ui/react-dropdown-menu": "^2.1.16",
55
55
  "@radix-ui/react-label": "^2.1.8",
@@ -92,8 +92,8 @@
92
92
  }
93
93
  },
94
94
  "devDependencies": {
95
- "@cosmicdrift/kumiko-locale-de": "0.383.0",
96
- "@cosmicdrift/kumiko-testing": "0.383.0",
95
+ "@cosmicdrift/kumiko-locale-de": "0.384.0",
96
+ "@cosmicdrift/kumiko-testing": "0.384.0",
97
97
  "@tailwindcss/cli": "^4.3.0",
98
98
  "@testing-library/react": "^16.3.2",
99
99
  "@testing-library/user-event": "^14.6.1",
package/src/changes.json CHANGED
@@ -1,4 +1,22 @@
1
1
  [
2
+ {
3
+ "version": "0.384.0",
4
+ "type": "fix",
5
+ "title": "A section with a visible action is not hidden when all its fields are",
6
+ "detail": "Edit and detail sections (entityEdit, projectionDetail, in every app) whose fields are all hidden by visibility conditions now stay on screen while at least one of their actions is visible. Before, such a section vanished together with its action buttons; apps that relied on that to hide a section must hide the action with its own visible condition."
7
+ },
8
+ {
9
+ "version": "0.384.0",
10
+ "type": "improvement",
11
+ "title": "Fraction digits for number and decimal formats",
12
+ "detail": "{ format: \"number\" | \"decimal\", maximumFractionDigits: 4 } renders 0.0004 as 0.0004 (de: 0,0004) instead of 0; minimumFractionDigits pads. Integers 0 to 20 with min not above max; anything else is ignored and the Intl default (3 digits) applies, so existing formats render unchanged."
13
+ },
14
+ {
15
+ "version": "0.384.0",
16
+ "type": "improvement",
17
+ "title": "DayPicker and QR code load on demand",
18
+ "detail": "The calendar popover and the QR secret value import react-day-picker and qrcode lazily, so screens without a date input or an MFA setup no longer ship them. Date popovers render their grid one tick later; tests should use findBy queries."
19
+ },
2
20
  {
3
21
  "version": "0.383.0",
4
22
  "type": "improvement",