@eifi1/ui-kit 0.50.0 → 0.51.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.
Files changed (143) hide show
  1. package/README.md +14 -12
  2. package/dist/admin/user-roster.js +31 -12
  3. package/dist/admin/user-roster.js.map +1 -1
  4. package/dist/auth/verify-email.d.ts +4 -0
  5. package/dist/auth/verify-email.js.map +1 -1
  6. package/dist/components/alert-banner.d.ts +9 -0
  7. package/dist/components/alert-banner.js +9 -5
  8. package/dist/components/alert-banner.js.map +1 -1
  9. package/dist/components/autocomplete.js +13 -0
  10. package/dist/components/autocomplete.js.map +1 -1
  11. package/dist/components/button-look.d.ts +4 -19
  12. package/dist/components/button-look.js +3 -30
  13. package/dist/components/button-look.js.map +1 -1
  14. package/dist/components/combobox.js +3 -1
  15. package/dist/components/combobox.js.map +1 -1
  16. package/dist/components/data-table-fit.js.map +1 -1
  17. package/dist/components/data-table-virtual.js +1 -0
  18. package/dist/components/data-table-virtual.js.map +1 -1
  19. package/dist/components/field-base.d.ts +31 -1
  20. package/dist/components/field-base.js +28 -0
  21. package/dist/components/field-base.js.map +1 -1
  22. package/dist/components/floating-panel.d.ts +6 -13
  23. package/dist/components/floating-panel.js +2 -9
  24. package/dist/components/floating-panel.js.map +1 -1
  25. package/dist/components/icon-button.js +3 -5
  26. package/dist/components/icon-button.js.map +1 -1
  27. package/dist/components/inline-entity-combobox.js +12 -0
  28. package/dist/components/inline-entity-combobox.js.map +1 -1
  29. package/dist/components/select.js +3 -2
  30. package/dist/components/select.js.map +1 -1
  31. package/dist/components/stat-tile.d.ts +8 -1
  32. package/dist/components/stat-tile.js +7 -3
  33. package/dist/components/stat-tile.js.map +1 -1
  34. package/dist/eslint/index.js +1 -1
  35. package/dist/i18n/german.js +2 -0
  36. package/dist/i18n/german.js.map +1 -1
  37. package/dist/i18n/kit-labels.d.ts +2 -2
  38. package/dist/i18n/kit-labels.js.map +1 -1
  39. package/dist/i18n/locales/en.js +2 -0
  40. package/dist/i18n/locales/en.js.map +2 -2
  41. package/dist/i18n/locales/es.js +2 -0
  42. package/dist/i18n/locales/es.js.map +1 -1
  43. package/dist/i18n/locales/fr.js +2 -0
  44. package/dist/i18n/locales/fr.js.map +1 -1
  45. package/dist/i18n/locales/hu.js +2 -0
  46. package/dist/i18n/locales/hu.js.map +1 -1
  47. package/dist/i18n/locales/it.js +2 -0
  48. package/dist/i18n/locales/it.js.map +1 -1
  49. package/dist/i18n/locales/zh.js +2 -0
  50. package/dist/i18n/locales/zh.js.map +1 -1
  51. package/dist/index.d.ts +3 -1
  52. package/dist/index.js +2 -0
  53. package/dist/index.js.map +1 -1
  54. package/dist/landing/page-seo.d.ts +19 -11
  55. package/dist/landing/page-seo.js +11 -7
  56. package/dist/landing/page-seo.js.map +1 -1
  57. package/dist/landing/public-page.d.ts +57 -0
  58. package/dist/landing/public-page.js +45 -0
  59. package/dist/landing/public-page.js.map +1 -0
  60. package/dist/lib/quiet-focus.d.ts +4 -0
  61. package/dist/lib/quiet-focus.js +19 -0
  62. package/dist/lib/quiet-focus.js.map +1 -0
  63. package/dist/lib/server-wake.d.ts +3 -2
  64. package/dist/lib/server-wake.js +19 -13
  65. package/dist/lib/server-wake.js.map +1 -1
  66. package/dist/shell/app-shell.d.ts +2 -15
  67. package/dist/shell/app-shell.js +7 -26
  68. package/dist/shell/app-shell.js.map +1 -1
  69. package/dist/shell/dev-menu.d.ts +98 -0
  70. package/dist/shell/dev-menu.js +133 -0
  71. package/dist/shell/dev-menu.js.map +1 -0
  72. package/dist/shell/dev-switchers.d.ts +6 -53
  73. package/dist/shell/dev-switchers.js +5 -100
  74. package/dist/shell/dev-switchers.js.map +1 -1
  75. package/dist/shell/top-bar-actions.d.ts +4 -1
  76. package/dist/shell/top-bar-actions.js.map +1 -1
  77. package/dist/shell/top-bar-parts-labels.d.ts +18 -9
  78. package/dist/shell/top-bar-parts-labels.js +2 -0
  79. package/dist/shell/top-bar-parts-labels.js.map +1 -1
  80. package/dist/shell/topbar-action-menu.d.ts +17 -2
  81. package/dist/shell/topbar-action-menu.js +3 -1
  82. package/dist/shell/topbar-action-menu.js.map +1 -1
  83. package/dist/shell.d.ts +3 -1
  84. package/dist/shell.js +3 -1
  85. package/dist/shell.js.map +1 -1
  86. package/dist/testing/index.d.ts +1 -0
  87. package/dist/testing/index.js +4 -0
  88. package/dist/testing/index.js.map +1 -1
  89. package/dist/testing/tour.d.ts +31 -0
  90. package/dist/testing/tour.js +12 -0
  91. package/dist/testing/tour.js.map +1 -0
  92. package/dist/theme/palette-presets.d.ts +0 -3
  93. package/dist/theme/palette-presets.js +0 -2
  94. package/dist/theme/palette-presets.js.map +1 -1
  95. package/dist/tour/tour-engine.d.ts +12 -0
  96. package/dist/tour/tour-engine.js +31 -0
  97. package/dist/tour/tour-engine.js.map +1 -0
  98. package/dist/tour/tour.d.ts +14 -3
  99. package/dist/tour/tour.js +60 -45
  100. package/dist/tour/tour.js.map +1 -1
  101. package/dist/tour/use-tour-url-sync.d.ts +5 -3
  102. package/dist/tour/use-tour-url-sync.js +21 -1
  103. package/dist/tour/use-tour-url-sync.js.map +1 -1
  104. package/package.json +1 -1
  105. package/src/admin/user-roster.tsx +30 -10
  106. package/src/auth/verify-email.tsx +4 -0
  107. package/src/components/alert-banner.tsx +37 -10
  108. package/src/components/autocomplete.tsx +18 -0
  109. package/src/components/button-look.ts +7 -57
  110. package/src/components/combobox.tsx +5 -1
  111. package/src/components/data-table-fit.ts +4 -1
  112. package/src/components/data-table-virtual.ts +14 -1
  113. package/src/components/field-base.tsx +53 -1
  114. package/src/components/floating-panel.tsx +6 -28
  115. package/src/components/icon-button.tsx +6 -8
  116. package/src/components/inline-entity-combobox.tsx +17 -0
  117. package/src/components/select.tsx +3 -6
  118. package/src/components/stat-tile.tsx +27 -9
  119. package/src/i18n/german.ts +2 -0
  120. package/src/i18n/kit-labels.tsx +2 -2
  121. package/src/i18n/locales/es.ts +2 -0
  122. package/src/i18n/locales/fr.ts +2 -0
  123. package/src/i18n/locales/hu.ts +2 -0
  124. package/src/i18n/locales/it.ts +2 -0
  125. package/src/i18n/locales/zh.ts +2 -0
  126. package/src/index.ts +4 -1
  127. package/src/landing/page-seo.ts +28 -16
  128. package/src/landing/public-page.tsx +106 -0
  129. package/src/lib/quiet-focus.ts +32 -0
  130. package/src/lib/server-wake.ts +41 -17
  131. package/src/shell/app-shell.tsx +11 -48
  132. package/src/shell/dev-menu.tsx +259 -0
  133. package/src/shell/dev-switchers.tsx +13 -174
  134. package/src/shell/top-bar-actions.tsx +4 -1
  135. package/src/shell/top-bar-parts-labels.ts +20 -9
  136. package/src/shell/topbar-action-menu.tsx +28 -2
  137. package/src/shell.ts +5 -1
  138. package/src/testing/index.ts +8 -0
  139. package/src/testing/tour.ts +41 -0
  140. package/src/theme/palette-presets.ts +0 -4
  141. package/src/tour/tour-engine.ts +41 -0
  142. package/src/tour/tour.tsx +64 -24
  143. package/src/tour/use-tour-url-sync.ts +33 -4
@@ -4,6 +4,7 @@ import * as ReactRouter from "react-router";
4
4
  import { useLocation, useNavigate } from "react-router";
5
5
  import { useSearchParamsState } from "../hooks/use-search-param-state.js";
6
6
  import { useTourOptional } from "./tour.js";
7
+ import { liveEngine } from "./tour-engine.js";
7
8
  import { devWarnings } from "./tour-gates.js";
8
9
  function navigationInFlight(history, dataRouter, rendered) {
9
10
  const state = dataRouter?.router?.state;
@@ -51,10 +52,12 @@ function useTourUrlSync({ launch, ready = true, stepAliases = [] }) {
51
52
  const aliases = stepAliases.join("\n");
52
53
  const launchRef = useRef(launch);
53
54
  const startRef = useRef(engine?.start ?? noProviderStart);
55
+ const [live] = useState(() => liveEngine(ENGINE_KEYS, NO_PROVIDER_ENGINE));
54
56
  const activeIdRef = useRef(activeId);
55
57
  useLayoutEffect(() => {
56
58
  launchRef.current = launch;
57
59
  startRef.current = engine?.start ?? noProviderStart;
60
+ live.set(engine ?? NO_PROVIDER_ENGINE);
58
61
  activeIdRef.current = active ? activeId : null;
59
62
  });
60
63
  const seenRef = useRef(null);
@@ -70,7 +73,7 @@ function useTourUrlSync({ launch, ready = true, stepAliases = [] }) {
70
73
  if (!pending || !ready) return;
71
74
  pendingRef.current = null;
72
75
  launchingRef.current = true;
73
- void Promise.resolve().then(() => launchRef.current(pending.id, { startIndex: pending.startIndex }, startRef.current)).catch(() => void 0).finally(() => {
76
+ void Promise.resolve().then(() => launchRef.current(pending.id, { startIndex: pending.startIndex }, startRef.current, live.engine)).catch(() => void 0).finally(() => {
74
77
  launchingRef.current = false;
75
78
  setSettled((n) => n + 1);
76
79
  });
@@ -95,6 +98,23 @@ function useTourUrlSync({ launch, ready = true, stepAliases = [] }) {
95
98
  const noProviderStart = () => {
96
99
  if (devWarnings()) console.warn("[ui-kit] useTourUrlSync: the launcher's start needs a TourProvider above it.");
97
100
  };
101
+ const noProviderCall = () => {
102
+ if (devWarnings()) console.warn("[ui-kit] useTourUrlSync: the launcher's engine needs a TourProvider above it.");
103
+ };
104
+ const NO_PROVIDER_ENGINE = {
105
+ active: false,
106
+ activeId: null,
107
+ start: noProviderStart,
108
+ stop: noProviderCall,
109
+ next: noProviderCall,
110
+ prev: noProviderCall,
111
+ goToStep: noProviderCall,
112
+ index: 0,
113
+ total: 0,
114
+ position: 0,
115
+ count: 0
116
+ };
117
+ const ENGINE_KEYS = Object.keys(NO_PROVIDER_ENGINE);
98
118
  const STATES = ["all", "todo", "done"];
99
119
  function useTourCatalogueUrlState(params = {}) {
100
120
  const [filter, setFilter] = useSearchParamsState({
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/tour/use-tour-url-sync.ts"],"sourcesContent":["import { createContext, useContext, useEffect, useLayoutEffect, useRef, useState, type Context } from \"react\";\nimport * as ReactRouter from \"react-router\";\nimport { useLocation, useNavigate, type Navigator } from \"react-router\";\nimport { useSearchParamsState } from \"../hooks/use-search-param-state\";\nimport { useTourOptional, type TourStart } from \"./tour\";\nimport { devWarnings } from \"./tour-gates\";\n\n/* ── useTourUrlSync ─────────────────────────────────────────────────────────── */\n\nexport interface UseTourUrlSyncOptions {\n /**\n * Start the tour `id` at `startIndex` (0-based) — the app's launcher, which builds the\n * steps, awaits its own `prepare()` and passes `onFinish` (the completion, the points\n * toast) to `useTour().start`. An unknown id should do nothing. May return a promise:\n * the parameters are kept until it settles. Under a `TourProvider` the third argument is\n * that provider's `start` (0.50, as `TourProvider launch` gets it), so one launcher serves\n * both. Without a provider it is a `start` that does nothing (a development build warns).\n */\n launch: (id: string, options: { startIndex: number }, start: TourStart) => void | Promise<unknown>;\n /**\n * Are the facts a tour's steps are built from loaded yet (keksdose's `ready`: the\n * caches a branching tour reads)? A deep link waits for it, keeping its parameters, so\n * an F5 mid-tour resumes the right variant at the right step. Default `true`.\n */\n ready?: boolean;\n /**\n * Older names of `tourStep`, read when it is absent and removed with it — kastlan's\n * `ts` (§2.7, for one minor). Their value is a 0-based step index, as kastlan wrote it.\n */\n stepAliases?: readonly string[];\n}\n\ntype Place = { pathname: string; search: string; hash: string };\n\n/**\n * Has the router already moved on from the location React is still rendering?\n * keksdose's check (tour-url-sync.tsx, tours audit 2026-10-02): a mirror write built\n * from the page being LEFT is a replace back onto it, which cancels a step's `go()`.\n *\n * - A data router (`createBrowserRouter`, kastlan): its navigator has no location (build\n * review 0.43, kastlan A2), so the router's own state answers — a navigation still\n * loading, or a location it has committed that React has not rendered yet.\n * - A declarative router (`BrowserRouter`, keksdose): its navigator is its history and\n * carries where it is.\n * - Anything else answers \"not in flight\".\n */\nfunction navigationInFlight(history: Navigator | undefined, dataRouter: DataRouterProbe, rendered: Place) {\n const state = dataRouter?.router?.state;\n if (state && (state.navigation?.state ?? \"idle\") !== \"idle\") return true;\n const live = state?.location ?? (history as (Navigator & { location?: Partial<Place> }) | undefined)?.location;\n if (!live) return false;\n return (\n live.pathname !== rendered.pathname || (live.search ?? \"\") !== rendered.search || (live.hash ?? \"\") !== rendered.hash\n );\n}\n\ntype NavigationProbe = { navigator?: Navigator } | null;\nconst NO_NAVIGATION = createContext<NavigationProbe>(null);\n/** The data router's state, as `useSearchParamState` reads it. */\ntype DataRouterProbe = {\n router?: { state?: { navigation?: { state?: string }; location?: Partial<Place> } };\n} | null;\nconst NO_DATA_ROUTER = createContext<DataRouterProbe>(null);\nconst DataRouterContext: Context<DataRouterProbe> = (() => {\n try {\n return (\n (ReactRouter as unknown as { UNSAFE_DataRouterContext?: Context<DataRouterProbe> }).UNSAFE_DataRouterContext ??\n NO_DATA_ROUTER\n );\n } catch {\n return NO_DATA_ROUTER;\n }\n})();\n/** Read off the namespace inside a `try`, as `useSearchParamState` reads the data router:\n * a test's partial `vi.mock(\"react-router\")` throws on an export it does not define. */\nconst NavigationContext: Context<NavigationProbe> = (() => {\n try {\n return (\n (ReactRouter as unknown as { UNSAFE_NavigationContext?: Context<NavigationProbe> }).UNSAFE_NavigationContext ??\n NO_NAVIGATION\n );\n } catch {\n return NO_NAVIGATION;\n }\n})();\n\n/** The step a link asks for, 0-based: `tourStep` is 1-based (keksdose), an alias 0-based. */\nfunction stepOf(params: URLSearchParams, aliases: readonly string[]): number {\n const step = Number(params.get(\"tourStep\"));\n if (params.has(\"tourStep\")) return Number.isFinite(step) && step > 0 ? step - 1 : 0;\n for (const alias of aliases) {\n const value = Number(params.get(alias));\n if (params.has(alias)) return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;\n }\n return 0;\n}\n\n/**\n * Tours and the URL (docs/tours-harmonization.md §2.7, §6.4) — keksdose's `TourUrlSync`,\n * on the kit's engine (`useTourOptional`):\n *\n * - **`?tour=<id>` starts that tour whenever it APPEARS** — on load, and on in-app\n * navigation too (kastlan's assistant links to `/help/tours?tour=<id>`, ka F4) —\n * resuming at `&tourStep=<n>` (1-based). It waits for `ready`.\n * - **While a tour runs, `?tour=&tourStep=` mirror where it is**, so a reload or a copied\n * link pins the step (kk K-3). A step that navigates drops them; this puts them back.\n * - **At the end it strips only its own parameters**; `?q=`, `?state=` and the hash stay.\n *\n * Mount it once inside the router and the `TourProvider`. Without a provider it still\n * launches through `launch` and never mirrors.\n */\nexport function useTourUrlSync({ launch, ready = true, stepAliases = [] }: UseTourUrlSyncOptions): void {\n const navigate = useNavigate();\n const location = useLocation();\n const { pathname, search, hash } = location;\n const history = useContext(NavigationContext)?.navigator;\n const dataRouter = useContext(DataRouterContext);\n const engine = useTourOptional();\n const active = engine?.active ?? false;\n const activeId = engine?.activeId ?? null;\n const index = engine?.index ?? 0;\n const urlTour = new URLSearchParams(search).get(\"tour\");\n const aliases = stepAliases.join(\"\\n\");\n\n const launchRef = useRef(launch);\n const startRef = useRef<TourStart>(engine?.start ?? noProviderStart);\n const activeIdRef = useRef(activeId);\n useLayoutEffect(() => {\n launchRef.current = launch;\n startRef.current = engine?.start ?? noProviderStart;\n activeIdRef.current = active ? activeId : null;\n });\n /** The `tour` value last seen in the URL: a tour starts when it CHANGES to an id. */\n const seenRef = useRef<string | null>(null);\n /** A deep link waiting for `ready`. */\n const pendingRef = useRef<{ id: string; startIndex: number } | null>(null);\n /** A launch under way (an app's `prepare()`). */\n const launchingRef = useRef(false);\n /** Bumped when a launch settles, so the mirror looks again. */\n const [settled, setSettled] = useState(0);\n\n useEffect(() => {\n if (urlTour !== seenRef.current) {\n seenRef.current = urlTour;\n // Its own mirror writing the running tour back is no new link.\n pendingRef.current =\n urlTour && urlTour !== activeIdRef.current\n ? { id: urlTour, startIndex: stepOf(new URLSearchParams(search), aliases ? aliases.split(\"\\n\") : []) }\n : null;\n }\n const pending = pendingRef.current;\n if (!pending || !ready) return;\n pendingRef.current = null;\n launchingRef.current = true;\n void Promise.resolve()\n .then(() => launchRef.current(pending.id, { startIndex: pending.startIndex }, startRef.current))\n .catch(() => undefined)\n .finally(() => {\n launchingRef.current = false;\n setSettled((n) => n + 1);\n });\n // Only a change of the parameter, or readiness, starts a tour.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [urlTour, ready]);\n\n useEffect(() => {\n if (navigationInFlight(history, dataRouter, { pathname, search, hash })) return;\n // A link not yet used keeps its parameters: a reload in that window resumes.\n if (pendingRef.current || launchingRef.current) return;\n const tourId = active && activeId ? activeId : null;\n const own = [\"tour\", \"tourStep\", ...(aliases ? aliases.split(\"\\n\") : [])];\n const current = new URLSearchParams(search);\n // Nothing to mirror and nothing to strip: never touch the URL (a no-op replace\n // clobbers a page's own just-issued navigation, keksdose feedback #4).\n if (!tourId && !own.some((key) => current.has(key))) return;\n const next = new URLSearchParams(search);\n // `set` keeps a parameter where it stands; only the aliases (and, at the end, the\n // tour's own) go.\n for (const key of tourId ? own.slice(2) : own) next.delete(key);\n if (tourId) {\n next.set(\"tour\", tourId);\n next.set(\"tourStep\", String(index + 1));\n }\n if (next.toString() === current.toString()) return;\n // Spelled out, not `setSearchParams` (\"?…\" drops the hash, keksdose dev#495).\n void navigate({ pathname, search: next.toString(), hash }, { replace: true, state: location.state });\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [active, activeId, index, pathname, search, hash, settled, aliases]);\n}\n\n/** `start` for a launcher run with no `TourProvider` above `useTourUrlSync`. */\nconst noProviderStart: TourStart = () => {\n if (devWarnings()) console.warn(\"[ui-kit] useTourUrlSync: the launcher's start needs a TourProvider above it.\");\n};\n\n/* ── useTourCatalogueUrlState ───────────────────────────────────────────────── */\n\n/** The catalogue's state filter. */\nexport type TourStateFilter = \"all\" | \"todo\" | \"done\";\n\n/** The catalogue's filters: the search, the group (`null`: every group), the state. */\nexport interface TourCatalogueFilter {\n query: string;\n group: string | null;\n state: TourStateFilter;\n}\n\nconst STATES: readonly TourStateFilter[] = [\"all\", \"todo\", \"done\"];\n\n/**\n * The catalogue's filters in the URL (§2.8) — keksdose's `?q`, `?topic`, `?state`, each\n * written with `replace` (typing does not bury the Back button), the defaults as the\n * clean URL. Spread it onto the catalogue: `<TourCatalogue {...useTourCatalogueUrlState()} … />`.\n * `params` renames them (kastlan's `?group`). Requires a react-router Router.\n */\nexport function useTourCatalogueUrlState(\n params: { query?: string; group?: string; state?: string } = {},\n): TourCatalogueFilter & { onChange: (patch: Partial<TourCatalogueFilter>) => void } {\n const [filter, setFilter] = useSearchParamsState<TourCatalogueFilter>({\n query: { param: params.query ?? \"q\", default: \"\", history: \"replace\" },\n group: { param: params.group ?? \"topic\", default: null, history: \"replace\", parse: (raw) => raw || undefined },\n state: {\n param: params.state ?? \"state\",\n default: \"all\",\n history: \"replace\",\n parse: (raw) => STATES.find((x) => x === raw),\n },\n });\n // `group: null` is a value to the setter (it clears `?topic`); `undefined` leaves it.\n return { ...filter, onChange: setFilter };\n}\n"],"mappings":";AAAA,SAAS,eAAe,YAAY,WAAW,iBAAiB,QAAQ,gBAA8B;AACtG,YAAY,iBAAiB;AAC7B,SAAS,aAAa,mBAAmC;AACzD,SAAS,4BAA4B;AACrC,SAAS,uBAAuC;AAChD,SAAS,mBAAmB;AAyC5B,SAAS,mBAAmB,SAAgC,YAA6B,UAAiB;AACxG,QAAM,QAAQ,YAAY,QAAQ;AAClC,MAAI,UAAU,MAAM,YAAY,SAAS,YAAY,OAAQ,QAAO;AACpE,QAAM,OAAO,OAAO,YAAa,SAAqE;AACtG,MAAI,CAAC,KAAM,QAAO;AAClB,SACE,KAAK,aAAa,SAAS,aAAa,KAAK,UAAU,QAAQ,SAAS,WAAW,KAAK,QAAQ,QAAQ,SAAS;AAErH;AAGA,MAAM,gBAAgB,cAA+B,IAAI;AAKzD,MAAM,iBAAiB,cAA+B,IAAI;AAC1D,MAAM,qBAA+C,MAAM;AACzD,MAAI;AACF,WACG,YAAmF,4BACpF;AAAA,EAEJ,QAAQ;AACN,WAAO;AAAA,EACT;AACF,GAAG;AAGH,MAAM,qBAA+C,MAAM;AACzD,MAAI;AACF,WACG,YAAmF,4BACpF;AAAA,EAEJ,QAAQ;AACN,WAAO;AAAA,EACT;AACF,GAAG;AAGH,SAAS,OAAO,QAAyB,SAAoC;AAC3E,QAAM,OAAO,OAAO,OAAO,IAAI,UAAU,CAAC;AAC1C,MAAI,OAAO,IAAI,UAAU,EAAG,QAAO,OAAO,SAAS,IAAI,KAAK,OAAO,IAAI,OAAO,IAAI;AAClF,aAAW,SAAS,SAAS;AAC3B,UAAM,QAAQ,OAAO,OAAO,IAAI,KAAK,CAAC;AACtC,QAAI,OAAO,IAAI,KAAK,EAAG,QAAO,OAAO,SAAS,KAAK,KAAK,QAAQ,IAAI,KAAK,MAAM,KAAK,IAAI;AAAA,EAC1F;AACA,SAAO;AACT;AAgBO,SAAS,eAAe,EAAE,QAAQ,QAAQ,MAAM,cAAc,CAAC,EAAE,GAAgC;AACtG,QAAM,WAAW,YAAY;AAC7B,QAAM,WAAW,YAAY;AAC7B,QAAM,EAAE,UAAU,QAAQ,KAAK,IAAI;AACnC,QAAM,UAAU,WAAW,iBAAiB,GAAG;AAC/C,QAAM,aAAa,WAAW,iBAAiB;AAC/C,QAAM,SAAS,gBAAgB;AAC/B,QAAM,SAAS,QAAQ,UAAU;AACjC,QAAM,WAAW,QAAQ,YAAY;AACrC,QAAM,QAAQ,QAAQ,SAAS;AAC/B,QAAM,UAAU,IAAI,gBAAgB,MAAM,EAAE,IAAI,MAAM;AACtD,QAAM,UAAU,YAAY,KAAK,IAAI;AAErC,QAAM,YAAY,OAAO,MAAM;AAC/B,QAAM,WAAW,OAAkB,QAAQ,SAAS,eAAe;AACnE,QAAM,cAAc,OAAO,QAAQ;AACnC,kBAAgB,MAAM;AACpB,cAAU,UAAU;AACpB,aAAS,UAAU,QAAQ,SAAS;AACpC,gBAAY,UAAU,SAAS,WAAW;AAAA,EAC5C,CAAC;AAED,QAAM,UAAU,OAAsB,IAAI;AAE1C,QAAM,aAAa,OAAkD,IAAI;AAEzE,QAAM,eAAe,OAAO,KAAK;AAEjC,QAAM,CAAC,SAAS,UAAU,IAAI,SAAS,CAAC;AAExC,YAAU,MAAM;AACd,QAAI,YAAY,QAAQ,SAAS;AAC/B,cAAQ,UAAU;AAElB,iBAAW,UACT,WAAW,YAAY,YAAY,UAC/B,EAAE,IAAI,SAAS,YAAY,OAAO,IAAI,gBAAgB,MAAM,GAAG,UAAU,QAAQ,MAAM,IAAI,IAAI,CAAC,CAAC,EAAE,IACnG;AAAA,IACR;AACA,UAAM,UAAU,WAAW;AAC3B,QAAI,CAAC,WAAW,CAAC,MAAO;AACxB,eAAW,UAAU;AACrB,iBAAa,UAAU;AACvB,SAAK,QAAQ,QAAQ,EAClB,KAAK,MAAM,UAAU,QAAQ,QAAQ,IAAI,EAAE,YAAY,QAAQ,WAAW,GAAG,SAAS,OAAO,CAAC,EAC9F,MAAM,MAAM,MAAS,EACrB,QAAQ,MAAM;AACb,mBAAa,UAAU;AACvB,iBAAW,CAAC,MAAM,IAAI,CAAC;AAAA,IACzB,CAAC;AAAA,EAGL,GAAG,CAAC,SAAS,KAAK,CAAC;AAEnB,YAAU,MAAM;AACd,QAAI,mBAAmB,SAAS,YAAY,EAAE,UAAU,QAAQ,KAAK,CAAC,EAAG;AAEzE,QAAI,WAAW,WAAW,aAAa,QAAS;AAChD,UAAM,SAAS,UAAU,WAAW,WAAW;AAC/C,UAAM,MAAM,CAAC,QAAQ,YAAY,GAAI,UAAU,QAAQ,MAAM,IAAI,IAAI,CAAC,CAAE;AACxE,UAAM,UAAU,IAAI,gBAAgB,MAAM;AAG1C,QAAI,CAAC,UAAU,CAAC,IAAI,KAAK,CAAC,QAAQ,QAAQ,IAAI,GAAG,CAAC,EAAG;AACrD,UAAM,OAAO,IAAI,gBAAgB,MAAM;AAGvC,eAAW,OAAO,SAAS,IAAI,MAAM,CAAC,IAAI,IAAK,MAAK,OAAO,GAAG;AAC9D,QAAI,QAAQ;AACV,WAAK,IAAI,QAAQ,MAAM;AACvB,WAAK,IAAI,YAAY,OAAO,QAAQ,CAAC,CAAC;AAAA,IACxC;AACA,QAAI,KAAK,SAAS,MAAM,QAAQ,SAAS,EAAG;AAE5C,SAAK,SAAS,EAAE,UAAU,QAAQ,KAAK,SAAS,GAAG,KAAK,GAAG,EAAE,SAAS,MAAM,OAAO,SAAS,MAAM,CAAC;AAAA,EAErG,GAAG,CAAC,QAAQ,UAAU,OAAO,UAAU,QAAQ,MAAM,SAAS,OAAO,CAAC;AACxE;AAGA,MAAM,kBAA6B,MAAM;AACvC,MAAI,YAAY,EAAG,SAAQ,KAAK,8EAA8E;AAChH;AAcA,MAAM,SAAqC,CAAC,OAAO,QAAQ,MAAM;AAQ1D,SAAS,yBACd,SAA6D,CAAC,GACqB;AACnF,QAAM,CAAC,QAAQ,SAAS,IAAI,qBAA0C;AAAA,IACpE,OAAO,EAAE,OAAO,OAAO,SAAS,KAAK,SAAS,IAAI,SAAS,UAAU;AAAA,IACrE,OAAO,EAAE,OAAO,OAAO,SAAS,SAAS,SAAS,MAAM,SAAS,WAAW,OAAO,CAAC,QAAQ,OAAO,OAAU;AAAA,IAC7G,OAAO;AAAA,MACL,OAAO,OAAO,SAAS;AAAA,MACvB,SAAS;AAAA,MACT,SAAS;AAAA,MACT,OAAO,CAAC,QAAQ,OAAO,KAAK,CAAC,MAAM,MAAM,GAAG;AAAA,IAC9C;AAAA,EACF,CAAC;AAED,SAAO,EAAE,GAAG,QAAQ,UAAU,UAAU;AAC1C;","names":[]}
1
+ {"version":3,"sources":["../../src/tour/use-tour-url-sync.ts"],"sourcesContent":["import { createContext, useContext, useEffect, useLayoutEffect, useRef, useState, type Context } from \"react\";\nimport * as ReactRouter from \"react-router\";\nimport { useLocation, useNavigate, type Navigator } from \"react-router\";\nimport { useSearchParamsState } from \"../hooks/use-search-param-state\";\nimport { useTourOptional, type TourEngine, type TourStart } from \"./tour\";\nimport { liveEngine } from \"./tour-engine\";\nimport { devWarnings } from \"./tour-gates\";\n\n/* ── useTourUrlSync ─────────────────────────────────────────────────────────── */\n\nexport interface UseTourUrlSyncOptions {\n /**\n * Start the tour `id` at `startIndex` (0-based) — the app's launcher, which builds the\n * steps, awaits its own `prepare()` and passes `onFinish` (the completion, the points\n * toast) to `useTour().start`. An unknown id should do nothing. May return a promise:\n * the parameters are kept until it settles. Under a `TourProvider` the third argument is\n * that provider's `start` (0.50, as `TourProvider launch` gets it), so one launcher serves\n * both; the fourth is its whole engine, read live (0.50.1, `TourEngine`: `stop`, `next`,\n * `active`, …). Without a provider `start` and the engine's methods do nothing (a\n * development build warns) and the engine reads as idle.\n */\n launch: (\n id: string,\n options: { startIndex: number },\n start: TourStart,\n engine: TourEngine,\n ) => void | Promise<unknown>;\n /**\n * Are the facts a tour's steps are built from loaded yet (keksdose's `ready`: the\n * caches a branching tour reads)? A deep link waits for it, keeping its parameters, so\n * an F5 mid-tour resumes the right variant at the right step. Default `true`.\n */\n ready?: boolean;\n /**\n * Older names of `tourStep`, read when it is absent and removed with it — kastlan's\n * `ts` (§2.7, for one minor). Their value is a 0-based step index, as kastlan wrote it.\n */\n stepAliases?: readonly string[];\n}\n\ntype Place = { pathname: string; search: string; hash: string };\n\n/**\n * Has the router already moved on from the location React is still rendering?\n * keksdose's check (tour-url-sync.tsx, tours audit 2026-10-02): a mirror write built\n * from the page being LEFT is a replace back onto it, which cancels a step's `go()`.\n *\n * - A data router (`createBrowserRouter`, kastlan): its navigator has no location (build\n * review 0.43, kastlan A2), so the router's own state answers — a navigation still\n * loading, or a location it has committed that React has not rendered yet.\n * - A declarative router (`BrowserRouter`, keksdose): its navigator is its history and\n * carries where it is.\n * - Anything else answers \"not in flight\".\n */\nfunction navigationInFlight(history: Navigator | undefined, dataRouter: DataRouterProbe, rendered: Place) {\n const state = dataRouter?.router?.state;\n if (state && (state.navigation?.state ?? \"idle\") !== \"idle\") return true;\n const live = state?.location ?? (history as (Navigator & { location?: Partial<Place> }) | undefined)?.location;\n if (!live) return false;\n return (\n live.pathname !== rendered.pathname || (live.search ?? \"\") !== rendered.search || (live.hash ?? \"\") !== rendered.hash\n );\n}\n\ntype NavigationProbe = { navigator?: Navigator } | null;\nconst NO_NAVIGATION = createContext<NavigationProbe>(null);\n/** The data router's state, as `useSearchParamState` reads it. */\ntype DataRouterProbe = {\n router?: { state?: { navigation?: { state?: string }; location?: Partial<Place> } };\n} | null;\nconst NO_DATA_ROUTER = createContext<DataRouterProbe>(null);\nconst DataRouterContext: Context<DataRouterProbe> = (() => {\n try {\n return (\n (ReactRouter as unknown as { UNSAFE_DataRouterContext?: Context<DataRouterProbe> }).UNSAFE_DataRouterContext ??\n NO_DATA_ROUTER\n );\n } catch {\n return NO_DATA_ROUTER;\n }\n})();\n/** Read off the namespace inside a `try`, as `useSearchParamState` reads the data router:\n * a test's partial `vi.mock(\"react-router\")` throws on an export it does not define. */\nconst NavigationContext: Context<NavigationProbe> = (() => {\n try {\n return (\n (ReactRouter as unknown as { UNSAFE_NavigationContext?: Context<NavigationProbe> }).UNSAFE_NavigationContext ??\n NO_NAVIGATION\n );\n } catch {\n return NO_NAVIGATION;\n }\n})();\n\n/** The step a link asks for, 0-based: `tourStep` is 1-based (keksdose), an alias 0-based. */\nfunction stepOf(params: URLSearchParams, aliases: readonly string[]): number {\n const step = Number(params.get(\"tourStep\"));\n if (params.has(\"tourStep\")) return Number.isFinite(step) && step > 0 ? step - 1 : 0;\n for (const alias of aliases) {\n const value = Number(params.get(alias));\n if (params.has(alias)) return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;\n }\n return 0;\n}\n\n/**\n * Tours and the URL (docs/tours-harmonization.md §2.7, §6.4) — keksdose's `TourUrlSync`,\n * on the kit's engine (`useTourOptional`):\n *\n * - **`?tour=<id>` starts that tour whenever it APPEARS** — on load, and on in-app\n * navigation too (kastlan's assistant links to `/help/tours?tour=<id>`, ka F4) —\n * resuming at `&tourStep=<n>` (1-based). It waits for `ready`.\n * - **While a tour runs, `?tour=&tourStep=` mirror where it is**, so a reload or a copied\n * link pins the step (kk K-3). A step that navigates drops them; this puts them back.\n * - **At the end it strips only its own parameters**; `?q=`, `?state=` and the hash stay.\n *\n * Mount it once inside the router and the `TourProvider`. Without a provider it still\n * launches through `launch` and never mirrors.\n */\nexport function useTourUrlSync({ launch, ready = true, stepAliases = [] }: UseTourUrlSyncOptions): void {\n const navigate = useNavigate();\n const location = useLocation();\n const { pathname, search, hash } = location;\n const history = useContext(NavigationContext)?.navigator;\n const dataRouter = useContext(DataRouterContext);\n const engine = useTourOptional();\n const active = engine?.active ?? false;\n const activeId = engine?.activeId ?? null;\n const index = engine?.index ?? 0;\n const urlTour = new URLSearchParams(search).get(\"tour\");\n const aliases = stepAliases.join(\"\\n\");\n\n const launchRef = useRef(launch);\n const startRef = useRef<TourStart>(engine?.start ?? noProviderStart);\n const [live] = useState(() => liveEngine<TourEngine>(ENGINE_KEYS, NO_PROVIDER_ENGINE));\n const activeIdRef = useRef(activeId);\n useLayoutEffect(() => {\n launchRef.current = launch;\n startRef.current = engine?.start ?? noProviderStart;\n live.set(engine ?? NO_PROVIDER_ENGINE);\n activeIdRef.current = active ? activeId : null;\n });\n /** The `tour` value last seen in the URL: a tour starts when it CHANGES to an id. */\n const seenRef = useRef<string | null>(null);\n /** A deep link waiting for `ready`. */\n const pendingRef = useRef<{ id: string; startIndex: number } | null>(null);\n /** A launch under way (an app's `prepare()`). */\n const launchingRef = useRef(false);\n /** Bumped when a launch settles, so the mirror looks again. */\n const [settled, setSettled] = useState(0);\n\n useEffect(() => {\n if (urlTour !== seenRef.current) {\n seenRef.current = urlTour;\n // Its own mirror writing the running tour back is no new link.\n pendingRef.current =\n urlTour && urlTour !== activeIdRef.current\n ? { id: urlTour, startIndex: stepOf(new URLSearchParams(search), aliases ? aliases.split(\"\\n\") : []) }\n : null;\n }\n const pending = pendingRef.current;\n if (!pending || !ready) return;\n pendingRef.current = null;\n launchingRef.current = true;\n void Promise.resolve()\n .then(() => launchRef.current(pending.id, { startIndex: pending.startIndex }, startRef.current, live.engine))\n .catch(() => undefined)\n .finally(() => {\n launchingRef.current = false;\n setSettled((n) => n + 1);\n });\n // Only a change of the parameter, or readiness, starts a tour.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [urlTour, ready]);\n\n useEffect(() => {\n if (navigationInFlight(history, dataRouter, { pathname, search, hash })) return;\n // A link not yet used keeps its parameters: a reload in that window resumes.\n if (pendingRef.current || launchingRef.current) return;\n const tourId = active && activeId ? activeId : null;\n const own = [\"tour\", \"tourStep\", ...(aliases ? aliases.split(\"\\n\") : [])];\n const current = new URLSearchParams(search);\n // Nothing to mirror and nothing to strip: never touch the URL (a no-op replace\n // clobbers a page's own just-issued navigation, keksdose feedback #4).\n if (!tourId && !own.some((key) => current.has(key))) return;\n const next = new URLSearchParams(search);\n // `set` keeps a parameter where it stands; only the aliases (and, at the end, the\n // tour's own) go.\n for (const key of tourId ? own.slice(2) : own) next.delete(key);\n if (tourId) {\n next.set(\"tour\", tourId);\n next.set(\"tourStep\", String(index + 1));\n }\n if (next.toString() === current.toString()) return;\n // Spelled out, not `setSearchParams` (\"?…\" drops the hash, keksdose dev#495).\n void navigate({ pathname, search: next.toString(), hash }, { replace: true, state: location.state });\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [active, activeId, index, pathname, search, hash, settled, aliases]);\n}\n\n/** `start` for a launcher run with no `TourProvider` above `useTourUrlSync`. */\nconst noProviderStart: TourStart = () => {\n if (devWarnings()) console.warn(\"[ui-kit] useTourUrlSync: the launcher's start needs a TourProvider above it.\");\n};\n\nconst noProviderCall = () => {\n if (devWarnings()) console.warn(\"[ui-kit] useTourUrlSync: the launcher's engine needs a TourProvider above it.\");\n};\n/** The engine for a launcher run with no `TourProvider` above `useTourUrlSync`: idle. */\nconst NO_PROVIDER_ENGINE: TourEngine = {\n active: false,\n activeId: null,\n start: noProviderStart,\n stop: noProviderCall,\n next: noProviderCall,\n prev: noProviderCall,\n goToStep: noProviderCall,\n index: 0,\n total: 0,\n position: 0,\n count: 0,\n};\nconst ENGINE_KEYS = Object.keys(NO_PROVIDER_ENGINE) as (keyof TourEngine)[];\n\n/* ── useTourCatalogueUrlState ───────────────────────────────────────────────── */\n\n/** The catalogue's state filter. */\nexport type TourStateFilter = \"all\" | \"todo\" | \"done\";\n\n/** The catalogue's filters: the search, the group (`null`: every group), the state. */\nexport interface TourCatalogueFilter {\n query: string;\n group: string | null;\n state: TourStateFilter;\n}\n\nconst STATES: readonly TourStateFilter[] = [\"all\", \"todo\", \"done\"];\n\n/**\n * The catalogue's filters in the URL (§2.8) — keksdose's `?q`, `?topic`, `?state`, each\n * written with `replace` (typing does not bury the Back button), the defaults as the\n * clean URL. Spread it onto the catalogue: `<TourCatalogue {...useTourCatalogueUrlState()} … />`.\n * `params` renames them (kastlan's `?group`). Requires a react-router Router.\n */\nexport function useTourCatalogueUrlState(\n params: { query?: string; group?: string; state?: string } = {},\n): TourCatalogueFilter & { onChange: (patch: Partial<TourCatalogueFilter>) => void } {\n const [filter, setFilter] = useSearchParamsState<TourCatalogueFilter>({\n query: { param: params.query ?? \"q\", default: \"\", history: \"replace\" },\n group: { param: params.group ?? \"topic\", default: null, history: \"replace\", parse: (raw) => raw || undefined },\n state: {\n param: params.state ?? \"state\",\n default: \"all\",\n history: \"replace\",\n parse: (raw) => STATES.find((x) => x === raw),\n },\n });\n // `group: null` is a value to the setter (it clears `?topic`); `undefined` leaves it.\n return { ...filter, onChange: setFilter };\n}\n"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eifi1/ui-kit",
3
- "version": "0.50.0",
3
+ "version": "0.51.0",
4
4
  "type": "module",
5
5
  "description": "App-agnostic shared design system: domain-free React components, theme and tokens.",
6
6
  "_sideEffects": "The barrel (src/index.ts) re-exports EVERY component, including components/chart, which statically imports recharts. Without this field a bundler must assume each of those modules has side effects, so it cannot drop the unused ones: importing a Button from '@eifi1/ui-kit' dragged all 433KB of recharts into the consumer's ENTRY chunk, and Keksdose was preloading it on every page load, login included. Nothing in src/ imports for side effects (no bare imports, no CSS imports from JS, no module-level global writes) — the token stylesheet is its own './tokens.css' export — so this is safe. CSS is still listed so any future stylesheet survives. Re-checked when `flag-icons` left `dependencies` and tsup started emitting a `\"use client\"` banner: neither touches this. The flag stylesheet was never imported from JS in the first place (that was the finding), and a directive prologue is a string literal every bundler lifts out before it decides whether a module is inert.",
@@ -8,6 +8,7 @@ import { AccountStateChip, RoleChip, dateColumn } from "../components/account-ch
8
8
  import type { AccountState, RoleVocabulary } from "../components/account-chips";
9
9
  import type { ChipTone } from "../components/chip";
10
10
  import type { DataTableColumn } from "../components/data-table";
11
+ import { SelectWidthFloor } from "../components/field-base";
11
12
  import { encodeSorts } from "../components/data-table-sort";
12
13
  import type { SortState } from "../components/data-table-sort";
13
14
  import { RowActions } from "../components/row-actions";
@@ -298,6 +299,13 @@ function rolesOf<T extends UserRosterRow, R extends string>(
298
299
  return typeof raw === "string" ? [raw as R] : [...(raw as readonly R[])];
299
300
  }
300
301
 
302
+ /** What a row's role select can show as its value: every role's word, and a held key
303
+ * the vocabulary does not know (RoleSelect shows it as the key). */
304
+ function roleWordsOf<R extends string>(roles: RoleVocabulary<R>, held: R | undefined): string[] {
305
+ const words = (Object.keys(roles) as R[]).map((role) => roles[role].label);
306
+ return held !== undefined && !(held in roles) ? [...words, held] : words;
307
+ }
308
+
301
309
  /** The row's name in the reader's order — a component, because the order is the
302
310
  * READER's and the locale comes from the provider. */
303
311
  function RowName<T extends UserRosterRow>({ row, children }: { row: T; children: (name: string) => ReactNode }) {
@@ -423,16 +431,28 @@ export function userRosterColumns<T extends UserRosterRow, R extends string = st
423
431
  return (
424
432
  <RowName row={row}>
425
433
  {(name) => (
426
- <RoleSelect<R>
427
- size="sm"
428
- name={name}
429
- value={held[0] ?? null}
430
- roles={roles}
431
- offered={rules.offered}
432
- disabledReason={rules.disabledReason}
433
- optionDisabledReason={rules.optionDisabledReason}
434
- onChange={(role) => onRoleChange(row, role)}
435
- />
434
+ // The column keeps room for the select's longest ROLE word in the app's
435
+ // language (0.51, Kurvenschmiede N5): beside the `grow` Name every other
436
+ // column is squeezed to its min-content, which a `w-full` select does not
437
+ // have, so the role select was the header's ~92px and read "Meml…",
438
+ // "Admi…". Every word of the vocabulary, not only the row's offered
439
+ // ones: the column is as wide as its widest row, so the rows line up, and
440
+ // a role held but not offered (KS's Reviewer) is still a selected value.
441
+ // Not the options' reasons ("Customer (owns work)"): they are never the
442
+ // selected value, and the open list is as wide as it needs anyway.
443
+ <div>
444
+ <RoleSelect<R>
445
+ size="sm"
446
+ name={name}
447
+ value={held[0] ?? null}
448
+ roles={roles}
449
+ offered={rules.offered}
450
+ disabledReason={rules.disabledReason}
451
+ optionDisabledReason={rules.optionDisabledReason}
452
+ onChange={(role) => onRoleChange(row, role)}
453
+ />
454
+ <SelectWidthFloor size="sm" values={roleWordsOf(roles, held[0])} />
455
+ </div>
436
456
  )}
437
457
  </RowName>
438
458
  );
@@ -364,6 +364,10 @@ export interface EmailVerificationBannerProps {
364
364
  * of a 390px phone). The resend's outcome ("Sent", the error) then moves out of the
365
365
  * message to sit after "Send again", at every width, so the line that answers the press
366
366
  * is never hidden behind "More". Default `false`: the outcome stays in the message.
367
+ *
368
+ * 0.51 (kastlan K1, KX-5): in the `strip` variant "Send again" then stays on the clamped
369
+ * message's line on a phone — one line, 43px, on a 390px phone in all seven catalogues —
370
+ * where it took a line of its own (71px). The box's equivalent is `actionPlacement`.
367
371
  */
368
372
  clampMobile?: boolean;
369
373
  /**
@@ -206,6 +206,15 @@ interface AlertBannerBaseProps extends Omit<ComponentPropsWithoutRef<"div">, "on
206
206
  * every phone screen, and the app built the clamp and the button around the banner's
207
207
  * children by hand. Ignored on a whole-row banner (`href` / `onClick`): a toggle cannot
208
208
  * sit inside the row's own link or button.
209
+ *
210
+ * 0.51 (kastlan K1): on a `strip` the clamped message claims 7rem instead of 12rem below
211
+ * `md`, so the {@link action} stays on its line rather than taking one of its own.
212
+ *
213
+ * For a one-line MESSAGE (a sentence or two). Not for a body with a list or a control in
214
+ * it: the clamp cuts the box to its first line, so a Disclosure's or a link's trigger
215
+ * past it is clipped out of sight and still takes the keyboard's focus, and "More" gives
216
+ * back the whole body at once. Shorten such a banner instead — one sentence, its action,
217
+ * and the rest behind a Disclosure of its own (keksdose's agree-again notice, 0.51).
209
218
  */
210
219
  clampMobile?: boolean;
211
220
  /**
@@ -300,7 +309,7 @@ export function AlertBanner({
300
309
  // server) the box is 0 tall, which reads as "may not fit": the toggle stays.
301
310
  const clamp = clampMobile && !interactive;
302
311
  const bodyId = useId();
303
- const bodyRef = useRef<HTMLSpanElement>(null);
312
+ const bodyRef = useRef<HTMLDivElement>(null);
304
313
  const [expanded, setExpanded] = useState(false);
305
314
  const [fits, setFits] = useState(false);
306
315
  useLayoutEffect(() => {
@@ -357,6 +366,17 @@ export function AlertBanner({
357
366
  // into a three-word column on a phone instead. With a real basis the action drops to
358
367
  // a second line once message + action no longer fit, and the message keeps the width.
359
368
  const grow = strip ? "flex-[1_1_12rem]" : "flex-1";
369
+ // …except a CLAMPED strip on a phone (0.51, kastlan K1 / prod #22, KX-5). There the
370
+ // message is one line whatever its width (the rest is behind "More"), so the 12rem only
371
+ // pushed the action under it: "Erneut senden" took a line of its own on a 390px phone,
372
+ // and kastlan's banner stood 71px where one line is 43. The clamp needs its "More" and
373
+ // the first words beside it, so below `md` the basis is 7rem (~4rem of text). Measured
374
+ // in Chromium at 390 × 844, kastlan's `mx-4` strip (326px inside): one line, 43px, in
375
+ // all seven catalogues, with Roboto and with the wider DejaVu Sans; French, the longest
376
+ // "Send again", keeps 8px. At 360px French (and Spanish in DejaVu) still drop a line.
377
+ // A rem basis like the 12rem, so at Large the action drops a line sooner, by itself.
378
+ // From `md` up the clamp is off and the 12rem holds; without `clampMobile` nothing moves.
379
+ const clampBasis = strip && clamp ? "max-md:basis-28" : undefined;
360
380
  // Inside a whole-row banner's button or link, which then carries `grow` itself.
361
381
  const nested = interactive && (onDismiss !== undefined || (action !== undefined && action !== null));
362
382
  const frame = inline
@@ -367,25 +387,32 @@ export function AlertBanner({
367
387
  );
368
388
  const focusRing = cn(FOCUS_RING, "focus-visible:ring-offset-1 focus-visible:ring-offset-surface");
369
389
 
390
+ // The message's box: a `<div>` (0.51), so a banner may hold block content — keksdose's
391
+ // agree-again notice has a list and a Disclosure in it, which inside the `<span>` this was
392
+ // were block elements in phrasing content. A `<span>` still in a whole-row banner, whose
393
+ // `<a>` / `<button>` takes phrasing content only.
394
+ const Body = interactive ? "span" : "div";
370
395
  const contentBody = clamp ? (
371
396
  // Below `md` a row: the message, clamped, then the toggle on its baseline. From
372
- // `md` up a plain span with the message in it, as without the prop.
373
- <span
397
+ // `md` up a plain box with the message in it, as without the prop. (Never a whole-row
398
+ // banner, so always a `<div>`.)
399
+ <div
374
400
  className={cn(
375
401
  !inlineRow && grow,
402
+ clampBasis,
376
403
  "min-w-0 text-start max-md:flex max-md:items-baseline max-md:gap-2",
377
404
  )}
378
405
  >
379
406
  {/* The clamp is deliberate: the rest is one tap away behind More, so the size sweep's
380
407
  truncation gate (scripts/screenshot-sizes.mjs) leaves it alone. */}
381
- <span
408
+ <div
382
409
  id={bodyId}
383
410
  ref={bodyRef}
384
411
  data-truncate-ok=""
385
412
  className={cn("max-md:min-w-0", !expanded && "max-md:line-clamp-1")}
386
413
  >
387
414
  {children}
388
- </span>
415
+ </div>
389
416
  {(expanded || !fits) && (
390
417
  <button
391
418
  type="button"
@@ -400,11 +427,11 @@ export function AlertBanner({
400
427
  {expanded ? common.showLess : common.showMore}
401
428
  </button>
402
429
  )}
403
- </span>
430
+ </div>
404
431
  ) : (
405
- <span className={cn(!inlineRow && (nested ? "flex-1" : grow), "min-w-0 text-start")}>
432
+ <Body className={cn(!inlineRow && (nested ? "flex-1" : grow), "min-w-0 text-start")}>
406
433
  {children}
407
- </span>
434
+ </Body>
408
435
  );
409
436
  const content = (
410
437
  <>
@@ -480,10 +507,10 @@ export function AlertBanner({
480
507
  <div className={flowRow} data-action-flow="">
481
508
  {/* The glyph rides with the message, on its first line; the two are
482
509
  centred against the action as one, as the whole-row button is. */}
483
- <span className={cn("flex min-w-0 flex-1 items-start", flowBasis, sm ? "gap-1.5" : "gap-2")}>
510
+ <div className={cn("flex min-w-0 flex-1 items-start", flowBasis, sm ? "gap-1.5" : "gap-2")}>
484
511
  {glyph}
485
512
  {contentBody}
486
- </span>
513
+ </div>
487
514
  {trailing}
488
515
  </div>
489
516
  </>
@@ -7,6 +7,7 @@ import { Spinner } from "./spinner";
7
7
  import { useAnchoredPanel } from "../hooks/use-anchored-panel";
8
8
  import { takeEscape, useDismissLayer } from "../hooks/use-dismiss";
9
9
  import { isImeEnter } from "../lib/ime";
10
+ import { isQuietFocus } from "../lib/quiet-focus";
10
11
  import { useAnchorDir } from "./use-anchor-dir";
11
12
  import {
12
13
  ComboboxFieldLabel,
@@ -268,6 +269,8 @@ export function Autocomplete<V extends string | number = string>(props: Autocomp
268
269
  // either way, so a caller that controls only SOMETIMES (`open={confirming ? false :
269
270
  // undefined}`) hands back a field that is exactly as open as focus says it is.
270
271
  const open = openProp ?? ownOpen;
272
+ // The field took the tour's quiet focus (§11.8) and has not opened since.
273
+ const quietRef = useRef(false);
271
274
  const setOpen = (next: boolean) => {
272
275
  setOwnOpen(next);
273
276
  if (next !== open) onOpenChange?.(next);
@@ -410,11 +413,26 @@ export function Autocomplete<V extends string | number = string>(props: Autocomp
410
413
  onFocus?.(e);
411
414
  // A locked field takes focus to say why, and opens nothing.
412
415
  if (locked) return;
416
+ // The tour's focus on arrival (§11.8) looks nothing up: the list would
417
+ // cover its card (0.50.1). A click, ArrowDown or typing opens it.
418
+ if (isQuietFocus(e.currentTarget)) {
419
+ quietRef.current = true;
420
+ return;
421
+ }
413
422
  // A seeded field (keksdose's `initialQuery`) is looked up as it is
414
423
  // focused; an empty one asks nothing, because of `minChars`.
415
424
  setOpen(true);
416
425
  }}
426
+ // After a quiet focus, the person's click on the already-focused field
427
+ // fires no `focus`: the click is their "open" instead.
428
+ onClick={(e) => {
429
+ rest.onClick?.(e);
430
+ if (!quietRef.current || locked) return;
431
+ quietRef.current = false;
432
+ setOpen(true);
433
+ }}
417
434
  onBlur={(e) => {
435
+ quietRef.current = false;
418
436
  onBlur?.(e);
419
437
  close();
420
438
  }}
@@ -34,54 +34,7 @@ export type ButtonVariant =
34
34
  | "secondary"
35
35
  | "ghost"
36
36
  | "danger"
37
- | "link"
38
- /** @deprecated 0.49 — use `default` (same look); removed in 0.51. */
39
- | "primary"
40
- /** @deprecated 0.49 — use `accent` (same look); removed in 0.51. */
41
- | "brand";
42
-
43
- /** @internal A {@link ButtonVariant} without the deprecated aliases. */
44
- export type CanonicalButtonVariant = Exclude<ButtonVariant, "primary" | "brand">;
45
-
46
- /** The deprecated names and what they stand for — see {@link ButtonVariant}. */
47
- const DEPRECATED_BUTTON_VARIANTS: Readonly<Record<string, CanonicalButtonVariant>> = {
48
- primary: "default",
49
- brand: "accent",
50
- };
51
-
52
- /** The kit's dev switch, as `lib/logger.ts` reads it: a development build, never a
53
- * production one (the consumer's bundler makes `import.meta.env.DEV` false) and never
54
- * under vitest. Read per call so a test can switch it on with `vi.stubEnv`. */
55
- function devWarnings(): boolean {
56
- return Boolean(import.meta.env?.DEV) && !import.meta.env?.VITEST;
57
- }
58
-
59
- const warnedVariants = new Set<string>();
60
-
61
- /**
62
- * @internal A deprecated variant name warns once (per `component` and name) in a
63
- * development build, then reads as the name it stands for. `component` is the prop's
64
- * owner in the message ("Button variant", "FormActions submitVariant", …).
65
- */
66
- export function warnDeprecatedVariant(component: string, name: string, replacement: string): void {
67
- if (!devWarnings()) return;
68
- const key = `${component}|${name}`;
69
- if (warnedVariants.has(key)) return;
70
- warnedVariants.add(key);
71
- console.warn(
72
- `[ui-kit] ${component}="${name}" is deprecated: use "${replacement}" (same look). ` +
73
- `"${name}" is removed in 0.51.`,
74
- );
75
- }
76
-
77
- /** @internal `primary` → `default`, `brand` → `accent` (warning once in development);
78
- * every other variant as it is. Runs before every lookup keyed by the variant. */
79
- export function resolveButtonVariant(variant: ButtonVariant, component = "Button variant"): CanonicalButtonVariant {
80
- const replacement = DEPRECATED_BUTTON_VARIANTS[variant];
81
- if (replacement === undefined) return variant as CanonicalButtonVariant;
82
- warnDeprecatedVariant(component, variant, replacement);
83
- return replacement;
84
- }
37
+ | "link";
85
38
 
86
39
  // Shared base ring for every button-styled element. Kept as a named const so the
87
40
  // <Button> component and the {@link buttonClasses} helper draw from one source and
@@ -155,9 +108,8 @@ export const HOVER_INK = "hover:bg-[color-mix(in_srgb,var(--text-primary)_7%,tra
155
108
  // outline); `accent` is the solid brand fill for the rare strong CTA; `danger` takes the
156
109
  // semantic `--danger` family (destructive semantics), so a consumer can re-point the
157
110
  // destructive hue instead of inheriting a hard-coded red. Focus rings follow each
158
- // variant's own accent: brand for the four neutral ones, danger for `danger`. Keyed by
159
- // the canonical names: {@link resolveButtonVariant} maps the deprecated ones first.
160
- export const buttonVariantClasses: Record<CanonicalButtonVariant, string> = {
111
+ // variant's own accent: brand for the four neutral ones, danger for `danger`.
112
+ export const buttonVariantClasses: Record<ButtonVariant, string> = {
161
113
  default:
162
114
  // Hovers onto `--bg-active`, not `--border` (0.33, §12.9): More contrast steps the
163
115
  // border until it is a 3:1 LINE, and as a fill under the label that left 2.7:1.
@@ -223,12 +175,12 @@ const BUTTON_TONES: Record<Exclude<ButtonTone, "default">, string> = {
223
175
  };
224
176
 
225
177
  /** Only the two transparent variants take every tone; see {@link ButtonTone}. */
226
- const TONED_VARIANTS = new Set<CanonicalButtonVariant>(["link", "ghost"]);
178
+ const TONED_VARIANTS = new Set<ButtonVariant>(["link", "ghost"]);
227
179
 
228
180
  // `tone="danger"` on the neutral boxed variants — see {@link ButtonTone}. Each string
229
181
  // restates every colour the variant sets (border, fill, text, hover, ring) so none of
230
182
  // the neutral ones survives the merge.
231
- const BUTTON_BOXED_DANGER: Partial<Record<CanonicalButtonVariant, string>> = {
183
+ const BUTTON_BOXED_DANGER: Partial<Record<ButtonVariant, string>> = {
232
184
  // Not boxed, but its hover is a fill: the danger wash under the danger text, as
233
185
  // IconButton's danger tone (0.33). On B′'s translucent ink `--danger` fell to 4.2:1
234
186
  // over a light page.
@@ -266,12 +218,11 @@ export interface ButtonClassesOptions {
266
218
  * compact toolbar links.
267
219
  */
268
220
  export function buttonClasses(
269
- given: ButtonVariant = "default",
221
+ variant: ButtonVariant = "default",
270
222
  classNameOrOptions?: string | ButtonClassesOptions,
271
223
  ): string {
272
224
  const { size = "md", className } =
273
225
  typeof classNameOrOptions === "object" ? classNameOrOptions : { className: classNameOrOptions };
274
- const variant = resolveButtonVariant(given, "buttonClasses variant");
275
226
  return cn(
276
227
  BUTTON_BASE,
277
228
  BUTTON_SIZES[size],
@@ -291,8 +242,7 @@ export const ICON_BUTTON_PRESSED =
291
242
  "bg-brand-soft text-brand hover:bg-brand-soft-hover hover:text-brand-muted disabled:hover:bg-brand-soft disabled:hover:text-brand";
292
243
 
293
244
  /** @internal The button look shared by the `<button>` and the `<a>` form. */
294
- export function buttonLook(given: ButtonVariant, size: ButtonSize, stretch: boolean | undefined, tone: ButtonTone) {
295
- const variant = resolveButtonVariant(given);
245
+ export function buttonLook(variant: ButtonVariant, size: ButtonSize, stretch: boolean | undefined, tone: ButtonTone) {
296
246
  return cn(
297
247
  BUTTON_BASE,
298
248
  BUTTON_SIZES[size],
@@ -6,6 +6,7 @@ import { cn } from "../lib/cn";
6
6
  import { useDropdown } from "./dropdown";
7
7
  import { takeEscape, useDismissLayer } from "../hooks/use-dismiss";
8
8
  import { isImeEnter } from "../lib/ime";
9
+ import { isQuietFocus } from "../lib/quiet-focus";
9
10
  import { usePhoneLayout } from "../hooks/use-breakpoint";
10
11
  import { PickerSheet, SHEET_ROW_CLASS } from "./picker-sheet";
11
12
  import { ComboboxFieldLabel, endHintRowProps, GROUP_HEADING_STICKY, useActiveOptionScroll } from "./combobox-core";
@@ -421,11 +422,14 @@ export function Combobox(props: ComboboxProps) {
421
422
  autoFocus={autoFocus}
422
423
  onBlur={onBlur}
423
424
  onMouseDown={primaryOnly.onMouseDown}
424
- onFocus={() => {
425
+ onFocus={(e) => {
425
426
  // A back/forward mouse button focuses this field on its way to
426
427
  // navigating; it is not a request to open anything (live #309 rework).
427
428
  // A locked field takes focus to say why, and opens nothing.
428
429
  if (primaryOnly.fromAuxButton() || locked) return;
430
+ // The tour's focus on arrival (§11.8) opens nothing: the list would cover
431
+ // its card (0.50.1). The click handler below still opens on a click.
432
+ if (isQuietFocus(e.currentTarget)) return;
429
433
  setOpen(true);
430
434
  setTyping(false);
431
435
  // The sheet carries its own input, so the field behind it must not also
@@ -183,7 +183,10 @@ export function useColumnFit<T>({
183
183
  // update limit (#185). So a column whose return the next measure undid, at the same
184
184
  // scroller width, waits for more room than it had then: a wider scroller or narrower
185
185
  // content. Whatever the error in its width, that is one invisible flip per room it is
186
- // tried at.
186
+ // tried at. (0.51, keksdose N2: under `virtualize` that excess was the table coming
187
+ // back at an OLD lock's ratcheted widths, not the ones measured here; the lock now goes
188
+ // when the columns change, data-table-virtual.ts. The guard stays for the rest: a
189
+ // column whose drawn cells are wider than its header.)
187
190
  let n = current.length;
188
191
  const rooms = new Map<string, number>();
189
192
  while (n > 0) {
@@ -30,7 +30,9 @@ import type { RefObject } from "react";
30
30
  // nothing). Re-measured from scratch when the data, the columns, a user width, the text
31
31
  // size or the scroller's width change — and when a grown column would push the table
32
32
  // past its scroller, which it fit before. A `grow` column and a user-resized one are not
33
- // locked (the first takes the rest, the second is pinned already).
33
+ // locked (the first takes the rest, the second is pinned already). A lock replaced is
34
+ // gone: changing back (a column the fit leaves out and brings back) measures again
35
+ // (0.51, keksdose N2).
34
36
 
35
37
  /** {@link DataTableProps.virtualize}'s options. */
36
38
  export interface DataTableVirtualize {
@@ -302,6 +304,17 @@ export function useRowWindow<T>({
302
304
  );
303
305
 
304
306
  const lockSig = `${resetKey}|${scrollerWidth}|${colKeys.join(",")}`;
307
+ // A lock is the table it was measured on, so it goes as soon as the signature moves on
308
+ // — not merely set aside until the signature comes BACK (0.51, keksdose N2). The fit
309
+ // leaves a column out and, measuring the table afresh without it, brings it back in the
310
+ // same frame, before this hook measures anything: the signature was then the old one
311
+ // again, and the column came back at the widths that had ratcheted up while rows
312
+ // scrolled (a wider cell widens its column once), 80–110px more than the fit had just
313
+ // measured (606 + 93 estimated, 778–812 drawn, at 757), and B2's guard kept it out
314
+ // although it fitted. Dropped here, the table it comes back to is drawn as measured,
315
+ // and the next frame locks that. Adjusted during render, as React's "storing
316
+ // information from previous renders": an effect would draw the stale widths first.
317
+ if (lock !== null && lock.sig !== lockSig) setLock(null);
305
318
  const colWidths = lock && lock.sig === lockSig ? lock.widths : null;
306
319
 
307
320
  // What the scroll handler and the frame's measure read: this render's model and lock.
@@ -21,7 +21,8 @@ import { DEFAULT_CHARACTER_COUNT_LABELS, DEFAULT_PASSWORD_REVEAL_LABELS } from "
21
21
  * `useFieldError`, every field's.
22
22
  *
23
23
  * Public through ./ui by name. `FieldGroup`, the counter and the placeholder helpers are
24
- * the three fields' shared parts (`@internal`).
24
+ * the three fields' shared parts (`@internal`), as are the Select's own classes and its
25
+ * `SelectWidthFloor` (0.51).
25
26
  */
26
27
 
27
28
  /**
@@ -197,6 +198,57 @@ export function FieldChevron({ className, ...rest }: FieldChevronProps) {
197
198
  );
198
199
  }
199
200
 
201
+ /** A native dropdown Select's dress: no native arrow, and `pe-9`, the room the
202
+ * {@link FieldChevron} takes. Shared with {@link SelectWidthFloor}. `@internal` */
203
+ export const SELECT_MENU_DRESS = "appearance-none pe-9";
204
+
205
+ /** The compact Select (`size="sm"`): the field's colours, a toolbar button's height.
206
+ * `py-0` and a fixed height rather than a smaller padding, so the box is 28px whatever
207
+ * line height the caller's type brings with it. Shared with {@link SelectWidthFloor}.
208
+ * `@internal` */
209
+ export const SELECT_SM = "h-7 py-0 ps-2 pe-7 text-xs";
210
+
211
+ /**
212
+ * The width an UNLABELLED Select needs to show the widest of `values` whole: an invisible,
213
+ * zero-height line under it, in the select's own type, padding, chevron room and border
214
+ * (the same classes), one value per line. Placed beside the Select in the box that sizes
215
+ * it, it holds that box to the widest value, and the `w-full` select fills it. `@internal`
216
+ *
217
+ * Why it takes a sizer (0.51, Kurvenschmiede N5). A `<select>` with a percentage width
218
+ * is a "compressible" element: its min-content contribution is ZERO (CSS Sizing 3
219
+ * §5.2.2), so the auto table layout squeezes its column to the header, and beside a
220
+ * `grow` column every other column IS squeezed to its min-content (data-table-fit.ts).
221
+ * The user roster's role select stood 92px and read "Meml…" and "Admi…" (KS review,
222
+ * 0.49 and 0.50). `min-width: max-content` on the select would hold it instead — but to
223
+ * its widest OPTION, and an unavailable role's option carries its reason ("Customer (owns
224
+ * work)", RoleSelect's `optionDisabledReason`): the column would widen for a text that is
225
+ * never the selected value. So the caller says which values can be selected, in the
226
+ * current language.
227
+ */
228
+ export function SelectWidthFloor({ values, size = "md" }: { values: readonly string[]; size?: "sm" | "md" }) {
229
+ if (values.length === 0) return null;
230
+ return (
231
+ <span
232
+ aria-hidden
233
+ data-select-floor=""
234
+ className={cn(
235
+ FIELD_BASE,
236
+ SELECT_MENU_DRESS,
237
+ size === "sm" && SELECT_SM,
238
+ // The horizontal box only: no height, no vertical padding or border, not painted.
239
+ "invisible h-0 overflow-hidden border-y-0 py-0 whitespace-nowrap shadow-none",
240
+ )}
241
+ >
242
+ {values.map((value, i) => (
243
+ // The value as generated content, not as text: it is laid out (and so measured)
244
+ // the same, but a test's `getByText("Member")` still finds the one option, and a
245
+ // find-in-page or a copied row does not meet a word nobody sees.
246
+ <span key={i} data-value={value} className="block before:content-[attr(data-value)]" />
247
+ ))}
248
+ </span>
249
+ );
250
+ }
251
+
200
252
  // Animated label that starts centred (as a placeholder) in an empty field and
201
253
  // floats up INSIDE the top strip on focus or once the field has a value. Sits on
202
254
  // the field's own surface, so no background chip and nothing to mismatch the card.