@eifi1/ui-kit 0.30.0 → 0.31.1

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 (269) hide show
  1. package/README.md +42 -5
  2. package/dist/account/data-export-setting.js +3 -2
  3. package/dist/account/data-export-setting.js.map +1 -1
  4. package/dist/account/delete-account-setting.d.ts +5 -1
  5. package/dist/account/delete-account-setting.js +3 -2
  6. package/dist/account/delete-account-setting.js.map +1 -1
  7. package/dist/account/email-change-setting.js +4 -3
  8. package/dist/account/email-change-setting.js.map +1 -1
  9. package/dist/account/sessions-setting.js +3 -2
  10. package/dist/account/sessions-setting.js.map +1 -1
  11. package/dist/admin/admin-action-confirm.d.ts +5 -1
  12. package/dist/admin/admin-parts.d.ts +8 -1
  13. package/dist/admin/admin-parts.js +1 -1
  14. package/dist/admin/admin-parts.js.map +1 -1
  15. package/dist/admin/invitations-panel.d.ts +5 -1
  16. package/dist/admin/invitations-panel.js +3 -2
  17. package/dist/admin/invitations-panel.js.map +1 -1
  18. package/dist/admin/reviewer-scope-editor.d.ts +5 -1
  19. package/dist/admin/role-select.d.ts +5 -1
  20. package/dist/admin/transfer-ownership-dialog.d.ts +5 -1
  21. package/dist/admin/user-roster.d.ts +5 -1
  22. package/dist/auth/auth-errors.d.ts +24 -6
  23. package/dist/auth/auth-errors.js +7 -1
  24. package/dist/auth/auth-errors.js.map +1 -1
  25. package/dist/auth/register-form.d.ts +5 -1
  26. package/dist/auth/reset-password-form.d.ts +1 -1
  27. package/dist/auth/reset-password-form.js +2 -1
  28. package/dist/auth/reset-password-form.js.map +1 -1
  29. package/dist/auth/sign-in-form.d.ts +15 -1
  30. package/dist/auth/sign-in-form.js +12 -3
  31. package/dist/auth/sign-in-form.js.map +1 -1
  32. package/dist/auth/verify-email.d.ts +4 -0
  33. package/dist/auth/verify-email.js +3 -2
  34. package/dist/auth/verify-email.js.map +1 -1
  35. package/dist/chart.d.ts +5 -1
  36. package/dist/components/account-settings-labels.d.ts +12 -0
  37. package/dist/components/account-settings-labels.js +2 -1
  38. package/dist/components/account-settings-labels.js.map +1 -1
  39. package/dist/components/account-settings.d.ts +8 -2
  40. package/dist/components/account-settings.js +19 -11
  41. package/dist/components/account-settings.js.map +1 -1
  42. package/dist/components/amount-input.d.ts +5 -1
  43. package/dist/components/button-group.d.ts +5 -1
  44. package/dist/components/calculator.d.ts +5 -1
  45. package/dist/components/confirm-dialog.d.ts +5 -1
  46. package/dist/components/confirm-dialog.js +7 -1
  47. package/dist/components/confirm-dialog.js.map +1 -1
  48. package/dist/components/copy-button.d.ts +5 -1
  49. package/dist/components/danger-confirm.d.ts +5 -1
  50. package/dist/components/data-table.js +8 -1
  51. package/dist/components/data-table.js.map +1 -1
  52. package/dist/components/date-picker.js +9 -4
  53. package/dist/components/date-picker.js.map +1 -1
  54. package/dist/components/facing-pair.d.ts +5 -1
  55. package/dist/components/file-button.d.ts +5 -1
  56. package/dist/components/file-dropzone.d.ts +5 -1
  57. package/dist/components/form-actions.d.ts +5 -1
  58. package/dist/components/iban-input.d.ts +5 -1
  59. package/dist/components/language-select.d.ts +5 -1
  60. package/dist/components/legal-page.d.ts +5 -1
  61. package/dist/components/legal.d.ts +5 -1
  62. package/dist/components/money-field.d.ts +5 -1
  63. package/dist/components/number-field.d.ts +5 -1
  64. package/dist/components/number-input.d.ts +5 -1
  65. package/dist/components/numpad-sheet.d.ts +5 -1
  66. package/dist/components/passkeys-setting.js +2 -1
  67. package/dist/components/passkeys-setting.js.map +1 -1
  68. package/dist/components/phone-input.d.ts +5 -1
  69. package/dist/components/series-chart.d.ts +5 -1
  70. package/dist/components/settings-fields.d.ts +40 -3
  71. package/dist/components/settings-fields.js +20 -1
  72. package/dist/components/settings-fields.js.map +1 -1
  73. package/dist/components/share-card.d.ts +5 -1
  74. package/dist/components/text-link.d.ts +5 -1
  75. package/dist/components/time-input.d.ts +5 -1
  76. package/dist/components/ui.d.ts +5 -1
  77. package/dist/{delete-account-setting-CGVMckMw.d.ts → delete-account-setting-CpIhzxxZ.d.ts} +60 -23
  78. package/dist/demo/demo-banner.d.ts +51 -0
  79. package/dist/demo/demo-banner.js +97 -0
  80. package/dist/demo/demo-banner.js.map +1 -0
  81. package/dist/demo/demo-countdown.d.ts +58 -0
  82. package/dist/demo/demo-countdown.js +79 -0
  83. package/dist/demo/demo-countdown.js.map +1 -0
  84. package/dist/demo/demo-ended.d.ts +45 -0
  85. package/dist/demo/demo-ended.js +42 -0
  86. package/dist/demo/demo-ended.js.map +1 -0
  87. package/dist/demo/demo-labels.d.ts +53 -0
  88. package/dist/demo/demo-labels.js +29 -0
  89. package/dist/demo/demo-labels.js.map +1 -0
  90. package/dist/demo/demo-session.d.ts +30 -0
  91. package/dist/demo/demo-session.js +8 -0
  92. package/dist/demo/demo-session.js.map +1 -0
  93. package/dist/demo/demo-start.d.ts +54 -0
  94. package/dist/demo/demo-start.js +68 -0
  95. package/dist/demo/demo-start.js.map +1 -0
  96. package/dist/feedback/feedback-attachment.d.ts +5 -1
  97. package/dist/feedback/feedback-context.d.ts +5 -1
  98. package/dist/feedback/feedback-crash.d.ts +5 -1
  99. package/dist/feedback/feedback-dialog.d.ts +5 -1
  100. package/dist/feedback/feedback-inbox.d.ts +5 -1
  101. package/dist/feedback/feedback-inbox.js +12 -2
  102. package/dist/feedback/feedback-inbox.js.map +1 -1
  103. package/dist/feedback/feedback-labels.d.ts +5 -1
  104. package/dist/feedback/feedback-labels.js +1 -0
  105. package/dist/feedback/feedback-labels.js.map +1 -1
  106. package/dist/feedback/feedback-menu.d.ts +5 -1
  107. package/dist/feedback/feedback-record.d.ts +5 -1
  108. package/dist/feedback/feedback-record.js +2 -0
  109. package/dist/feedback/feedback-record.js.map +1 -1
  110. package/dist/feedback/feedback-row-detail.d.ts +5 -1
  111. package/dist/feedback/feedback-row-detail.js +7 -0
  112. package/dist/feedback/feedback-row-detail.js.map +1 -1
  113. package/dist/feedback/feedback-status-undo.d.ts +5 -1
  114. package/dist/feedback/feedback-submit.d.ts +5 -1
  115. package/dist/feedback/feedback-swipe.d.ts +6 -2
  116. package/dist/feedback/feedback-swipe.js.map +1 -1
  117. package/dist/feedback/feedback-table.d.ts +5 -1
  118. package/dist/feedback/feedback-thread.d.ts +5 -1
  119. package/dist/feedback.d.ts +5 -1
  120. package/dist/hooks/use-file-drop.d.ts +5 -1
  121. package/dist/i18n/defaults.d.ts +5 -1
  122. package/dist/i18n/defaults.js +6 -0
  123. package/dist/i18n/defaults.js.map +1 -1
  124. package/dist/i18n/german.d.ts +5 -1
  125. package/dist/i18n/german.js +85 -2
  126. package/dist/i18n/german.js.map +1 -1
  127. package/dist/i18n/kit-labels.d.ts +5 -1
  128. package/dist/i18n/kit-labels.js.map +1 -1
  129. package/dist/i18n/languages.d.ts +5 -1
  130. package/dist/i18n/locales/de-CH.d.ts +5 -1
  131. package/dist/i18n/locales/en.d.ts +5 -1
  132. package/dist/i18n/locales/es.d.ts +5 -1
  133. package/dist/i18n/locales/es.js +82 -2
  134. package/dist/i18n/locales/es.js.map +1 -1
  135. package/dist/i18n/locales/fr.d.ts +5 -1
  136. package/dist/i18n/locales/fr.js +83 -2
  137. package/dist/i18n/locales/fr.js.map +1 -1
  138. package/dist/i18n/locales/hu.d.ts +5 -1
  139. package/dist/i18n/locales/hu.js +83 -2
  140. package/dist/i18n/locales/hu.js.map +1 -1
  141. package/dist/i18n/locales/it.d.ts +5 -1
  142. package/dist/i18n/locales/it.js +82 -2
  143. package/dist/i18n/locales/it.js.map +1 -1
  144. package/dist/i18n/locales/zh.d.ts +5 -1
  145. package/dist/i18n/locales/zh.js +81 -2
  146. package/dist/i18n/locales/zh.js.map +1 -1
  147. package/dist/i18n/review.d.ts +5 -1
  148. package/dist/i18n/review.js +15 -1
  149. package/dist/i18n/review.js.map +1 -1
  150. package/dist/index.d.ts +25 -3
  151. package/dist/index.js +96 -0
  152. package/dist/index.js.map +1 -1
  153. package/dist/landing/access.d.ts +48 -0
  154. package/dist/landing/access.js +38 -0
  155. package/dist/landing/access.js.map +1 -0
  156. package/dist/landing/landing-actions.d.ts +140 -0
  157. package/dist/landing/landing-actions.js +39 -0
  158. package/dist/landing/landing-actions.js.map +1 -0
  159. package/dist/landing/landing-labels.d.ts +43 -0
  160. package/dist/landing/landing-labels.js +23 -0
  161. package/dist/landing/landing-labels.js.map +1 -0
  162. package/dist/landing/landing-sections.d.ts +218 -0
  163. package/dist/landing/landing-sections.js +167 -0
  164. package/dist/landing/landing-sections.js.map +1 -0
  165. package/dist/landing/page-seo.d.ts +99 -0
  166. package/dist/landing/page-seo.js +121 -0
  167. package/dist/landing/page-seo.js.map +1 -0
  168. package/dist/landing/public-header.d.ts +148 -0
  169. package/dist/landing/public-header.js +49 -0
  170. package/dist/landing/public-header.js.map +1 -0
  171. package/dist/landing/routing.d.ts +231 -0
  172. package/dist/landing/routing.js +84 -0
  173. package/dist/landing/routing.js.map +1 -0
  174. package/dist/rhf/fields.d.ts +5 -1
  175. package/dist/rhf/form.d.ts +5 -1
  176. package/dist/rhf.d.ts +5 -1
  177. package/dist/settings/settings-catalogue.d.ts +84 -0
  178. package/dist/settings/settings-catalogue.js +47 -0
  179. package/dist/settings/settings-catalogue.js.map +1 -0
  180. package/dist/settings/settings-context.d.ts +31 -0
  181. package/dist/settings/settings-context.js +11 -0
  182. package/dist/settings/settings-context.js.map +1 -0
  183. package/dist/settings/settings-heading.d.ts +50 -0
  184. package/dist/settings/settings-heading.js +27 -0
  185. package/dist/settings/settings-heading.js.map +1 -0
  186. package/dist/settings/settings-labels.d.ts +48 -0
  187. package/dist/settings/settings-labels.js +43 -0
  188. package/dist/settings/settings-labels.js.map +1 -0
  189. package/dist/settings/settings-layout.d.ts +77 -0
  190. package/dist/settings/settings-layout.js +242 -0
  191. package/dist/settings/settings-layout.js.map +1 -0
  192. package/dist/settings/settings-section.d.ts +133 -0
  193. package/dist/settings/settings-section.js +63 -0
  194. package/dist/settings/settings-section.js.map +1 -0
  195. package/dist/settings/use-account-language.d.ts +83 -0
  196. package/dist/settings/use-account-language.js +62 -0
  197. package/dist/settings/use-account-language.js.map +1 -0
  198. package/dist/settings/use-settings-focus.d.ts +34 -0
  199. package/dist/settings/use-settings-focus.js +77 -0
  200. package/dist/settings/use-settings-focus.js.map +1 -0
  201. package/dist/settings/use-settings-route.d.ts +118 -0
  202. package/dist/settings/use-settings-route.js +120 -0
  203. package/dist/settings/use-settings-route.js.map +1 -0
  204. package/dist/shell/app-shell.d.ts +11 -1
  205. package/dist/shell/app-shell.js +3 -2
  206. package/dist/shell/app-shell.js.map +1 -1
  207. package/dist/shell/top-bar-brand.d.ts +5 -1
  208. package/dist/shell.d.ts +15 -1
  209. package/dist/shell.js +56 -0
  210. package/dist/shell.js.map +1 -1
  211. package/dist/wizard/stepper-nav.d.ts +5 -1
  212. package/dist/wizard.d.ts +5 -1
  213. package/package.json +2 -1
  214. package/src/account/data-export-setting.tsx +3 -2
  215. package/src/account/delete-account-setting.tsx +3 -2
  216. package/src/account/email-change-setting.tsx +4 -7
  217. package/src/account/sessions-setting.tsx +3 -2
  218. package/src/admin/admin-parts.ts +9 -2
  219. package/src/admin/invitations-panel.tsx +11 -4
  220. package/src/auth/auth-errors.ts +36 -5
  221. package/src/auth/reset-password-form.tsx +6 -2
  222. package/src/auth/sign-in-form.tsx +34 -3
  223. package/src/auth/verify-email.tsx +14 -2
  224. package/src/components/account-settings-labels.ts +13 -0
  225. package/src/components/account-settings.tsx +33 -12
  226. package/src/components/confirm-dialog.tsx +20 -1
  227. package/src/components/data-table.tsx +8 -0
  228. package/src/components/date-picker.tsx +9 -4
  229. package/src/components/passkeys-setting.tsx +2 -1
  230. package/src/components/settings-fields.tsx +50 -1
  231. package/src/demo/demo-banner.tsx +159 -0
  232. package/src/demo/demo-countdown.ts +141 -0
  233. package/src/demo/demo-ended.tsx +89 -0
  234. package/src/demo/demo-labels.ts +78 -0
  235. package/src/demo/demo-session.ts +32 -0
  236. package/src/demo/demo-start.tsx +150 -0
  237. package/src/feedback/feedback-inbox.tsx +21 -8
  238. package/src/feedback/feedback-labels.ts +2 -1
  239. package/src/feedback/feedback-record.ts +10 -7
  240. package/src/feedback/feedback-row-detail.tsx +28 -4
  241. package/src/feedback/feedback-swipe.tsx +1 -1
  242. package/src/i18n/defaults.ts +6 -0
  243. package/src/i18n/german.ts +87 -0
  244. package/src/i18n/kit-labels.tsx +9 -0
  245. package/src/i18n/locales/es.ts +84 -0
  246. package/src/i18n/locales/fr.ts +85 -0
  247. package/src/i18n/locales/hu.ts +85 -0
  248. package/src/i18n/locales/it.ts +84 -0
  249. package/src/i18n/locales/zh.ts +82 -0
  250. package/src/i18n/review.ts +14 -0
  251. package/src/index.ts +98 -1
  252. package/src/landing/access.ts +97 -0
  253. package/src/landing/landing-actions.tsx +96 -0
  254. package/src/landing/landing-labels.ts +59 -0
  255. package/src/landing/landing-sections.tsx +337 -0
  256. package/src/landing/page-seo.ts +251 -0
  257. package/src/landing/public-header.tsx +120 -0
  258. package/src/landing/routing.tsx +226 -0
  259. package/src/settings/settings-catalogue.ts +132 -0
  260. package/src/settings/settings-context.ts +33 -0
  261. package/src/settings/settings-heading.tsx +65 -0
  262. package/src/settings/settings-labels.ts +87 -0
  263. package/src/settings/settings-layout.tsx +379 -0
  264. package/src/settings/settings-section.tsx +108 -0
  265. package/src/settings/use-account-language.ts +146 -0
  266. package/src/settings/use-settings-focus.ts +120 -0
  267. package/src/settings/use-settings-route.ts +258 -0
  268. package/src/shell/app-shell.tsx +10 -2
  269. package/src/shell.ts +51 -0
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/settings/use-account-language.ts"],"sourcesContent":["import { useCallback, useMemo } from \"react\";\n\n/**\n * Where the language on screen came from (docs/settings-harmonization.md §6.2), in the\n * order they are asked.\n */\nexport type AccountLanguageSource = \"device\" | \"account\" | \"browser\" | \"default\";\n\n/** `de_ch`, `DE-ch` → `de-CH`: the case and separator a BCP 47 tag is written in. */\nfunction canonicalTag(tag: string): string {\n const parts = tag.trim().replace(/_/g, \"-\").split(\"-\").filter(Boolean);\n return parts\n .map((part, i) => {\n if (i === 0) return part.toLowerCase();\n if (part.length === 2) return part.toUpperCase();\n if (part.length === 4) return part[0].toUpperCase() + part.slice(1).toLowerCase();\n return part.toLowerCase();\n })\n .join(\"-\");\n}\n\n/**\n * The offered language a tag means, or `null` — the client's half of server-kit's\n * `canonical_locale(tag, offered)` (§6.2): case and `_` normalised; an offered tag matches\n * exactly; otherwise the first offered tag of the same language (`de`, `de-DE` → `de-CH`\n * where `de-CH` is what the app offers); anything else is no match.\n */\nexport function matchOfferedLanguage(tag: string | null | undefined, offered: readonly string[]): string | null {\n if (!tag || !tag.trim()) return null;\n const wanted = canonicalTag(tag);\n const exact = offered.find((code) => canonicalTag(code) === wanted);\n if (exact !== undefined) return exact;\n const language = wanted.split(\"-\")[0];\n return offered.find((code) => canonicalTag(code).split(\"-\")[0] === language) ?? null;\n}\n\nexport interface ResolveAccountLanguageInput {\n /** This device's own choice — made on this device, stored only when the user picks. */\n device: string | null | undefined;\n /** The signed-in account's `locale`; `undefined` while signed out. */\n account: string | null | undefined;\n /** The browser's languages, most wanted first (`navigator.languages`). */\n browser?: readonly string[];\n /** The languages the app offers. */\n offered: readonly string[];\n /** The app's default, when nothing else matches. */\n fallback: string;\n}\n\nexport interface ResolvedAccountLanguage {\n language: string;\n source: AccountLanguageSource;\n}\n\n/**\n * The language on a device (§6.2): **its own choice → the account's locale → the\n * browser's language → the app's default**, each taken only when it names a language the\n * app offers. Pure: the order, without the storage.\n *\n * \"At sign-in the account applies unless the device has its own choice\" is this order and\n * nothing more: once the account's locale arrives it outranks the browser, never the\n * device's pick.\n */\nexport function resolveAccountLanguage(input: ResolveAccountLanguageInput): ResolvedAccountLanguage {\n const { device, account, browser = [], offered, fallback } = input;\n const fromDevice = matchOfferedLanguage(device, offered);\n if (fromDevice) return { language: fromDevice, source: \"device\" };\n const fromAccount = matchOfferedLanguage(account, offered);\n if (fromAccount) return { language: fromAccount, source: \"account\" };\n for (const tag of browser) {\n const fromBrowser = matchOfferedLanguage(tag, offered);\n if (fromBrowser) return { language: fromBrowser, source: \"browser\" };\n }\n return { language: fallback, source: \"default\" };\n}\n\nexport interface UseAccountLanguageOptions {\n /** The signed-in account's `locale` (`/auth/me`). `undefined` while signed out or not\n * loaded yet; `null` for an account with no language set. */\n account: string | null | undefined;\n /** This device's own choice — `<app>-lang` — or `null` when it was never picked here.\n * Detection must not fill it (i18next's detector with `caches: []`, §6.2). */\n device: string | null | undefined;\n /** Store the device's own choice (`<app>-lang`). */\n setDevice: (code: string) => void;\n /** Write the account's locale: `PATCH /auth/me {locale}`. Return its promise. */\n save: (code: string) => unknown;\n /** A demo session: a pick stays on the device, the account is never written (§6.2). */\n isDemo?: boolean;\n /** The languages the app offers, as it writes them. */\n offered: readonly string[];\n /** The app's default language. */\n fallback: string;\n /** The browser's languages. Default: `navigator.languages`. */\n browser?: readonly string[];\n}\n\nexport interface AccountLanguage extends ResolvedAccountLanguage {\n /**\n * The user picked `code` (the language card, the top bar's quick switch): it becomes the\n * device's own choice and — signed in, and not a demo — the account's locale. The\n * promise is `save`'s; the device keeps the pick if it fails, and the app says so.\n */\n pick: (code: string) => Promise<unknown>;\n}\n\nfunction navigatorLanguages(): readonly string[] {\n if (typeof navigator === \"undefined\") return [];\n return navigator.languages?.length ? navigator.languages : navigator.language ? [navigator.language] : [];\n}\n\n/**\n * The account's language on this device (§6.2, §10.8): the order of\n * {@link resolveAccountLanguage}, and the writes — so the three apps' copies go.\n *\n * - **Nothing writes the account on its own.** Rendering, signing in and adopting the\n * account's locale call neither `save` nor `setDevice`; keksdose's\n * `useSyncLanguageToAccount` (a PATCH on every session start) overwrote the account\n * from a new device before that device could adopt it.\n * - **A pick** writes the device's choice, then `save` — except in a demo session, whose\n * throwaway account needs no language (and model R refuses the write).\n *\n * The hook only answers; the app applies `language` (`i18n.changeLanguage`) where it\n * applies languages today.\n */\nexport function useAccountLanguage(options: UseAccountLanguageOptions): AccountLanguage {\n const { account, device, setDevice, save, isDemo = false, offered, fallback, browser } = options;\n const resolved = useMemo(\n () => resolveAccountLanguage({ device, account, browser: browser ?? navigatorLanguages(), offered, fallback }),\n [device, account, browser, offered, fallback],\n );\n const signedIn = account !== undefined;\n const pick = useCallback(\n (code: string) => {\n setDevice(code);\n if (isDemo || !signedIn) return Promise.resolve();\n try {\n return Promise.resolve(save(code));\n } catch (error) {\n return Promise.reject(error);\n }\n },\n [setDevice, save, isDemo, signedIn],\n );\n return { ...resolved, pick };\n}\n"],"mappings":";AAAA,SAAS,aAAa,eAAe;AASrC,SAAS,aAAa,KAAqB;AACzC,QAAM,QAAQ,IAAI,KAAK,EAAE,QAAQ,MAAM,GAAG,EAAE,MAAM,GAAG,EAAE,OAAO,OAAO;AACrE,SAAO,MACJ,IAAI,CAAC,MAAM,MAAM;AAChB,QAAI,MAAM,EAAG,QAAO,KAAK,YAAY;AACrC,QAAI,KAAK,WAAW,EAAG,QAAO,KAAK,YAAY;AAC/C,QAAI,KAAK,WAAW,EAAG,QAAO,KAAK,CAAC,EAAE,YAAY,IAAI,KAAK,MAAM,CAAC,EAAE,YAAY;AAChF,WAAO,KAAK,YAAY;AAAA,EAC1B,CAAC,EACA,KAAK,GAAG;AACb;AAQO,SAAS,qBAAqB,KAAgC,SAA2C;AAC9G,MAAI,CAAC,OAAO,CAAC,IAAI,KAAK,EAAG,QAAO;AAChC,QAAM,SAAS,aAAa,GAAG;AAC/B,QAAM,QAAQ,QAAQ,KAAK,CAAC,SAAS,aAAa,IAAI,MAAM,MAAM;AAClE,MAAI,UAAU,OAAW,QAAO;AAChC,QAAM,WAAW,OAAO,MAAM,GAAG,EAAE,CAAC;AACpC,SAAO,QAAQ,KAAK,CAAC,SAAS,aAAa,IAAI,EAAE,MAAM,GAAG,EAAE,CAAC,MAAM,QAAQ,KAAK;AAClF;AA6BO,SAAS,uBAAuB,OAA6D;AAClG,QAAM,EAAE,QAAQ,SAAS,UAAU,CAAC,GAAG,SAAS,SAAS,IAAI;AAC7D,QAAM,aAAa,qBAAqB,QAAQ,OAAO;AACvD,MAAI,WAAY,QAAO,EAAE,UAAU,YAAY,QAAQ,SAAS;AAChE,QAAM,cAAc,qBAAqB,SAAS,OAAO;AACzD,MAAI,YAAa,QAAO,EAAE,UAAU,aAAa,QAAQ,UAAU;AACnE,aAAW,OAAO,SAAS;AACzB,UAAM,cAAc,qBAAqB,KAAK,OAAO;AACrD,QAAI,YAAa,QAAO,EAAE,UAAU,aAAa,QAAQ,UAAU;AAAA,EACrE;AACA,SAAO,EAAE,UAAU,UAAU,QAAQ,UAAU;AACjD;AAgCA,SAAS,qBAAwC;AAC/C,MAAI,OAAO,cAAc,YAAa,QAAO,CAAC;AAC9C,SAAO,UAAU,WAAW,SAAS,UAAU,YAAY,UAAU,WAAW,CAAC,UAAU,QAAQ,IAAI,CAAC;AAC1G;AAgBO,SAAS,mBAAmB,SAAqD;AACtF,QAAM,EAAE,SAAS,QAAQ,WAAW,MAAM,SAAS,OAAO,SAAS,UAAU,QAAQ,IAAI;AACzF,QAAM,WAAW;AAAA,IACf,MAAM,uBAAuB,EAAE,QAAQ,SAAS,SAAS,WAAW,mBAAmB,GAAG,SAAS,SAAS,CAAC;AAAA,IAC7G,CAAC,QAAQ,SAAS,SAAS,SAAS,QAAQ;AAAA,EAC9C;AACA,QAAM,WAAW,YAAY;AAC7B,QAAM,OAAO;AAAA,IACX,CAAC,SAAiB;AAChB,gBAAU,IAAI;AACd,UAAI,UAAU,CAAC,SAAU,QAAO,QAAQ,QAAQ;AAChD,UAAI;AACF,eAAO,QAAQ,QAAQ,KAAK,IAAI,CAAC;AAAA,MACnC,SAAS,OAAO;AACd,eAAO,QAAQ,OAAO,KAAK;AAAA,MAC7B;AAAA,IACF;AAAA,IACA,CAAC,WAAW,MAAM,QAAQ,QAAQ;AAAA,EACpC;AACA,SAAO,EAAE,GAAG,UAAU,KAAK;AAC7B;","names":[]}
@@ -0,0 +1,34 @@
1
+ /**
2
+ * The ring a `?focus=` target wears for {@link SETTINGS_FOCUS_MS} — keksdose's
3
+ * `HIGHLIGHT`, in tokens: `--brand` for the ring and the page's own surface for the gap,
4
+ * where keksdose named a slate for the dark theme. A ring rather than a background
5
+ * flash: the destructive cards already own a background tint, and a ring reads the same
6
+ * on every card.
7
+ */
8
+ declare const SETTINGS_FOCUS_RING: string[];
9
+ /** How long the ring stays (§3.5). */
10
+ declare const SETTINGS_FOCUS_MS = 1800;
11
+ /**
12
+ * `?focus=<anchor>` (docs/settings-harmonization.md §3.5): scroll the card into view, ring
13
+ * it for 1.8 s, move focus to its heading for a screen reader, then remove `focus` — and
14
+ * ONLY `focus` — with a replace, so a reload does not ring it again. Every other
15
+ * parameter stays: a tour's `tour` / `tourStep`, the admin roster's filters (dropping URL
16
+ * parts broke keksdose's tours once, dev#495). The hash and `location.state` stay too.
17
+ *
18
+ * A search hit, ⌘K, a tour and a mail link all arrive this way. `SettingsLayout` calls it
19
+ * for its own page; an app's own page can call it with no argument to act on its URL.
20
+ *
21
+ * `anchor`:
22
+ * - left out: the URL's `focus` parameter;
23
+ * - a string: that anchor (the layout passes the URL's, once its route is canonical);
24
+ * - `null`: nothing to do yet — the layout passes it while the address is about to be
25
+ * replaced, so a removal here cannot undo a redirect there.
26
+ *
27
+ * "Once its group has rendered": the effect runs after the commit that mounted the
28
+ * group's cards, so the card is there; a card that mounts later (lazy, or behind its
29
+ * data) is waited for, for a few seconds, before the hook gives up and leaves the URL
30
+ * as it is.
31
+ */
32
+ declare function useSettingsFocus(anchor?: string | null): void;
33
+
34
+ export { SETTINGS_FOCUS_MS, SETTINGS_FOCUS_RING, useSettingsFocus };
@@ -0,0 +1,77 @@
1
+ "use client";
2
+ import { useEffect, useRef } from "react";
3
+ import { useLocation, useNavigate } from "react-router";
4
+ const SETTINGS_FOCUS_RING = ["ring-2", "ring-[var(--brand)]", "ring-offset-2", "ring-offset-[var(--bg-page)]"];
5
+ const SETTINGS_FOCUS_MS = 1800;
6
+ const WAIT_MS = 5e3;
7
+ const HEADING = "[data-settings-heading], h1, h2, h3, h4, h5, h6";
8
+ function prefersReducedMotion() {
9
+ return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
10
+ }
11
+ function useSettingsFocus(anchor) {
12
+ const location = useLocation();
13
+ const navigate = useNavigate();
14
+ const target = anchor === void 0 ? new URLSearchParams(location.search).get("focus") : anchor;
15
+ const ring = useRef(null);
16
+ useEffect(() => {
17
+ if (!target) return;
18
+ const { pathname, search, hash, state } = location;
19
+ const land = (el) => {
20
+ el.scrollIntoView?.({ behavior: prefersReducedMotion() ? "auto" : "smooth", block: "center" });
21
+ if (ring.current) {
22
+ window.clearTimeout(ring.current.timer);
23
+ ring.current.el.classList.remove(...SETTINGS_FOCUS_RING);
24
+ }
25
+ el.classList.add(...SETTINGS_FOCUS_RING);
26
+ el.setAttribute("data-settings-focus", "");
27
+ const timer = window.setTimeout(() => {
28
+ el.classList.remove(...SETTINGS_FOCUS_RING);
29
+ el.removeAttribute("data-settings-focus");
30
+ if (ring.current?.el === el) ring.current = null;
31
+ }, SETTINGS_FOCUS_MS);
32
+ ring.current = { el, timer };
33
+ const heading = el.querySelector(HEADING) ?? el;
34
+ if (!heading.hasAttribute("tabindex")) heading.setAttribute("tabindex", "-1");
35
+ heading.focus({ preventScroll: true });
36
+ const params = new URLSearchParams(search);
37
+ if (params.get("focus") !== target) return;
38
+ params.delete("focus");
39
+ const rest = params.toString();
40
+ navigate({ pathname, search: rest ? `?${rest}` : "", hash }, { replace: true, state });
41
+ };
42
+ const found = document.getElementById(target);
43
+ if (found) {
44
+ land(found);
45
+ return;
46
+ }
47
+ const observer = new MutationObserver(() => {
48
+ const el = document.getElementById(target);
49
+ if (!el) return;
50
+ observer.disconnect();
51
+ window.clearTimeout(giveUp);
52
+ land(el);
53
+ });
54
+ observer.observe(document.body, { childList: true, subtree: true });
55
+ const giveUp = window.setTimeout(() => observer.disconnect(), WAIT_MS);
56
+ return () => {
57
+ observer.disconnect();
58
+ window.clearTimeout(giveUp);
59
+ };
60
+ }, [target, location, navigate]);
61
+ useEffect(
62
+ () => () => {
63
+ const current = ring.current;
64
+ if (!current) return;
65
+ window.clearTimeout(current.timer);
66
+ current.el.classList.remove(...SETTINGS_FOCUS_RING);
67
+ current.el.removeAttribute("data-settings-focus");
68
+ },
69
+ []
70
+ );
71
+ }
72
+ export {
73
+ SETTINGS_FOCUS_MS,
74
+ SETTINGS_FOCUS_RING,
75
+ useSettingsFocus
76
+ };
77
+ //# sourceMappingURL=use-settings-focus.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/settings/use-settings-focus.ts"],"sourcesContent":["import { useEffect, useRef } from \"react\";\nimport { useLocation, useNavigate } from \"react-router\";\n\n/**\n * The ring a `?focus=` target wears for {@link SETTINGS_FOCUS_MS} — keksdose's\n * `HIGHLIGHT`, in tokens: `--brand` for the ring and the page's own surface for the gap,\n * where keksdose named a slate for the dark theme. A ring rather than a background\n * flash: the destructive cards already own a background tint, and a ring reads the same\n * on every card.\n */\nexport const SETTINGS_FOCUS_RING = [\"ring-2\", \"ring-[var(--brand)]\", \"ring-offset-2\", \"ring-offset-[var(--bg-page)]\"];\n\n/** How long the ring stays (§3.5). */\nexport const SETTINGS_FOCUS_MS = 1800;\n\n/** How long a target that is not on the page yet (a lazy card, a card waiting for its\n * data) is waited for before the hook gives up on it. */\nconst WAIT_MS = 5000;\n\nconst HEADING = \"[data-settings-heading], h1, h2, h3, h4, h5, h6\";\n\nfunction prefersReducedMotion(): boolean {\n return typeof window.matchMedia === \"function\" && window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n}\n\n/**\n * `?focus=<anchor>` (docs/settings-harmonization.md §3.5): scroll the card into view, ring\n * it for 1.8 s, move focus to its heading for a screen reader, then remove `focus` — and\n * ONLY `focus` — with a replace, so a reload does not ring it again. Every other\n * parameter stays: a tour's `tour` / `tourStep`, the admin roster's filters (dropping URL\n * parts broke keksdose's tours once, dev#495). The hash and `location.state` stay too.\n *\n * A search hit, ⌘K, a tour and a mail link all arrive this way. `SettingsLayout` calls it\n * for its own page; an app's own page can call it with no argument to act on its URL.\n *\n * `anchor`:\n * - left out: the URL's `focus` parameter;\n * - a string: that anchor (the layout passes the URL's, once its route is canonical);\n * - `null`: nothing to do yet — the layout passes it while the address is about to be\n * replaced, so a removal here cannot undo a redirect there.\n *\n * \"Once its group has rendered\": the effect runs after the commit that mounted the\n * group's cards, so the card is there; a card that mounts later (lazy, or behind its\n * data) is waited for, for a few seconds, before the hook gives up and leaves the URL\n * as it is.\n */\nexport function useSettingsFocus(anchor?: string | null): void {\n const location = useLocation();\n const navigate = useNavigate();\n const target = anchor === undefined ? new URLSearchParams(location.search).get(\"focus\") : anchor;\n // The ring outlives the effect that drew it: removing `focus` from the URL re-runs the\n // effect (and cleans it up) at once, and the ring must still stay its 1.8 s.\n const ring = useRef<{ el: HTMLElement; timer: number } | null>(null);\n\n useEffect(() => {\n if (!target) return;\n const { pathname, search, hash, state } = location;\n\n const land = (el: HTMLElement) => {\n el.scrollIntoView?.({ behavior: prefersReducedMotion() ? \"auto\" : \"smooth\", block: \"center\" });\n\n if (ring.current) {\n window.clearTimeout(ring.current.timer);\n ring.current.el.classList.remove(...SETTINGS_FOCUS_RING);\n }\n el.classList.add(...SETTINGS_FOCUS_RING);\n el.setAttribute(\"data-settings-focus\", \"\");\n const timer = window.setTimeout(() => {\n el.classList.remove(...SETTINGS_FOCUS_RING);\n el.removeAttribute(\"data-settings-focus\");\n if (ring.current?.el === el) ring.current = null;\n }, SETTINGS_FOCUS_MS);\n ring.current = { el, timer };\n\n // The heading, so a screen reader starts at the card's name; the card itself when\n // it has none. `preventScroll`: the smooth scroll above is already on its way.\n const heading = el.querySelector<HTMLElement>(HEADING) ?? el;\n if (!heading.hasAttribute(\"tabindex\")) heading.setAttribute(\"tabindex\", \"-1\");\n heading.focus({ preventScroll: true });\n\n const params = new URLSearchParams(search);\n if (params.get(\"focus\") !== target) return;\n params.delete(\"focus\");\n const rest = params.toString();\n navigate({ pathname, search: rest ? `?${rest}` : \"\", hash }, { replace: true, state });\n };\n\n const found = document.getElementById(target);\n if (found) {\n land(found);\n return;\n }\n const observer = new MutationObserver(() => {\n const el = document.getElementById(target);\n if (!el) return;\n observer.disconnect();\n window.clearTimeout(giveUp);\n land(el);\n });\n observer.observe(document.body, { childList: true, subtree: true });\n const giveUp = window.setTimeout(() => observer.disconnect(), WAIT_MS);\n return () => {\n observer.disconnect();\n window.clearTimeout(giveUp);\n };\n // `location`, a new object per navigation: the same hit picked twice is two\n // navigations, and rings twice.\n }, [target, location, navigate]);\n\n useEffect(\n () => () => {\n const current = ring.current;\n if (!current) return;\n window.clearTimeout(current.timer);\n current.el.classList.remove(...SETTINGS_FOCUS_RING);\n current.el.removeAttribute(\"data-settings-focus\");\n },\n [],\n );\n}\n"],"mappings":";AAAA,SAAS,WAAW,cAAc;AAClC,SAAS,aAAa,mBAAmB;AASlC,MAAM,sBAAsB,CAAC,UAAU,uBAAuB,iBAAiB,8BAA8B;AAG7G,MAAM,oBAAoB;AAIjC,MAAM,UAAU;AAEhB,MAAM,UAAU;AAEhB,SAAS,uBAAgC;AACvC,SAAO,OAAO,OAAO,eAAe,cAAc,OAAO,WAAW,kCAAkC,EAAE;AAC1G;AAuBO,SAAS,iBAAiB,QAA8B;AAC7D,QAAM,WAAW,YAAY;AAC7B,QAAM,WAAW,YAAY;AAC7B,QAAM,SAAS,WAAW,SAAY,IAAI,gBAAgB,SAAS,MAAM,EAAE,IAAI,OAAO,IAAI;AAG1F,QAAM,OAAO,OAAkD,IAAI;AAEnE,YAAU,MAAM;AACd,QAAI,CAAC,OAAQ;AACb,UAAM,EAAE,UAAU,QAAQ,MAAM,MAAM,IAAI;AAE1C,UAAM,OAAO,CAAC,OAAoB;AAChC,SAAG,iBAAiB,EAAE,UAAU,qBAAqB,IAAI,SAAS,UAAU,OAAO,SAAS,CAAC;AAE7F,UAAI,KAAK,SAAS;AAChB,eAAO,aAAa,KAAK,QAAQ,KAAK;AACtC,aAAK,QAAQ,GAAG,UAAU,OAAO,GAAG,mBAAmB;AAAA,MACzD;AACA,SAAG,UAAU,IAAI,GAAG,mBAAmB;AACvC,SAAG,aAAa,uBAAuB,EAAE;AACzC,YAAM,QAAQ,OAAO,WAAW,MAAM;AACpC,WAAG,UAAU,OAAO,GAAG,mBAAmB;AAC1C,WAAG,gBAAgB,qBAAqB;AACxC,YAAI,KAAK,SAAS,OAAO,GAAI,MAAK,UAAU;AAAA,MAC9C,GAAG,iBAAiB;AACpB,WAAK,UAAU,EAAE,IAAI,MAAM;AAI3B,YAAM,UAAU,GAAG,cAA2B,OAAO,KAAK;AAC1D,UAAI,CAAC,QAAQ,aAAa,UAAU,EAAG,SAAQ,aAAa,YAAY,IAAI;AAC5E,cAAQ,MAAM,EAAE,eAAe,KAAK,CAAC;AAErC,YAAM,SAAS,IAAI,gBAAgB,MAAM;AACzC,UAAI,OAAO,IAAI,OAAO,MAAM,OAAQ;AACpC,aAAO,OAAO,OAAO;AACrB,YAAM,OAAO,OAAO,SAAS;AAC7B,eAAS,EAAE,UAAU,QAAQ,OAAO,IAAI,IAAI,KAAK,IAAI,KAAK,GAAG,EAAE,SAAS,MAAM,MAAM,CAAC;AAAA,IACvF;AAEA,UAAM,QAAQ,SAAS,eAAe,MAAM;AAC5C,QAAI,OAAO;AACT,WAAK,KAAK;AACV;AAAA,IACF;AACA,UAAM,WAAW,IAAI,iBAAiB,MAAM;AAC1C,YAAM,KAAK,SAAS,eAAe,MAAM;AACzC,UAAI,CAAC,GAAI;AACT,eAAS,WAAW;AACpB,aAAO,aAAa,MAAM;AAC1B,WAAK,EAAE;AAAA,IACT,CAAC;AACD,aAAS,QAAQ,SAAS,MAAM,EAAE,WAAW,MAAM,SAAS,KAAK,CAAC;AAClE,UAAM,SAAS,OAAO,WAAW,MAAM,SAAS,WAAW,GAAG,OAAO;AACrE,WAAO,MAAM;AACX,eAAS,WAAW;AACpB,aAAO,aAAa,MAAM;AAAA,IAC5B;AAAA,EAGF,GAAG,CAAC,QAAQ,UAAU,QAAQ,CAAC;AAE/B;AAAA,IACE,MAAM,MAAM;AACV,YAAM,UAAU,KAAK;AACrB,UAAI,CAAC,QAAS;AACd,aAAO,aAAa,QAAQ,KAAK;AACjC,cAAQ,GAAG,UAAU,OAAO,GAAG,mBAAmB;AAClD,cAAQ,GAAG,gBAAgB,qBAAqB;AAAA,IAClD;AAAA,IACA,CAAC;AAAA,EACH;AACF;","names":[]}
@@ -0,0 +1,118 @@
1
+ import { SettingsGroup, SettingsEntry } from './settings-catalogue.js';
2
+ import 'react';
3
+ import '../search/search-index.js';
4
+
5
+ /**
6
+ * Which layout the settings page is in. `"auto"` follows the screen ({@link PHONE_QUERY},
7
+ * as `Tabs` does); `"desktop"` / `"phone"` pin one — for a preview of both side by side,
8
+ * where the window is one width.
9
+ */
10
+ type SettingsLayoutMode = "auto" | "desktop" | "phone";
11
+ interface UseSettingsRouteOptions<G extends string> {
12
+ groups: readonly SettingsGroup<G>[];
13
+ /** The catalogue's entries. Given, a group with no visible entry is hidden (§4.1), and
14
+ * `/settings?focus=<anchor>` finds the anchor's group. */
15
+ entries?: readonly SettingsEntry<G>[];
16
+ /** `/settings` or `/admin`. */
17
+ basePath: string;
18
+ /** The group `/settings` opens on a desktop. Default: the first visible group (§4.1). */
19
+ defaultGroup?: G;
20
+ /** Retired group ids and their successors — `{ overview: "metrics" }` — applied with a
21
+ * replace (§3.1, keksdose's admin). */
22
+ aliases?: Readonly<Partial<Record<string, G>>>;
23
+ /** See {@link SettingsLayoutMode}. Default `"auto"`. */
24
+ layout?: SettingsLayoutMode;
25
+ }
26
+ interface SettingsRoute<G extends string> {
27
+ /** The group on screen. `null` on a phone's list (and while there is no visible group). */
28
+ group: G | null;
29
+ /** The segment after the group, owned by a card; `null` without one. */
30
+ sub: string | null;
31
+ /** `?focus=<anchor>`, or `null`. */
32
+ focus: string | null;
33
+ /** Open a group: a PUSH on a phone, so the system back gesture returns to the list; a
34
+ * REPLACE on a desktop, so back leaves settings instead of walking every group looked
35
+ * at (§3.1, keksdose's rule). */
36
+ select: (group: G) => void;
37
+ /** See {@link SettingsLayoutContextValue.selectSub}. */
38
+ selectSub: (sub: string | null) => void;
39
+ /** To the list: `navigate(-1)` when the list is the previous entry, else a replace to
40
+ * the base path — so a deep link from a mail never sends "back" out of the app. */
41
+ back: () => void;
42
+ /** The visible groups, in order. */
43
+ groups: SettingsGroup<G>[];
44
+ /** Whether the phone layout is in force. */
45
+ phone: boolean;
46
+ /** Whether this location is about to be replaced (a legacy hash, an alias, an unknown
47
+ * group): what renders meanwhile is already the destination's, but nothing should
48
+ * act on the URL until it is. */
49
+ redirecting: boolean;
50
+ /** Whether the list is the entry before this one (the marker {@link select} and the
51
+ * list's links leave in `location.state`). */
52
+ fromList: boolean;
53
+ }
54
+ /**
55
+ * The `location.state` key that says "the list is the entry before this one". Written by
56
+ * the phone list's links and {@link SettingsRoute.select}; carried over by every replace
57
+ * the page makes, since a replace does not change what came before.
58
+ */
59
+ declare const SETTINGS_FROM_LIST_STATE = "eifi1.settings.fromList";
60
+ /** `location.state` with the marker set — for an app's own link to a group page that is
61
+ * opened FROM the list it renders. */
62
+ declare function settingsFromListState(): Record<string, true>;
63
+ interface SettingsLocation {
64
+ pathname: string;
65
+ search: string;
66
+ hash: string;
67
+ }
68
+ interface SettingsLocationRules<G extends string> {
69
+ basePath: string;
70
+ /** The visible group ids. */
71
+ groups: readonly G[];
72
+ /** The resolved default group, `null` with no visible group. */
73
+ defaultGroup: G | null;
74
+ aliases?: Readonly<Partial<Record<string, G>>>;
75
+ /** Anchor → group, for `/settings?focus=<anchor>` with no group named. */
76
+ anchorGroup?: ReadonlyMap<string, G>;
77
+ phone: boolean;
78
+ }
79
+ interface SettingsLocationResult<G extends string> {
80
+ group: G | null;
81
+ sub: string | null;
82
+ /** The path (with query) to replace the location with, or `null` when it is already
83
+ * canonical. */
84
+ redirect: string | null;
85
+ }
86
+ /**
87
+ * Where a location lands on the settings page — pure, so the rules of §3.1 are testable
88
+ * without a router:
89
+ *
90
+ * - `/settings` → the default group on a desktop (a replace), the list on a phone;
91
+ * - `/settings/<group>[/<sub>]` → that group, the rest of the path as `sub`;
92
+ * - an alias → its successor, with a replace, `sub`, query and hash kept;
93
+ * - an unknown or hidden group → the default group (desktop) or the list (phone), with
94
+ * a replace and the query kept — never a 404, so an old bookmark still lands;
95
+ * - keksdose's legacy `/settings?<query>#<group>[/<sub>]` →
96
+ * `/settings/<group>[/<sub>]?<query>`, the WHOLE query carried (a tour's
97
+ * `tour`/`tourStep` as well as `focus`), then resolved by the rules above;
98
+ * - `/settings?focus=<anchor>` with no group → the anchor's group, where it can ring.
99
+ */
100
+ declare function resolveSettingsLocation<G extends string>(location: SettingsLocation, rules: SettingsLocationRules<G>): SettingsLocationResult<G>;
101
+ /**
102
+ * The settings page's route (docs/settings-harmonization.md §3.1): which group the URL
103
+ * names, and the history rules for moving between groups.
104
+ *
105
+ * The URL IS the selection, as it was in keksdose (where it was the hash): there is no
106
+ * state to keep in step with it, so a deep link, a reload, back and forward all land
107
+ * where the address says. Everything that is not canonical — `/settings` on a desktop, a
108
+ * legacy hash, an alias, an unknown or hidden group — is replaced by its canonical path.
109
+ *
110
+ * **The replace runs on every navigation**, keyed on `location.key`, not once on mount:
111
+ * the router keeps the page mounted across links followed inside the app — keksdose's
112
+ * inbox renders admin-typed URLs, and a push click routes an open window without a
113
+ * reload — so a legacy `/settings#security` arriving while the page is open converts too
114
+ * (§10.1).
115
+ */
116
+ declare function useSettingsRoute<G extends string>(options: UseSettingsRouteOptions<G>): SettingsRoute<G>;
117
+
118
+ export { SETTINGS_FROM_LIST_STATE, type SettingsLayoutMode, type SettingsLocation, type SettingsLocationResult, type SettingsLocationRules, type SettingsRoute, type UseSettingsRouteOptions, resolveSettingsLocation, settingsFromListState, useSettingsRoute };
@@ -0,0 +1,120 @@
1
+ "use client";
2
+ import { useCallback, useEffect, useMemo } from "react";
3
+ import { useLocation, useNavigate } from "react-router";
4
+ import { useMediaQuery } from "../hooks/use-media-query.js";
5
+ import { PHONE_QUERY } from "../components/ui.js";
6
+ import { normalizeSettingsBase, visibleSettingsGroups } from "./settings-catalogue.js";
7
+ const SETTINGS_FROM_LIST_STATE = "eifi1.settings.fromList";
8
+ function settingsFromListState() {
9
+ return { [SETTINGS_FROM_LIST_STATE]: true };
10
+ }
11
+ function isFromList(state) {
12
+ return typeof state === "object" && state !== null && state[SETTINGS_FROM_LIST_STATE] === true;
13
+ }
14
+ function resolveSettingsLocation(location, rules) {
15
+ const base = normalizeSettingsBase(rules.basePath);
16
+ const { pathname, search, hash } = location;
17
+ const known = new Set(rules.groups);
18
+ const atBase = pathname === base || pathname === `${base}/` || base === "" && pathname === "/";
19
+ if (atBase) {
20
+ const legacy = hash.replace(/^#/, "").replace(/^\/+/, "");
21
+ if (legacy) {
22
+ const converted = `${base}/${legacy}`;
23
+ const next = resolveSettingsLocation({ pathname: converted, search, hash: "" }, rules);
24
+ return { ...next, redirect: next.redirect ?? `${converted}${search}` };
25
+ }
26
+ const anchor = new URLSearchParams(search).get("focus");
27
+ const anchorGroup = anchor ? rules.anchorGroup?.get(anchor) : void 0;
28
+ if (anchorGroup && known.has(anchorGroup)) {
29
+ return { group: anchorGroup, sub: null, redirect: `${base}/${anchorGroup}${search}` };
30
+ }
31
+ if (rules.phone || rules.defaultGroup === null) return { group: null, sub: null, redirect: null };
32
+ return { group: rules.defaultGroup, sub: null, redirect: `${base}/${rules.defaultGroup}${search}` };
33
+ }
34
+ if (!pathname.startsWith(`${base}/`)) {
35
+ return { group: rules.phone ? null : rules.defaultGroup, sub: null, redirect: null };
36
+ }
37
+ const rest = pathname.slice(base.length + 1);
38
+ const [segment = "", ...subParts] = rest.split("/");
39
+ const sub = subParts.filter(Boolean).join("/") || null;
40
+ if (known.has(segment)) return { group: segment, sub, redirect: null };
41
+ const successor = rules.aliases?.[segment];
42
+ if (successor !== void 0 && known.has(successor)) {
43
+ return {
44
+ group: successor,
45
+ sub,
46
+ redirect: `${base}/${successor}${sub ? `/${sub}` : ""}${search}${hash}`
47
+ };
48
+ }
49
+ if (rules.phone || rules.defaultGroup === null) {
50
+ return { group: null, sub: null, redirect: `${base || "/"}${search}` };
51
+ }
52
+ return { group: rules.defaultGroup, sub: null, redirect: `${base}/${rules.defaultGroup}${search}` };
53
+ }
54
+ function useSettingsRoute(options) {
55
+ const { groups, entries, basePath, defaultGroup, aliases, layout = "auto" } = options;
56
+ const location = useLocation();
57
+ const navigate = useNavigate();
58
+ const narrow = useMediaQuery(PHONE_QUERY, false);
59
+ const phone = layout === "phone" || layout === "auto" && narrow;
60
+ const base = normalizeSettingsBase(basePath);
61
+ const visible = useMemo(() => visibleSettingsGroups(groups, entries), [groups, entries]);
62
+ const anchorGroup = useMemo(() => {
63
+ const map = /* @__PURE__ */ new Map();
64
+ for (const e of entries ?? []) if (e.visible !== false && !map.has(e.anchor)) map.set(e.anchor, e.group);
65
+ return map;
66
+ }, [entries]);
67
+ const ids = visible.map((g) => g.id);
68
+ const fallback = defaultGroup !== void 0 && ids.includes(defaultGroup) ? defaultGroup : ids[0] ?? null;
69
+ const { group, sub, redirect } = resolveSettingsLocation(location, {
70
+ basePath: base,
71
+ groups: ids,
72
+ defaultGroup: fallback,
73
+ aliases,
74
+ anchorGroup,
75
+ phone
76
+ });
77
+ const { key, state, search } = location;
78
+ useEffect(() => {
79
+ if (redirect) navigate(redirect, { replace: true, state });
80
+ }, [key, redirect, state, navigate]);
81
+ const select = useCallback(
82
+ (next) => {
83
+ const to = `${base}/${next}`;
84
+ if (phone) navigate(to, group === null ? { state: settingsFromListState() } : void 0);
85
+ else navigate(to, { replace: true, state });
86
+ },
87
+ [base, phone, group, state, navigate]
88
+ );
89
+ const selectSub = useCallback(
90
+ (next) => {
91
+ if (group === null) return;
92
+ navigate(`${base}/${group}${next ? `/${next}` : ""}${search}`, { replace: true, state });
93
+ },
94
+ [base, group, search, state, navigate]
95
+ );
96
+ const fromList = isFromList(state);
97
+ const back = useCallback(() => {
98
+ if (fromList) navigate(-1);
99
+ else navigate(base || "/", { replace: true });
100
+ }, [fromList, base, navigate]);
101
+ return {
102
+ group,
103
+ sub,
104
+ focus: new URLSearchParams(search).get("focus"),
105
+ select,
106
+ selectSub,
107
+ back,
108
+ groups: visible,
109
+ phone,
110
+ redirecting: redirect !== null,
111
+ fromList
112
+ };
113
+ }
114
+ export {
115
+ SETTINGS_FROM_LIST_STATE,
116
+ resolveSettingsLocation,
117
+ settingsFromListState,
118
+ useSettingsRoute
119
+ };
120
+ //# sourceMappingURL=use-settings-route.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/settings/use-settings-route.ts"],"sourcesContent":["import { useCallback, useEffect, useMemo } from \"react\";\nimport { useLocation, useNavigate } from \"react-router\";\nimport { useMediaQuery } from \"../hooks/use-media-query\";\nimport { PHONE_QUERY } from \"../components/ui\";\nimport { normalizeSettingsBase, visibleSettingsGroups } from \"./settings-catalogue\";\nimport type { SettingsEntry, SettingsGroup } from \"./settings-catalogue\";\n\n/**\n * Which layout the settings page is in. `\"auto\"` follows the screen ({@link PHONE_QUERY},\n * as `Tabs` does); `\"desktop\"` / `\"phone\"` pin one — for a preview of both side by side,\n * where the window is one width.\n */\nexport type SettingsLayoutMode = \"auto\" | \"desktop\" | \"phone\";\n\nexport interface UseSettingsRouteOptions<G extends string> {\n groups: readonly SettingsGroup<G>[];\n /** The catalogue's entries. Given, a group with no visible entry is hidden (§4.1), and\n * `/settings?focus=<anchor>` finds the anchor's group. */\n entries?: readonly SettingsEntry<G>[];\n /** `/settings` or `/admin`. */\n basePath: string;\n /** The group `/settings` opens on a desktop. Default: the first visible group (§4.1). */\n defaultGroup?: G;\n /** Retired group ids and their successors — `{ overview: \"metrics\" }` — applied with a\n * replace (§3.1, keksdose's admin). */\n aliases?: Readonly<Partial<Record<string, G>>>;\n /** See {@link SettingsLayoutMode}. Default `\"auto\"`. */\n layout?: SettingsLayoutMode;\n}\n\nexport interface SettingsRoute<G extends string> {\n /** The group on screen. `null` on a phone's list (and while there is no visible group). */\n group: G | null;\n /** The segment after the group, owned by a card; `null` without one. */\n sub: string | null;\n /** `?focus=<anchor>`, or `null`. */\n focus: string | null;\n /** Open a group: a PUSH on a phone, so the system back gesture returns to the list; a\n * REPLACE on a desktop, so back leaves settings instead of walking every group looked\n * at (§3.1, keksdose's rule). */\n select: (group: G) => void;\n /** See {@link SettingsLayoutContextValue.selectSub}. */\n selectSub: (sub: string | null) => void;\n /** To the list: `navigate(-1)` when the list is the previous entry, else a replace to\n * the base path — so a deep link from a mail never sends \"back\" out of the app. */\n back: () => void;\n /** The visible groups, in order. */\n groups: SettingsGroup<G>[];\n /** Whether the phone layout is in force. */\n phone: boolean;\n /** Whether this location is about to be replaced (a legacy hash, an alias, an unknown\n * group): what renders meanwhile is already the destination's, but nothing should\n * act on the URL until it is. */\n redirecting: boolean;\n /** Whether the list is the entry before this one (the marker {@link select} and the\n * list's links leave in `location.state`). */\n fromList: boolean;\n}\n\n/**\n * The `location.state` key that says \"the list is the entry before this one\". Written by\n * the phone list's links and {@link SettingsRoute.select}; carried over by every replace\n * the page makes, since a replace does not change what came before.\n */\nexport const SETTINGS_FROM_LIST_STATE = \"eifi1.settings.fromList\";\n\n/** `location.state` with the marker set — for an app's own link to a group page that is\n * opened FROM the list it renders. */\nexport function settingsFromListState(): Record<string, true> {\n return { [SETTINGS_FROM_LIST_STATE]: true };\n}\n\nfunction isFromList(state: unknown): boolean {\n return typeof state === \"object\" && state !== null && (state as Record<string, unknown>)[SETTINGS_FROM_LIST_STATE] === true;\n}\n\nexport interface SettingsLocation {\n pathname: string;\n search: string;\n hash: string;\n}\n\nexport interface SettingsLocationRules<G extends string> {\n basePath: string;\n /** The visible group ids. */\n groups: readonly G[];\n /** The resolved default group, `null` with no visible group. */\n defaultGroup: G | null;\n aliases?: Readonly<Partial<Record<string, G>>>;\n /** Anchor → group, for `/settings?focus=<anchor>` with no group named. */\n anchorGroup?: ReadonlyMap<string, G>;\n phone: boolean;\n}\n\nexport interface SettingsLocationResult<G extends string> {\n group: G | null;\n sub: string | null;\n /** The path (with query) to replace the location with, or `null` when it is already\n * canonical. */\n redirect: string | null;\n}\n\n/**\n * Where a location lands on the settings page — pure, so the rules of §3.1 are testable\n * without a router:\n *\n * - `/settings` → the default group on a desktop (a replace), the list on a phone;\n * - `/settings/<group>[/<sub>]` → that group, the rest of the path as `sub`;\n * - an alias → its successor, with a replace, `sub`, query and hash kept;\n * - an unknown or hidden group → the default group (desktop) or the list (phone), with\n * a replace and the query kept — never a 404, so an old bookmark still lands;\n * - keksdose's legacy `/settings?<query>#<group>[/<sub>]` →\n * `/settings/<group>[/<sub>]?<query>`, the WHOLE query carried (a tour's\n * `tour`/`tourStep` as well as `focus`), then resolved by the rules above;\n * - `/settings?focus=<anchor>` with no group → the anchor's group, where it can ring.\n */\nexport function resolveSettingsLocation<G extends string>(\n location: SettingsLocation,\n rules: SettingsLocationRules<G>,\n): SettingsLocationResult<G> {\n const base = normalizeSettingsBase(rules.basePath);\n const { pathname, search, hash } = location;\n const known = new Set<string>(rules.groups);\n const atBase = pathname === base || pathname === `${base}/` || (base === \"\" && pathname === \"/\");\n\n if (atBase) {\n const legacy = hash.replace(/^#/, \"\").replace(/^\\/+/, \"\");\n if (legacy) {\n // The hash becomes the path; whatever the path then says (an alias, an unknown\n // group) is resolved in the same step, so the history sees one replace.\n const converted = `${base}/${legacy}`;\n const next = resolveSettingsLocation({ pathname: converted, search, hash: \"\" }, rules);\n return { ...next, redirect: next.redirect ?? `${converted}${search}` };\n }\n const anchor = new URLSearchParams(search).get(\"focus\");\n const anchorGroup = anchor ? rules.anchorGroup?.get(anchor) : undefined;\n if (anchorGroup && known.has(anchorGroup)) {\n return { group: anchorGroup, sub: null, redirect: `${base}/${anchorGroup}${search}` };\n }\n if (rules.phone || rules.defaultGroup === null) return { group: null, sub: null, redirect: null };\n return { group: rules.defaultGroup, sub: null, redirect: `${base}/${rules.defaultGroup}${search}` };\n }\n\n if (!pathname.startsWith(`${base}/`)) {\n // Mounted somewhere `basePath` does not name: a misconfiguration, not a link to\n // correct. Show what the base path would, and leave the address alone.\n return { group: rules.phone ? null : rules.defaultGroup, sub: null, redirect: null };\n }\n const rest = pathname.slice(base.length + 1);\n const [segment = \"\", ...subParts] = rest.split(\"/\");\n const sub = subParts.filter(Boolean).join(\"/\") || null;\n if (known.has(segment)) return { group: segment as G, sub, redirect: null };\n\n const successor = rules.aliases?.[segment];\n if (successor !== undefined && known.has(successor)) {\n return {\n group: successor,\n sub,\n redirect: `${base}/${successor}${sub ? `/${sub}` : \"\"}${search}${hash}`,\n };\n }\n\n // Unknown or hidden. The hash is dropped: on the list it would read as a legacy group\n // and send the page round again.\n if (rules.phone || rules.defaultGroup === null) {\n return { group: null, sub: null, redirect: `${base || \"/\"}${search}` };\n }\n return { group: rules.defaultGroup, sub: null, redirect: `${base}/${rules.defaultGroup}${search}` };\n}\n\n/**\n * The settings page's route (docs/settings-harmonization.md §3.1): which group the URL\n * names, and the history rules for moving between groups.\n *\n * The URL IS the selection, as it was in keksdose (where it was the hash): there is no\n * state to keep in step with it, so a deep link, a reload, back and forward all land\n * where the address says. Everything that is not canonical — `/settings` on a desktop, a\n * legacy hash, an alias, an unknown or hidden group — is replaced by its canonical path.\n *\n * **The replace runs on every navigation**, keyed on `location.key`, not once on mount:\n * the router keeps the page mounted across links followed inside the app — keksdose's\n * inbox renders admin-typed URLs, and a push click routes an open window without a\n * reload — so a legacy `/settings#security` arriving while the page is open converts too\n * (§10.1).\n */\nexport function useSettingsRoute<G extends string>(options: UseSettingsRouteOptions<G>): SettingsRoute<G> {\n const { groups, entries, basePath, defaultGroup, aliases, layout = \"auto\" } = options;\n const location = useLocation();\n const navigate = useNavigate();\n const narrow = useMediaQuery(PHONE_QUERY, false);\n const phone = layout === \"phone\" || (layout === \"auto\" && narrow);\n const base = normalizeSettingsBase(basePath);\n\n const visible = useMemo(() => visibleSettingsGroups(groups, entries), [groups, entries]);\n const anchorGroup = useMemo(() => {\n const map = new Map<string, G>();\n for (const e of entries ?? []) if (e.visible !== false && !map.has(e.anchor)) map.set(e.anchor, e.group);\n return map;\n }, [entries]);\n const ids = visible.map((g) => g.id);\n const fallback =\n defaultGroup !== undefined && ids.includes(defaultGroup) ? defaultGroup : (ids[0] ?? null);\n\n const { group, sub, redirect } = resolveSettingsLocation(location, {\n basePath: base,\n groups: ids,\n defaultGroup: fallback,\n aliases,\n anchorGroup,\n phone,\n });\n\n const { key, state, search } = location;\n useEffect(() => {\n // `state` rides along: the marker describes the entry BEFORE this one, which a\n // replace does not change.\n if (redirect) navigate(redirect, { replace: true, state });\n }, [key, redirect, state, navigate]);\n\n const select = useCallback(\n (next: G) => {\n const to = `${base}/${next}`;\n // From the list, the push marks it, so the group page's back is a real back.\n if (phone) navigate(to, group === null ? { state: settingsFromListState() } : undefined);\n // A new group is a new page: the old one's query (a focus, a roster filter) stays\n // behind, as keksdose's `selectGroup` left it.\n else navigate(to, { replace: true, state });\n },\n [base, phone, group, state, navigate],\n );\n\n const selectSub = useCallback(\n (next: string | null) => {\n if (group === null) return;\n navigate(`${base}/${group}${next ? `/${next}` : \"\"}${search}`, { replace: true, state });\n },\n [base, group, search, state, navigate],\n );\n\n const fromList = isFromList(state);\n const back = useCallback(() => {\n if (fromList) navigate(-1);\n else navigate(base || \"/\", { replace: true });\n }, [fromList, base, navigate]);\n\n return {\n group,\n sub,\n focus: new URLSearchParams(search).get(\"focus\"),\n select,\n selectSub,\n back,\n groups: visible,\n phone,\n redirecting: redirect !== null,\n fromList,\n };\n}\n"],"mappings":"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","names":[]}
@@ -1,7 +1,7 @@
1
1
  import * as react from 'react';
2
2
  import { ComponentPropsWithoutRef, ReactNode } from 'react';
3
3
  import { LucideIcon } from 'lucide-react';
4
- import { dR as KitLinkComponent } from '../delete-account-setting-CGVMckMw.js';
4
+ import { dR as KitLinkComponent } from '../delete-account-setting-CpIhzxxZ.js';
5
5
  import '../components/tooltip.js';
6
6
  import '../hooks/use-anchored-rect.js';
7
7
  import '../lib/clipping.js';
@@ -55,6 +55,8 @@ import '../components/image-grid.js';
55
55
  import '../components/lightbox.js';
56
56
  import '../components/write-lock.js';
57
57
  import '../auth/sign-in-form.js';
58
+ import '../landing/access.js';
59
+ import '../landing/landing-labels.js';
58
60
  import './topbar-controls.js';
59
61
  import '../theme/palette-presets.js';
60
62
  import '../theme/chart-palette.js';
@@ -76,6 +78,8 @@ import '../account/data-export-setting.js';
76
78
  import '../components/account-chips.js';
77
79
  import '../admin/admin-parts.js';
78
80
  import '../admin/admin-action-log.js';
81
+ import '../settings/settings-labels.js';
82
+ import '../demo/demo-labels.js';
79
83
  import '../components/reauth-dialog.js';
80
84
  import '../components/server-wake.js';
81
85
  import '../lib/server-wake.js';
@@ -111,6 +115,12 @@ interface AppShellNavItem {
111
115
  * shortening the real label, which the sidebar has ample room for and which is the
112
116
  * name the rest of the product uses. */
113
117
  shortLabel?: string;
118
+ /** 0.31.0: leave this entry out of the MOBILE BOTTOM BAR; the sidebar keeps it.
119
+ * For an entry a phone reaches another way — the brand link to the start page —
120
+ * when one cell fewer is what keeps the others' labels whole (the bar divides its
121
+ * width evenly, see `shortLabel`). Its group's sub-row still shows while one of its
122
+ * pages is open. Named like `DataTable`'s column `mobileHidden`. */
123
+ mobileHidden?: boolean;
114
124
  icon: LucideIcon;
115
125
  /** Passed to NavLink's `end` (exact match). Defaults to true. */
116
126
  end?: boolean;
@@ -57,6 +57,7 @@ function AppShell({
57
57
  ...rest
58
58
  }) {
59
59
  const embedded = useContext(AppShellNesting) || !!embeddedProp;
60
+ const barNav = nav.filter((item) => !item.mobileHidden);
60
61
  const collapseStorageKey = collapseStorageKeyProp ?? (embedded ? null : "appLayout.sidebarCollapsed");
61
62
  const labels = useKitLabels("appShell", DEFAULT_APP_SHELL_LABELS, {
62
63
  collapse: collapseLabel,
@@ -174,8 +175,8 @@ function AppShell({
174
175
  {
175
176
  "data-tour": "nav",
176
177
  className: "grid",
177
- style: { gridTemplateColumns: `repeat(${nav.length}, minmax(0, 1fr))` },
178
- children: nav.map((item) => /* @__PURE__ */ jsx(MobileNavItem, { item }, item.to))
178
+ style: { gridTemplateColumns: `repeat(${barNav.length}, minmax(0, 1fr))` },
179
+ children: barNav.map((item) => /* @__PURE__ */ jsx(MobileNavItem, { item }, item.to))
179
180
  }
180
181
  )
181
182
  ]