@eifi1/ui-kit 0.51.6 → 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.
- package/README.md +5 -3
- package/dist/admin/admin-action-confirm.js +2 -4
- package/dist/admin/admin-action-confirm.js.map +1 -1
- package/dist/auth/complete-name-dialog.d.ts +3 -2
- package/dist/auth/complete-name-dialog.js +1 -4
- package/dist/auth/complete-name-dialog.js.map +1 -1
- package/dist/billing/cancel-request.d.ts +11 -2
- package/dist/billing/cancel-request.js +15 -3
- package/dist/billing/cancel-request.js.map +1 -1
- package/dist/billing/cancel-subscription.d.ts +2 -2
- package/dist/billing/cancel-subscription.js +2 -7
- package/dist/billing/cancel-subscription.js.map +1 -1
- package/dist/billing/plan-switch.d.ts +2 -2
- package/dist/billing/plan-switch.js +2 -4
- package/dist/billing/plan-switch.js.map +1 -1
- package/dist/components/amount-input.d.ts +9 -4
- package/dist/components/amount-input.js +2 -3
- package/dist/components/amount-input.js.map +1 -1
- package/dist/components/calculator.d.ts +21 -2
- package/dist/components/calculator.js +53 -14
- package/dist/components/calculator.js.map +1 -1
- package/dist/components/combobox-core.d.ts +8 -5
- package/dist/components/combobox-core.js +3 -4
- package/dist/components/combobox-core.js.map +1 -1
- package/dist/components/combobox.js +2 -2
- package/dist/components/combobox.js.map +1 -1
- package/dist/components/data-table-mobile.d.ts +24 -0
- package/dist/components/data-table-mobile.js +20 -1
- package/dist/components/data-table-mobile.js.map +1 -1
- package/dist/components/data-table.d.ts +13 -5
- package/dist/components/data-table.js +35 -9
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/dialog-frame.d.ts +2 -1
- package/dist/components/dialog-frame.js.map +1 -1
- package/dist/components/entity-combobox.js +4 -2
- package/dist/components/entity-combobox.js.map +1 -1
- package/dist/components/inline-entity-combobox.js +4 -3
- package/dist/components/inline-entity-combobox.js.map +1 -1
- package/dist/components/list.js +1 -1
- package/dist/components/list.js.map +1 -1
- package/dist/components/modal.d.ts +24 -0
- package/dist/components/modal.js +9 -3
- package/dist/components/modal.js.map +1 -1
- package/dist/components/money-draft.js +7 -5
- package/dist/components/money-draft.js.map +1 -1
- package/dist/components/money-field.d.ts +2 -2
- package/dist/components/money-field.js.map +1 -1
- package/dist/components/multi-entity-combobox.js +2 -1
- package/dist/components/multi-entity-combobox.js.map +1 -1
- package/dist/components/number-field.d.ts +6 -4
- package/dist/components/number-field.js +3 -2
- package/dist/components/number-field.js.map +1 -1
- package/dist/components/number-input.d.ts +4 -1
- package/dist/components/number-input.js +3 -2
- package/dist/components/number-input.js.map +1 -1
- package/dist/components/numpad-sheet.js.map +1 -1
- package/dist/components/reauth-dialog.d.ts +4 -5
- package/dist/components/reauth-dialog.js +1 -4
- package/dist/components/reauth-dialog.js.map +1 -1
- package/dist/components/series-chart-axes.d.ts +7 -0
- package/dist/components/series-chart-axes.js +1 -0
- package/dist/components/series-chart-axes.js.map +1 -1
- package/dist/components/tabs.js +12 -10
- package/dist/components/tabs.js.map +1 -1
- package/dist/eslint/index.js +1 -1
- package/dist/hooks/use-dismiss.js.map +1 -1
- package/dist/hooks/use-overlay-history.d.ts +7 -1
- package/dist/hooks/use-overlay-history.js +16 -2
- package/dist/hooks/use-overlay-history.js.map +1 -1
- package/dist/lib/calc.d.ts +7 -1
- package/dist/lib/calc.js +3 -1
- package/dist/lib/calc.js.map +1 -1
- package/dist/lib/clipboard-minus.d.ts +2 -2
- package/dist/lib/clipboard-minus.js +1 -1
- package/dist/lib/clipboard-minus.js.map +1 -1
- package/dist/lib/decimal-marks.d.ts +7 -1
- package/dist/lib/decimal-marks.js +5 -2
- package/dist/lib/decimal-marks.js.map +1 -1
- package/dist/lib/format.d.ts +5 -3
- package/dist/lib/format.js +12 -0
- package/dist/lib/format.js.map +1 -1
- package/dist/lib/text-match.d.ts +9 -0
- package/dist/lib/text-match.js +4 -0
- package/dist/lib/text-match.js.map +1 -1
- package/dist/tour/tour.d.ts +3 -1
- package/dist/tour/tour.js +9 -3
- package/dist/tour/tour.js.map +1 -1
- package/dist/tour/use-tour-url-sync.js +2 -2
- package/dist/tour/use-tour-url-sync.js.map +1 -1
- package/package.json +3 -3
- package/src/admin/admin-action-confirm.tsx +4 -5
- package/src/auth/complete-name-dialog.tsx +5 -7
- package/src/billing/cancel-request.ts +28 -6
- package/src/billing/cancel-subscription.tsx +4 -11
- package/src/billing/plan-switch.tsx +5 -8
- package/src/components/amount-input.tsx +14 -7
- package/src/components/calculator.tsx +98 -17
- package/src/components/combobox-core.tsx +10 -7
- package/src/components/combobox.tsx +5 -6
- package/src/components/data-table-mobile.tsx +49 -1
- package/src/components/data-table.tsx +66 -11
- package/src/components/dialog-frame.tsx +2 -1
- package/src/components/entity-combobox.tsx +4 -1
- package/src/components/inline-entity-combobox.tsx +5 -3
- package/src/components/list.tsx +4 -1
- package/src/components/modal.tsx +39 -3
- package/src/components/money-draft.ts +16 -5
- package/src/components/money-field.tsx +2 -2
- package/src/components/multi-entity-combobox.tsx +1 -0
- package/src/components/number-field.tsx +14 -7
- package/src/components/number-input.tsx +9 -3
- package/src/components/numpad-sheet.tsx +4 -3
- package/src/components/reauth-dialog.tsx +6 -10
- package/src/components/series-chart-axes.ts +8 -0
- package/src/components/tabs.tsx +33 -17
- package/src/hooks/use-dismiss.ts +3 -2
- package/src/hooks/use-overlay-history.ts +38 -2
- package/src/lib/calc.ts +13 -2
- package/src/lib/clipboard-minus.ts +14 -4
- package/src/lib/decimal-marks.ts +12 -3
- package/src/lib/format.ts +30 -3
- package/src/lib/text-match.ts +12 -0
- package/src/tour/tour.tsx +21 -4
- package/src/tour/use-tour-url-sync.ts +7 -3
|
@@ -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 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 ||\n (live.search ?? \"\") !== rendered.search ||\n (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":[]}
|
|
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 ||\n (live.search ?? \"\") !== rendered.search ||\n (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.\n * Floored, as the aliases always were (FLW-9): `?tourStep=2.5` was index 1.5. */\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 >= 1 ? Math.floor(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 // Said, as `TourProvider`'s own `launch` path says it (FLW-9): swallowed, a launch\n // that failed was a tour that silently did not start — in kastlan after its\n // launcher had already turned practice mode on.\n .catch((error: unknown) => console.error(error))\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":";AAAA,SAAS,eAAe,YAAY,WAAW,iBAAiB,QAAQ,gBAA8B;AACtG,YAAY,iBAAiB;AAC7B,SAAS,aAAa,mBAAmC;AACzD,SAAS,4BAA4B;AACrC,SAAS,uBAAwD;AACjE,SAAS,kBAAkB;AAC3B,SAAS,mBAAmB;AAgD5B,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,aAC1B,KAAK,UAAU,QAAQ,SAAS,WAChC,KAAK,QAAQ,QAAQ,SAAS;AAEnC;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;AAIH,SAAS,OAAO,QAAyB,SAAoC;AAC3E,QAAM,OAAO,OAAO,OAAO,IAAI,UAAU,CAAC;AAC1C,MAAI,OAAO,IAAI,UAAU,EAAG,QAAO,OAAO,SAAS,IAAI,KAAK,QAAQ,IAAI,KAAK,MAAM,IAAI,IAAI,IAAI;AAC/F,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,IAAI,IAAI,SAAS,MAAM,WAAuB,aAAa,kBAAkB,CAAC;AACrF,QAAM,cAAc,OAAO,QAAQ;AACnC,kBAAgB,MAAM;AACpB,cAAU,UAAU;AACpB,aAAS,UAAU,QAAQ,SAAS;AACpC,SAAK,IAAI,UAAU,kBAAkB;AACrC,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,SAAS,KAAK,MAAM,CAAC,EAI3G,MAAM,CAAC,UAAmB,QAAQ,MAAM,KAAK,CAAC,EAC9C,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;AAEA,MAAM,iBAAiB,MAAM;AAC3B,MAAI,YAAY,EAAG,SAAQ,KAAK,+EAA+E;AACjH;AAEA,MAAM,qBAAiC;AAAA,EACrC,QAAQ;AAAA,EACR,UAAU;AAAA,EACV,OAAO;AAAA,EACP,MAAM;AAAA,EACN,MAAM;AAAA,EACN,MAAM;AAAA,EACN,UAAU;AAAA,EACV,OAAO;AAAA,EACP,OAAO;AAAA,EACP,UAAU;AAAA,EACV,OAAO;AACT;AACA,MAAM,cAAc,OAAO,KAAK,kBAAkB;AAclD,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":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@eifi1/ui-kit",
|
|
3
|
-
"version": "0.51.
|
|
3
|
+
"version": "0.51.7",
|
|
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.",
|
|
@@ -140,7 +140,7 @@
|
|
|
140
140
|
"@testing-library/react": ">=16",
|
|
141
141
|
"dexie": "^4",
|
|
142
142
|
"idb-keyval": "^6",
|
|
143
|
-
"lucide-react": ">=0.
|
|
143
|
+
"lucide-react": ">=0.577.0 <2",
|
|
144
144
|
"modern-screenshot": "^4.7.0",
|
|
145
145
|
"react": ">=19.2",
|
|
146
146
|
"react-dom": ">=19.2",
|
|
@@ -178,7 +178,7 @@
|
|
|
178
178
|
"optional": true
|
|
179
179
|
}
|
|
180
180
|
},
|
|
181
|
-
"_dependencies": "Only what the package imports AND owns the version of. `lucide-react` is a PEER: every consumer already declares `lucide-react: ^0.511.0` itself, so a hard dependency here bought each of them a second copy of the icon set in node_modules — and pinned to 0.511.x it held all three behind a release line that is now at 1.x. The range is deliberately wide (`>=0.
|
|
181
|
+
"_dependencies": "Only what the package imports AND owns the version of. `lucide-react` is a PEER: every consumer already declares `lucide-react: ^0.511.0` itself, so a hard dependency here bought each of them a second copy of the icon set in node_modules — and pinned to 0.511.x it held all three behind a release line that is now at 1.x. The range is deliberately wide (`>=0.577.0 <2`): the kit imports named icon components and nothing else, so the app picks the version. Its floor is the version all three apps run, and scripts/verify-package.mjs installs that floor rather than the 1.x devDependency, so an icon that exists only in 1.x fails the kit's gate, not the apps' builds (MOD-2, refactor audit 2026-10-30; the old `>=0.400.0` floor had never been loaded by anything). `flag-icons` was never imported at all — 5.6 MB of stylesheet and SVG installed into every consumer to support class NAMES (`fi fi-xx`) that CurrencyFlag, CurrencySelect and LanguageMenu render as strings. The host supplies that stylesheet; see the dependency section of README.md. It stays a devDependency because the showcase imports it.",
|
|
182
182
|
"dependencies": {
|
|
183
183
|
"clsx": "^2.1.1",
|
|
184
184
|
"tailwind-merge": "^3.7.0",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Children, useContext, useEffect, useId, useRef, useState } from "react";
|
|
2
|
-
import type {
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
3
|
import { useKitLabels, type LabelOverride } from "../i18n/kit-labels";
|
|
4
4
|
import { AlertBanner } from "../components/alert-banner";
|
|
5
5
|
import { Checkbox } from "../components/checkbox";
|
|
@@ -378,6 +378,8 @@ export function AdminActionConfirm({
|
|
|
378
378
|
title={heading}
|
|
379
379
|
description={identity}
|
|
380
380
|
onClose={() => onClose(confirmed.current)}
|
|
381
|
+
// The removal offered with the refusal is a request too.
|
|
382
|
+
busy={busy}
|
|
381
383
|
actions={(close) => (
|
|
382
384
|
<CloseOnDone done={done}>
|
|
383
385
|
<Button type="button" variant="secondary" disabled={busy} onClick={close}>
|
|
@@ -426,10 +428,7 @@ export function AdminActionConfirm({
|
|
|
426
428
|
description={identity}
|
|
427
429
|
onClose={() => onClose(confirmed.current)}
|
|
428
430
|
// A request that has left cannot be called back by closing the dialog.
|
|
429
|
-
|
|
430
|
-
if (busy && event.key === "Escape") event.preventDefault();
|
|
431
|
-
}}
|
|
432
|
-
aria-busy={busy || undefined}
|
|
431
|
+
busy={busy}
|
|
433
432
|
actions={(close) => (
|
|
434
433
|
<CloseOnDone done={done}>
|
|
435
434
|
<Button type="button" variant="ghost" disabled={busy} onClick={close}>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useContext, useEffect, useId, useRef, useState } from "react";
|
|
2
|
-
import type {
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
3
|
|
|
4
4
|
import { useKitLabels, type LabelOverride } from "../i18n/kit-labels";
|
|
5
5
|
import { usePromisePending } from "../components/danger-confirm";
|
|
@@ -74,8 +74,9 @@ export interface CompleteNameDialogProps {
|
|
|
74
74
|
* complete name, no page is blocked, and the app asks again at the next sign-in.
|
|
75
75
|
*
|
|
76
76
|
* The promise handling is `ReauthDialog`'s: busy while `onSave` runs (the fields
|
|
77
|
-
* read-only, "Later" disabled, Escape held — a request that has
|
|
78
|
-
* back by closing the dialog), closed on resolve, kept open on
|
|
77
|
+
* read-only, "Later" disabled, Escape, the backdrop and Back held — a request that has
|
|
78
|
+
* left cannot be called back by closing the dialog), closed on resolve, kept open on
|
|
79
|
+
* reject. Save is a
|
|
79
80
|
* `commit` control, so a `WriteLockProvider` above it locks it with its reason — every
|
|
80
81
|
* lock but a lapsed plan's (`COMMIT_EXCEPT_BILLING`, 0.33): one's own name is the
|
|
81
82
|
* account's own settings, which billing never locks (docs/billing-harmonization.md §3.3,
|
|
@@ -160,10 +161,7 @@ export function CompleteNameDialog({
|
|
|
160
161
|
title={labels.title}
|
|
161
162
|
description={labels.description}
|
|
162
163
|
onClose={() => onClose(saved.current)}
|
|
163
|
-
|
|
164
|
-
if (pending && e.key === "Escape") e.preventDefault();
|
|
165
|
-
}}
|
|
166
|
-
aria-busy={pending || undefined}
|
|
164
|
+
busy={pending}
|
|
167
165
|
actions={
|
|
168
166
|
<CompleteNameActions
|
|
169
167
|
formId={formId}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState } from "react";
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* What the payer asked for in the app (0.43, kastlan `18bf4c0`): `"cancel"` after
|
|
@@ -8,6 +8,9 @@ import { useState } from "react";
|
|
|
8
8
|
*/
|
|
9
9
|
export type CancelRequested = "cancel" | "resume" | null;
|
|
10
10
|
|
|
11
|
+
/** How long a request waits for the provider's webhook: `useCheckoutProcessing`'s bound. */
|
|
12
|
+
const WAIT_FOR = 10 * 60_000;
|
|
13
|
+
|
|
11
14
|
/** What the page shows for the row as read now — `CancelRequest.view(rowScheduled)`. */
|
|
12
15
|
export interface CancelRequestView {
|
|
13
16
|
/** For `SubscriptionActions`' `cancelRequested`. */
|
|
@@ -23,7 +26,8 @@ export interface CancelRequestView {
|
|
|
23
26
|
export interface CancelRequest {
|
|
24
27
|
/** Read the overview again every few seconds while this is true — the overview query's
|
|
25
28
|
* `waiting` (kastlan's `useBillingOverview({ waiting })`, a `refetchInterval` inside) —
|
|
26
|
-
* so the page follows the provider. Needs no overview data: pass it to the query.
|
|
29
|
+
* so the page follows the provider. Needs no overview data: pass it to the query.
|
|
30
|
+
* False again after `timeoutMs` with no move of the row. */
|
|
27
31
|
waiting: boolean;
|
|
28
32
|
/**
|
|
29
33
|
* The row as read now (the overview's `cancel_at_period_end`; `undefined` while it
|
|
@@ -54,10 +58,25 @@ export interface CancelRequest {
|
|
|
54
58
|
* onResume={() => resume.mutate(undefined, { onSuccess: view.noteAsked })}
|
|
55
59
|
* …
|
|
56
60
|
* />
|
|
61
|
+
*
|
|
62
|
+
* **Bounded,** as `useCheckoutProcessing` is (FLW-6): after `timeoutMs` (10 minutes)
|
|
63
|
+
* with no move of the row the request is dropped — the polling stops and the page shows
|
|
64
|
+
* the row as it is. Unbounded, a webhook that never landed kept kastlan re-reading the
|
|
65
|
+
* overview every 3 s for as long as the page was open, which also kept a Cloud Run
|
|
66
|
+
* instance awake, and the plan line said "Cancels on …" for a row that never moved.
|
|
57
67
|
*/
|
|
58
|
-
export function useCancelRequest(): CancelRequest {
|
|
59
|
-
// What was asked, kept with the row's state it was asked against.
|
|
60
|
-
const [asked, setAsked] = useState<{ scheduled: boolean } | null>(null);
|
|
68
|
+
export function useCancelRequest({ timeoutMs = WAIT_FOR }: { timeoutMs?: number } = {}): CancelRequest {
|
|
69
|
+
// What was asked, kept with the row's state it was asked against and when.
|
|
70
|
+
const [asked, setAsked] = useState<{ scheduled: boolean; at: number } | null>(null);
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
if (asked === null) return;
|
|
73
|
+
// Only THIS ask: one that a later ask or the row has replaced is not dropped twice.
|
|
74
|
+
const timer = setTimeout(
|
|
75
|
+
() => setAsked((current) => (current === asked ? null : current)),
|
|
76
|
+
Math.max(0, asked.at + timeoutMs - Date.now()),
|
|
77
|
+
);
|
|
78
|
+
return () => clearTimeout(timer);
|
|
79
|
+
}, [asked, timeoutMs]);
|
|
61
80
|
const view = (row: boolean | null | undefined): CancelRequestView => {
|
|
62
81
|
// Not read yet: nothing to show against, and nothing to drop.
|
|
63
82
|
if (row === undefined || row === null) return { requested: null, cancelScheduled: false, noteAsked: () => {} };
|
|
@@ -68,7 +87,10 @@ export function useCancelRequest(): CancelRequest {
|
|
|
68
87
|
return {
|
|
69
88
|
requested: open ? (row ? "resume" : "cancel") : null,
|
|
70
89
|
cancelScheduled: open ? !row : row,
|
|
71
|
-
noteAsked: () =>
|
|
90
|
+
noteAsked: () => {
|
|
91
|
+
const at = Date.now();
|
|
92
|
+
setAsked((a) => (a !== null && a.scheduled === row ? null : { scheduled: row, at }));
|
|
93
|
+
},
|
|
72
94
|
};
|
|
73
95
|
};
|
|
74
96
|
return { waiting: asked !== null, view };
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import type { KeyboardEvent } from "react";
|
|
2
|
-
|
|
3
1
|
import { useKitLabels, useKitLocale } from "../i18n/kit-labels";
|
|
4
2
|
import type { LabelOverride } from "../i18n/kit-labels";
|
|
5
3
|
import { DEFAULT_COMMON_LABELS } from "../i18n/shared-labels";
|
|
@@ -21,8 +19,8 @@ export interface CancelSubscriptionConfirmProps {
|
|
|
21
19
|
periodEnd: DateInput;
|
|
22
20
|
/** Confirm: the app's `POST /billing/cancel` (server-kit 0.14, §16.3). */
|
|
23
21
|
onConfirm: () => void;
|
|
24
|
-
/** The cancel is under way: Confirm's spinner, no second press, and Escape
|
|
25
|
-
*
|
|
22
|
+
/** The cancel is under way: Confirm's spinner, no second press, and neither Escape,
|
|
23
|
+
* the backdrop nor Back closes it. */
|
|
26
24
|
pending?: boolean;
|
|
27
25
|
/** The date's locale. Default: the provider's. */
|
|
28
26
|
locale?: string;
|
|
@@ -72,13 +70,8 @@ export function CancelSubscriptionConfirm({
|
|
|
72
70
|
open={open}
|
|
73
71
|
title={labels.cancelConfirmTitle}
|
|
74
72
|
description={labels.cancelConfirmBody(formatDate(periodEnd, "medium", { locale }))}
|
|
75
|
-
onClose={() =>
|
|
76
|
-
|
|
77
|
-
}}
|
|
78
|
-
onKeyDown={(e: KeyboardEvent<HTMLDivElement>) => {
|
|
79
|
-
if (pending && e.key === "Escape") e.preventDefault();
|
|
80
|
-
}}
|
|
81
|
-
aria-busy={pending || undefined}
|
|
73
|
+
onClose={() => onOpenChange(false)}
|
|
74
|
+
busy={pending}
|
|
82
75
|
data-cancel-subscription-confirm=""
|
|
83
76
|
actions={(close) => (
|
|
84
77
|
<>
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import type { KeyboardEvent } from "react";
|
|
2
|
-
|
|
3
1
|
import { useKitLabels, useKitLocale } from "../i18n/kit-labels";
|
|
4
2
|
import type { LabelOverride } from "../i18n/kit-labels";
|
|
5
3
|
import { DEFAULT_COMMON_LABELS } from "../i18n/shared-labels";
|
|
@@ -50,8 +48,8 @@ export interface PlanSwitchConfirmProps {
|
|
|
50
48
|
/** `POST /billing/plan/preview`'s answer. Undefined while it loads: a spinner, and
|
|
51
49
|
* Confirm is held. */
|
|
52
50
|
preview?: PlanSwitchPreview;
|
|
53
|
-
/** `POST /billing/plan` is under way: Confirm's spinner, no second press, and
|
|
54
|
-
*
|
|
51
|
+
/** `POST /billing/plan` is under way: Confirm's spinner, no second press, and neither
|
|
52
|
+
* Escape, the backdrop nor Back closes it. */
|
|
55
53
|
pending?: boolean;
|
|
56
54
|
/** The plan's display name, the app's (§3.1). Default: the code. */
|
|
57
55
|
planName?: (code: string) => string;
|
|
@@ -152,10 +150,9 @@ export function PlanSwitchConfirm({
|
|
|
152
150
|
title={labels.switchTitle}
|
|
153
151
|
description={labels.switchFromTo(side(from), side(to))}
|
|
154
152
|
onClose={() => onOpenChange(false)}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
}
|
|
158
|
-
aria-busy={preview === undefined || pending || undefined}
|
|
153
|
+
busy={pending}
|
|
154
|
+
// A preview that loads is busy too, but holds nothing: Cancel stays a way out.
|
|
155
|
+
aria-busy={preview === undefined || undefined}
|
|
159
156
|
actions={(close) => (
|
|
160
157
|
<>
|
|
161
158
|
<Button variant="secondary" onClick={close} disabled={pending}>
|
|
@@ -267,10 +267,15 @@ export interface AmountInputProps extends FieldHelpProps, DataAttributes {
|
|
|
267
267
|
max?: number;
|
|
268
268
|
/**
|
|
269
269
|
* The SETTLED figure, dot-decimal, sign included — fired on blur, on Enter, on the
|
|
270
|
-
* numpad's "=" and
|
|
271
|
-
*
|
|
272
|
-
*
|
|
273
|
-
*
|
|
270
|
+
* numpad's "=" and once per calculator session (its "=" or Enter in its display, or
|
|
271
|
+
* the pad closing on a result not committed yet), never on a keystroke ("1200+" is
|
|
272
|
+
* not a figure yet). `onChange` still reports every keystroke and every result the
|
|
273
|
+
* calculator writes back; this is for a host that stores a number and must not see
|
|
274
|
+
* drafts (kastlan 52; see {@link MoneyField}, which is built on it).
|
|
275
|
+
*
|
|
276
|
+
* Refactor audit 2026-10-30, INP-1: every calculator keystroke that resolved used to
|
|
277
|
+
* commit, so "100+50" committed 105 on its way to 150 — keksdose saved both, and its
|
|
278
|
+
* threshold field, `disabled` while it saves, lost the calculator to the first.
|
|
274
279
|
*/
|
|
275
280
|
onCommit?: (value: string) => void;
|
|
276
281
|
/**
|
|
@@ -681,12 +686,14 @@ export function AmountInput(props: AmountInputProps) {
|
|
|
681
686
|
// reads either mark, and "1.234,5" is two of them. The result comes back
|
|
682
687
|
// dot-decimal.
|
|
683
688
|
value={derived}
|
|
689
|
+
// Live: every result, settled, so the field never shows more decimals than
|
|
690
|
+
// it will keep. The commit is the session's one (INP-1): its "=", or the pad
|
|
691
|
+
// closing on a newer result — not each figure on the way to the result.
|
|
684
692
|
onChange={(result, expression) => {
|
|
685
693
|
setDraft(null);
|
|
686
|
-
|
|
687
|
-
handleText(settled, expression);
|
|
688
|
-
onCommit?.(settled);
|
|
694
|
+
handleText(settle(result), expression);
|
|
689
695
|
}}
|
|
696
|
+
onCommit={(result) => onCommit?.(settle(result))}
|
|
690
697
|
className="px-1.5"
|
|
691
698
|
aria-label={labels?.calculatorTrigger}
|
|
692
699
|
labels={labels?.calculator}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { useEffect, useMemo, useRef, useState } from "react";
|
|
1
|
+
import { useEffect, useEffectEvent, useMemo, useRef, useState } from "react";
|
|
2
|
+
import type { RefObject } from "react";
|
|
2
3
|
import { Calculator as CalculatorIcon, Delete } from "lucide-react";
|
|
3
4
|
import { Popover } from "./popover";
|
|
4
5
|
import { cn } from "../lib/cn";
|
|
@@ -30,6 +31,24 @@ interface CalculatorButtonProps {
|
|
|
30
31
|
* the one argument and ignore this.
|
|
31
32
|
*/
|
|
32
33
|
onChange: (value: string, expression?: string) => void;
|
|
34
|
+
/**
|
|
35
|
+
* Called ONCE per calculator session, with the result the host should keep: on "="
|
|
36
|
+
* (the key, or Enter in the display), and when the panel closes — Escape, a press
|
|
37
|
+
* outside, the trigger — on a result `onChange` reported after the last commit. Never
|
|
38
|
+
* per keystroke: `onChange` is the live channel, this is the one to save on.
|
|
39
|
+
*
|
|
40
|
+
* Not called when the button goes away with its panel open (a lock or `disabled`
|
|
41
|
+
* arriving, the field unmounting): those say the value must not move, and the result
|
|
42
|
+
* stays where `onChange` put it.
|
|
43
|
+
*
|
|
44
|
+
* Refactor audit 2026-10-30, INP-1: the pad had no commit point of its own, and the
|
|
45
|
+
* two field engines guessed in opposite directions. NumberInput never committed a
|
|
46
|
+
* result, so Kurvenschmiede's dialogs saved the number from before the calculation;
|
|
47
|
+
* AmountInput committed every keystroke that resolved, so "100+50" saved 105 on its way
|
|
48
|
+
* to 150, and keksdose's threshold field, `disabled` while it saves, lost its pad to the
|
|
49
|
+
* first of them.
|
|
50
|
+
*/
|
|
51
|
+
onCommit?: (value: string) => void;
|
|
33
52
|
className?: string;
|
|
34
53
|
/** The trigger's accessible name; wins over `calculator.open` from the provider. */
|
|
35
54
|
"aria-label"?: string;
|
|
@@ -89,10 +108,13 @@ function seed(initial: string): string {
|
|
|
89
108
|
function CalculatorPanel({
|
|
90
109
|
initial,
|
|
91
110
|
onChange,
|
|
111
|
+
onEquals,
|
|
92
112
|
labels,
|
|
93
113
|
}: {
|
|
94
114
|
initial: string;
|
|
95
115
|
onChange: (value: string, expression?: string) => void;
|
|
116
|
+
/** "=" was pressed on a result: the session's commit point. */
|
|
117
|
+
onEquals: () => void;
|
|
96
118
|
labels: CalculatorLabels;
|
|
97
119
|
}) {
|
|
98
120
|
const [expr, setExpr] = useState(() => seed(initial));
|
|
@@ -132,6 +154,7 @@ function CalculatorPanel({
|
|
|
132
154
|
const out = formatResult(result);
|
|
133
155
|
setExpr(out);
|
|
134
156
|
onChange(out, text);
|
|
157
|
+
onEquals();
|
|
135
158
|
};
|
|
136
159
|
|
|
137
160
|
return (
|
|
@@ -213,15 +236,66 @@ function CalculatorPanel({
|
|
|
213
236
|
);
|
|
214
237
|
}
|
|
215
238
|
|
|
239
|
+
/**
|
|
240
|
+
* The trigger, and the place a session ends. The render in which `open` turns false is
|
|
241
|
+
* the panel closing, however it closed — Escape, a press outside, this button — and
|
|
242
|
+
* that is when a result the pad left unsettled is committed. A component of its own
|
|
243
|
+
* because `open` lives in Popover and reaches only the trigger.
|
|
244
|
+
*
|
|
245
|
+
* Not the panel's unmount: the panel also goes when a lock or `disabled` takes the
|
|
246
|
+
* calculator away, and nothing may commit then. Those unmount this button with it, so no
|
|
247
|
+
* render ever sees `open` turn false.
|
|
248
|
+
*/
|
|
249
|
+
function CalculatorTrigger({
|
|
250
|
+
open,
|
|
251
|
+
onToggle,
|
|
252
|
+
onClosed,
|
|
253
|
+
buttonRef,
|
|
254
|
+
label,
|
|
255
|
+
className,
|
|
256
|
+
}: {
|
|
257
|
+
open: boolean;
|
|
258
|
+
onToggle: () => void;
|
|
259
|
+
onClosed: () => void;
|
|
260
|
+
buttonRef: RefObject<HTMLButtonElement | null>;
|
|
261
|
+
label: string;
|
|
262
|
+
className?: string;
|
|
263
|
+
}) {
|
|
264
|
+
const closed = useEffectEvent(onClosed);
|
|
265
|
+
const wasOpen = useRef(open);
|
|
266
|
+
useEffect(() => {
|
|
267
|
+
if (wasOpen.current && !open) closed();
|
|
268
|
+
wasOpen.current = open;
|
|
269
|
+
}, [open]);
|
|
270
|
+
return (
|
|
271
|
+
<button
|
|
272
|
+
ref={buttonRef}
|
|
273
|
+
type="button"
|
|
274
|
+
tabIndex={-1}
|
|
275
|
+
onClick={onToggle}
|
|
276
|
+
aria-label={label}
|
|
277
|
+
aria-expanded={open}
|
|
278
|
+
className={cn(
|
|
279
|
+
"flex items-center justify-center text-placeholder transition-colors hover:text-secondary",
|
|
280
|
+
className,
|
|
281
|
+
)}
|
|
282
|
+
>
|
|
283
|
+
<CalculatorIcon className="size-4" />
|
|
284
|
+
</button>
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
216
288
|
/**
|
|
217
289
|
* A calculator icon that opens a small keypad popover anchored to its host
|
|
218
290
|
* field. Designed as a trailing-edge adornment: render it inside the field's
|
|
219
291
|
* `relative` wrapper, positioned absolutely. The keypad seeds from the current
|
|
220
|
-
* field value and writes evaluated results straight back through `onChange
|
|
292
|
+
* field value and writes evaluated results straight back through `onChange`, and
|
|
293
|
+
* commits the session once through `onCommit`.
|
|
221
294
|
*/
|
|
222
295
|
export function CalculatorButton({
|
|
223
296
|
value,
|
|
224
297
|
onChange,
|
|
298
|
+
onCommit,
|
|
225
299
|
className,
|
|
226
300
|
"aria-label": ariaLabel,
|
|
227
301
|
labels: labelsProp,
|
|
@@ -233,28 +307,35 @@ export function CalculatorButton({
|
|
|
233
307
|
[ariaLabel, labelsProp],
|
|
234
308
|
);
|
|
235
309
|
const labels = useKitLabels("calculator", DEFAULT_CALCULATOR_LABELS, fromProps);
|
|
310
|
+
// The last result `onChange` reported that no commit has covered yet — what closing
|
|
311
|
+
// the panel commits. Written by the pad's handlers and read when it closes; never
|
|
312
|
+
// rendered, so a ref.
|
|
313
|
+
const unsettled = useRef<string | null>(null);
|
|
314
|
+
const change = (next: string, expression?: string) => {
|
|
315
|
+
unsettled.current = next;
|
|
316
|
+
onChange(next, expression);
|
|
317
|
+
};
|
|
318
|
+
const settle = () => {
|
|
319
|
+
const next = unsettled.current;
|
|
320
|
+
unsettled.current = null;
|
|
321
|
+
if (next !== null) onCommit?.(next);
|
|
322
|
+
};
|
|
236
323
|
return (
|
|
237
324
|
<Popover
|
|
238
325
|
width="14rem"
|
|
239
326
|
labels={{ panel: labels.panel }}
|
|
240
327
|
trigger={({ open, toggle, ref }) => (
|
|
241
|
-
<
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
"flex items-center justify-center text-placeholder transition-colors hover:text-secondary",
|
|
250
|
-
className,
|
|
251
|
-
)}
|
|
252
|
-
>
|
|
253
|
-
<CalculatorIcon className="size-4" />
|
|
254
|
-
</button>
|
|
328
|
+
<CalculatorTrigger
|
|
329
|
+
open={open}
|
|
330
|
+
onToggle={toggle}
|
|
331
|
+
onClosed={settle}
|
|
332
|
+
buttonRef={ref}
|
|
333
|
+
label={labels.open}
|
|
334
|
+
className={className}
|
|
335
|
+
/>
|
|
255
336
|
)}
|
|
256
337
|
>
|
|
257
|
-
{() => <CalculatorPanel initial={value} onChange={
|
|
338
|
+
{() => <CalculatorPanel initial={value} onChange={change} onEquals={settle} labels={labels} />}
|
|
258
339
|
</Popover>
|
|
259
340
|
);
|
|
260
341
|
}
|
|
@@ -22,7 +22,7 @@ import { anchoredInline, useAnchoredPanel, viewportSize, type AnchoredPanel } fr
|
|
|
22
22
|
import { pressInside, takeEscape, useDismissLayer, useOutsideClick } from "../hooks/use-dismiss";
|
|
23
23
|
import { useOverlayHistory } from "../hooks/use-overlay-history";
|
|
24
24
|
import { isImeEnter } from "../lib/ime";
|
|
25
|
-
import { matchesSearch, searchWords } from "../lib/text-match";
|
|
25
|
+
import { matchesSearch, sameName, searchWords } from "../lib/text-match";
|
|
26
26
|
import { DEFAULT_COMBOBOX_LABELS, useKitLabels, useKitLocale } from "../i18n/kit-labels";
|
|
27
27
|
import { hasContent } from "../lib/presence";
|
|
28
28
|
|
|
@@ -108,25 +108,28 @@ export const DISABLED_ROW_CLASS = "cursor-not-allowed opacity-50 hover:bg-transp
|
|
|
108
108
|
* Whether an entity picker offers its create row — one rule for
|
|
109
109
|
* {@link EntityCombobox}, {@link MultiEntityCombobox} and {@link InlineEntityCombobox}.
|
|
110
110
|
*
|
|
111
|
-
* With a query: while the trimmed query names no option exactly
|
|
112
|
-
*
|
|
113
|
-
*
|
|
111
|
+
* With a query: while the trimmed query names no option exactly — an exact hit is a
|
|
112
|
+
* record the list is already showing, and a second way to "make" it would mint a
|
|
113
|
+
* duplicate. "Exactly" as the list matches, folded by `locale` ({@link sameName}): with
|
|
114
|
+
* plain `toLowerCase`, "cafe" listed Café and offered "Create “cafe”" beside it (refactor
|
|
115
|
+
* audit 2026-10-30, DUP-5). With NO query: only when the caller named the row for that
|
|
114
116
|
* case (`createEmptyLabel`) — a row reading `Create “”` says nothing, and the kit cannot
|
|
115
117
|
* know the noun ("Create cash account") that would make it say something (keksdose G9).
|
|
116
118
|
*
|
|
117
119
|
* `names` is what the query is compared against: the loaded rows for a panel picker
|
|
118
|
-
* (its async list is all it knows), every option for the inline one.
|
|
120
|
+
* (its async list is all it knows), every option for the inline one. `locale`: the
|
|
121
|
+
* provider's, which the list's match folds by; left out, the runtime's.
|
|
119
122
|
*/
|
|
120
123
|
export function offersCreate(
|
|
121
124
|
onCreate: unknown,
|
|
122
125
|
query: string,
|
|
123
126
|
names: readonly string[],
|
|
124
127
|
createEmptyLabel: string | undefined,
|
|
128
|
+
locale?: string,
|
|
125
129
|
): boolean {
|
|
126
130
|
if (!onCreate) return false;
|
|
127
131
|
if (!query) return Boolean(createEmptyLabel);
|
|
128
|
-
|
|
129
|
-
return !names.some((n) => n.toLowerCase() === q);
|
|
132
|
+
return !names.some((n) => sameName(n, query, locale));
|
|
130
133
|
}
|
|
131
134
|
|
|
132
135
|
/**
|
|
@@ -11,7 +11,7 @@ import { usePhoneLayout } from "../hooks/use-breakpoint";
|
|
|
11
11
|
import { PickerSheet, SHEET_ROW_CLASS } from "./picker-sheet";
|
|
12
12
|
import { ComboboxFieldLabel, endHintRowProps, GROUP_HEADING_STICKY, useActiveOptionScroll } from "./combobox-core";
|
|
13
13
|
import { useKitLocale } from "../i18n/kit-labels";
|
|
14
|
-
import { rankMatch } from "../lib/text-match";
|
|
14
|
+
import { rankMatch, sameName } from "../lib/text-match";
|
|
15
15
|
import {
|
|
16
16
|
EndHintRow,
|
|
17
17
|
FieldCaption,
|
|
@@ -328,8 +328,9 @@ export function Combobox(props: ComboboxProps) {
|
|
|
328
328
|
setTyping(false);
|
|
329
329
|
};
|
|
330
330
|
|
|
331
|
-
// Offered only when the typed text is genuinely NOT in the pool: an exact
|
|
332
|
-
//
|
|
331
|
+
// Offered only when the typed text is genuinely NOT in the pool: an exact match —
|
|
332
|
+
// folded as the list matches, so "cafe" is Café ({@link sameName}, DUP-5 in the
|
|
333
|
+
// 2026-10-30 refactor audit) — is an existing entry the list is already showing, and a
|
|
333
334
|
// second way to pick it would be noise. Compared against `options`, not `matches` —
|
|
334
335
|
// `matches` is capped, so a pool of 300 payees would otherwise offer to "add" one
|
|
335
336
|
// that exists but fell off the end of the list.
|
|
@@ -352,9 +353,7 @@ export function Combobox(props: ComboboxProps) {
|
|
|
352
353
|
|
|
353
354
|
const typed = value.trim();
|
|
354
355
|
const createRow =
|
|
355
|
-
createLabel && typed.length > 0 && !options.some((o) => o
|
|
356
|
-
? createLabel(typed)
|
|
357
|
-
: null;
|
|
356
|
+
createLabel && typed.length > 0 && !options.some((o) => sameName(o, typed, locale)) ? createLabel(typed) : null;
|
|
358
357
|
|
|
359
358
|
// The list is a layer on the Escape stack while it SHOWS (Wave 2, DUP-1): an Escape
|
|
360
359
|
// over an empty "open" list is the dialog's, as it always was. On a phone the sheet
|