@eifi1/ui-kit 0.46.1 → 0.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/README.md +5 -5
  2. package/dist/components/data-table.js +18 -1
  3. package/dist/components/data-table.js.map +1 -1
  4. package/dist/components/row-actions.d.ts +25 -0
  5. package/dist/components/row-actions.js +13 -3
  6. package/dist/components/row-actions.js.map +1 -1
  7. package/dist/components/tabs.d.ts +24 -16
  8. package/dist/components/tabs.js +37 -31
  9. package/dist/components/tabs.js.map +1 -1
  10. package/dist/components/ui-labels.js +1 -1
  11. package/dist/components/ui-labels.js.map +1 -1
  12. package/dist/eslint/index.js +1 -1
  13. package/dist/feedback/feedback-attachment-download.d.ts +19 -0
  14. package/dist/feedback/feedback-attachment-download.js +59 -0
  15. package/dist/feedback/feedback-attachment-download.js.map +1 -0
  16. package/dist/feedback/feedback-comment-thread.d.ts +17 -6
  17. package/dist/feedback/feedback-comment-thread.js +21 -2
  18. package/dist/feedback/feedback-comment-thread.js.map +1 -1
  19. package/dist/feedback/feedback-entry-file.d.ts +61 -0
  20. package/dist/feedback/feedback-entry-file.js +76 -0
  21. package/dist/feedback/feedback-entry-file.js.map +1 -0
  22. package/dist/feedback/feedback-public-list.d.ts +18 -5
  23. package/dist/feedback/feedback-public-list.js +8 -0
  24. package/dist/feedback/feedback-public-list.js.map +1 -1
  25. package/dist/feedback/feedback-public-types.d.ts +7 -2
  26. package/dist/feedback/feedback-replies.d.ts +26 -9
  27. package/dist/feedback/feedback-replies.js +20 -10
  28. package/dist/feedback/feedback-replies.js.map +1 -1
  29. package/dist/feedback/feedback-row-detail-labels.js +0 -1
  30. package/dist/feedback/feedback-row-detail-labels.js.map +1 -1
  31. package/dist/feedback/feedback-row-detail.d.ts +23 -10
  32. package/dist/feedback/feedback-row-detail.js +10 -54
  33. package/dist/feedback/feedback-row-detail.js.map +1 -1
  34. package/dist/feedback.d.ts +1 -0
  35. package/dist/feedback.js +1 -0
  36. package/dist/feedback.js.map +1 -1
  37. package/dist/i18n/german.js +1 -6
  38. package/dist/i18n/german.js.map +1 -1
  39. package/dist/i18n/locales/en.js +1 -6
  40. package/dist/i18n/locales/en.js.map +2 -2
  41. package/dist/i18n/locales/es.js +1 -6
  42. package/dist/i18n/locales/es.js.map +1 -1
  43. package/dist/i18n/locales/fr.js +1 -6
  44. package/dist/i18n/locales/fr.js.map +1 -1
  45. package/dist/i18n/locales/hu.js +1 -6
  46. package/dist/i18n/locales/hu.js.map +1 -1
  47. package/dist/i18n/locales/it.js +1 -6
  48. package/dist/i18n/locales/it.js.map +1 -1
  49. package/dist/i18n/locales/zh.js +1 -6
  50. package/dist/i18n/locales/zh.js.map +1 -1
  51. package/dist/i18n/review.js +0 -1
  52. package/dist/i18n/review.js.map +1 -1
  53. package/dist/index.d.ts +2 -4
  54. package/dist/index.js +11 -4
  55. package/dist/index.js.map +1 -1
  56. package/dist/notifications/device-zone-report.d.ts +1 -1
  57. package/dist/notifications/device-zone-report.js.map +1 -1
  58. package/dist/notifications/notification-types.d.ts +0 -3
  59. package/dist/notifications/notifications-labels.d.ts +2 -15
  60. package/dist/notifications/notifications-labels.js +0 -4
  61. package/dist/notifications/notifications-labels.js.map +1 -1
  62. package/dist/notifications/quiet-hours-setting.d.ts +3 -8
  63. package/dist/notifications/quiet-hours-setting.js +8 -3
  64. package/dist/notifications/quiet-hours-setting.js.map +1 -1
  65. package/dist/theme/theme-store.d.ts +4 -4
  66. package/dist/theme/theme-store.js +1 -0
  67. package/dist/theme/theme-store.js.map +1 -1
  68. package/package.json +2 -2
  69. package/src/components/data-table.tsx +29 -3
  70. package/src/components/row-actions.tsx +42 -3
  71. package/src/components/tabs.tsx +66 -44
  72. package/src/components/ui-labels.ts +1 -1
  73. package/src/feedback/feedback-attachment-download.tsx +77 -0
  74. package/src/feedback/feedback-comment-thread.tsx +42 -7
  75. package/src/feedback/feedback-entry-file.tsx +150 -0
  76. package/src/feedback/feedback-public-list.tsx +25 -4
  77. package/src/feedback/feedback-public-types.ts +7 -2
  78. package/src/feedback/feedback-replies.tsx +46 -19
  79. package/src/feedback/feedback-row-detail-labels.ts +0 -1
  80. package/src/feedback/feedback-row-detail.tsx +35 -86
  81. package/src/feedback.ts +1 -0
  82. package/src/i18n/german.ts +1 -6
  83. package/src/i18n/locales/es.ts +1 -6
  84. package/src/i18n/locales/fr.ts +1 -6
  85. package/src/i18n/locales/hu.ts +1 -6
  86. package/src/i18n/locales/it.ts +1 -6
  87. package/src/i18n/locales/zh.ts +1 -6
  88. package/src/i18n/review.ts +0 -1
  89. package/src/index.ts +10 -3
  90. package/src/notifications/device-zone-report.ts +1 -1
  91. package/src/notifications/notification-types.ts +0 -3
  92. package/src/notifications/notifications-labels.ts +2 -19
  93. package/src/notifications/quiet-hours-setting.tsx +16 -11
  94. package/src/theme/theme-store.ts +13 -5
  95. package/dist/notifications/time-zone-select.d.ts +0 -54
  96. package/dist/notifications/time-zone-select.js +0 -71
  97. package/dist/notifications/time-zone-select.js.map +0 -1
  98. package/src/notifications/time-zone-select.tsx +0 -125
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/notifications/quiet-hours-setting.tsx"],"sourcesContent":["import { useId, useState } from \"react\";\nimport type { ComponentPropsWithRef, ReactNode } from \"react\";\n\nimport type { LabelOverride } from \"../i18n/kit-labels\";\nimport { cn } from \"../lib/cn\";\nimport { Card } from \"../components/card\";\nimport { TimeInput } from \"../components/time-input\";\nimport { COMMIT_EXCEPT_BILLING } from \"../components/write-lock\";\nimport { CARD_DESCRIPTION_CLASS } from \"../settings/settings-card-classes\";\nimport { SettingsCardTitle } from \"../settings/settings-heading\";\nimport { settle, useMounted } from \"../account/account-parts\";\nimport { describeFailure, FailureLine, useNotificationsLabels, useWriteOverlay } from \"./notification-parts\";\nimport type { NotificationsLabels } from \"./notifications-labels\";\nimport type { NotificationSettingsPatch } from \"./notification-types\";\nimport { zoneName } from \"./time-zone-select\";\n\n/** The zone the server applies while no device ever reported one (§10.3): every app's. */\nconst DEFAULT_FALLBACK_ZONE = \"Europe/Zurich\";\n\nexport interface QuietHoursSettingProps extends Omit<ComponentPropsWithRef<\"div\">, \"children\" | \"onChange\"> {\n /** The `GET`'s `quiet_hours_start`, `\"HH:MM\"`, or `null`. */\n start: string | null;\n /** The `GET`'s `quiet_hours_end`. */\n end: string | null;\n /** The zone the 0.36 card showed, an IANA name, or `null`.\n * @deprecated 0.46 — pass `zone` (§10.3: the zone is the account's, automatic only); removed in 0.47.\n * Shown read-only while `zone` is left out; the card no longer offers a picker. */\n timezone?: string | null;\n /**\n * 0.46 (§10.3, amended 2026-10-20: automatic only): the account's zone — `/auth/me`'s\n * `device_timezone`, the zone of the device last used; `null` while no device has\n * reported one, and the card then names `fallbackZone`, the zone the server applies.\n * Shown read-only once a time is set (\"In your time zone (Europe / Zurich), which follows\n * the device you last used.\"). No picker: the zone is never chosen.\n */\n zone?: string | null;\n /** 0.46 (§10.4): the app queues a held push and sends it when the window ends (it runs\n * the notifications tick); the description says so. Default `false`: dropped. */\n held?: boolean;\n /**\n * Save one change — `PATCH /notifications/settings`: a time, or `null` when cleared.\n * Never a zone. The field shows the new value while the call runs and goes back if it\n * rejects.\n */\n onChange: (patch: NotificationSettingsPatch) => Promise<unknown> | void;\n /** The zone named while neither `zone` nor `timezone` names one: the one the server\n * applies. Default `\"Europe/Zurich\"`, every app's. */\n fallbackZone?: string;\n /** The app keeps an inbox, and the description says a held push still reaches it. */\n inbox?: boolean;\n disabled?: boolean;\n /** The app's own words for a failed save, or `undefined` for the kit's `failed`. */\n describeError?: (error: unknown) => ReactNode | undefined;\n labels?: LabelOverride<NotificationsLabels>;\n}\n\ntype QuietField = \"quiet_hours_start\" | \"quiet_hours_end\";\n\n/**\n * Quiet hours (docs/notifications-harmonization.md §2.4, §2.6, §4.2): a window of local\n * times in the account's zone, which may cross midnight, when no push of a machine-sent\n * type is sent. A held push is dropped — or, with `held` (0.46, §10.4), sent when the\n * window ends; the inbox row and the mail go as usual. keksdose's block, as a card of its\n * own.\n *\n * **0.46 (§10.3, amended 2026-10-20, Marcel): the zone is the account's, automatic\n * only** — the zone of the device last used, else `Europe/Zurich`. The card names it\n * read-only and never sends one; there is no picker and no \"Use it\".\n *\n * - **Two times**, each saved as it is entered; a cleared time sends `null`. While only\n * one is set the card says so: the window fails open until both are.\n * - **The zone** shows while a time is set: `zone`, else the deprecated `timezone`, else\n * `fallbackZone`.\n *\n * Under a write lock the fields are `commit` controls exempt from billing.\n */\nexport function QuietHoursSetting({\n start,\n end,\n timezone = null,\n zone,\n held = false,\n onChange,\n fallbackZone = DEFAULT_FALLBACK_ZONE,\n inbox = false,\n disabled = false,\n describeError,\n labels: labelsProp,\n className,\n ...rest\n}: QuietHoursSettingProps) {\n const labels = useNotificationsLabels(labelsProp);\n const overlay = useWriteOverlay<string | null>(`${start}|${end}`);\n const [failure, setFailure] = useState<ReactNode>(null);\n const mounted = useMounted();\n const id = useId();\n\n const shown = (field: QuietField, server: string | null) =>\n overlay.has(field) ? (overlay.get(field) ?? null) : server;\n const startShown = shown(\"quiet_hours_start\", start);\n const endShown = shown(\"quiet_hours_end\", end);\n const accountZone = (zone === undefined ? timezone : zone) ?? fallbackZone;\n\n const save = (patch: NotificationSettingsPatch) => {\n const fields = Object.keys(patch) as QuietField[];\n setFailure(null);\n for (const field of fields) overlay.set(field, patch[field] ?? null);\n settle(() => onChange(patch)).then(\n () => {\n if (mounted.current) for (const field of fields) overlay.settle(field, true);\n },\n (error: unknown) => {\n if (!mounted.current) return;\n for (const field of fields) overlay.settle(field, false);\n setFailure(describeFailure(describeError?.(error), labels.failed));\n },\n );\n };\n\n const saveTime = (field: \"quiet_hours_start\" | \"quiet_hours_end\", value: string) => {\n const next = value || null;\n if (next === shown(field, field === \"quiet_hours_start\" ? start : end)) return;\n save({ [field]: next });\n };\n\n const anySet = Boolean(startShown || endShown);\n const halfSet = Boolean(startShown) !== Boolean(endShown);\n\n return (\n <Card {...rest} className={cn(\"p-4 space-y-3\", className)}>\n <div>\n <SettingsCardTitle>{labels.quietTitle}</SettingsCardTitle>\n <div className={CARD_DESCRIPTION_CLASS}>{labels.quietDescription(inbox, held)}</div>\n </div>\n <div className=\"flex flex-wrap items-end gap-3\">\n <TimeInput\n id={`${id}-start`}\n label={labels.quietFrom}\n value={startShown ?? \"\"}\n disabled={disabled}\n commit={COMMIT_EXCEPT_BILLING}\n onValueChange={(value) => saveTime(\"quiet_hours_start\", value)}\n className=\"w-36\"\n />\n <TimeInput\n id={`${id}-end`}\n label={labels.quietUntil}\n value={endShown ?? \"\"}\n disabled={disabled}\n commit={COMMIT_EXCEPT_BILLING}\n onValueChange={(value) => saveTime(\"quiet_hours_end\", value)}\n className=\"w-36\"\n />\n </div>\n {halfSet && (\n <p className=\"text-xs text-warning\" data-quiet-incomplete=\"\">\n {labels.quietIncomplete}\n </p>\n )}\n {anySet && (\n <p className={CARD_DESCRIPTION_CLASS} data-quiet-zone=\"\">\n {labels.quietZone(zoneName(accountZone, labels.regions))}\n </p>\n )}\n <FailureLine>{failure}</FailureLine>\n </Card>\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/notifications/quiet-hours-setting.tsx"],"sourcesContent":["import { useId, useState } from \"react\";\nimport type { ComponentPropsWithRef, ReactNode } from \"react\";\n\nimport type { LabelOverride } from \"../i18n/kit-labels\";\nimport { cn } from \"../lib/cn\";\nimport { Card } from \"../components/card\";\nimport { TimeInput } from \"../components/time-input\";\nimport { COMMIT_EXCEPT_BILLING } from \"../components/write-lock\";\nimport { CARD_DESCRIPTION_CLASS } from \"../settings/settings-card-classes\";\nimport { SettingsCardTitle } from \"../settings/settings-heading\";\nimport { settle, useMounted } from \"../account/account-parts\";\nimport { describeFailure, FailureLine, useNotificationsLabels, useWriteOverlay } from \"./notification-parts\";\nimport type { NotificationRegionLabels, NotificationsLabels } from \"./notifications-labels\";\nimport type { NotificationSettingsPatch } from \"./notification-types\";\n\n/** The zone the server applies while no device ever reported one (§10.3): every app's. */\nconst DEFAULT_FALLBACK_ZONE = \"Europe/Zurich\";\n\n/** A zone for a sentence: \"Europe / Zurich\", the region in the kit's words (`labels.regions`);\n * a region the labels do not know keeps its IANA name, a zone with none (\"UTC\") is itself. */\nfunction zoneName(zone: string, regions: NotificationRegionLabels): string {\n const slash = zone.indexOf(\"/\");\n if (slash < 0) return zone;\n const region = zone.slice(0, slash);\n const named = Object.prototype.hasOwnProperty.call(regions, region)\n ? regions[region as keyof NotificationRegionLabels]\n : region;\n return `${named} / ${zone.slice(slash + 1).replace(/_/g, \" \").replace(/\\//g, \" / \")}`;\n}\n\nexport interface QuietHoursSettingProps extends Omit<ComponentPropsWithRef<\"div\">, \"children\" | \"onChange\"> {\n /** The `GET`'s `quiet_hours_start`, `\"HH:MM\"`, or `null`. */\n start: string | null;\n /** The `GET`'s `quiet_hours_end`. */\n end: string | null;\n /**\n * 0.46 (§10.3, amended 2026-10-20: automatic only): the account's zone — `/auth/me`'s\n * `device_timezone`, the zone of the device last used; `null` while no device has\n * reported one, and the card then names `fallbackZone`, the zone the server applies.\n * Shown read-only once a time is set (\"In your time zone (Europe / Zurich), which follows\n * the device you last used.\"). No picker: the zone is never chosen.\n */\n zone?: string | null;\n /** 0.46 (§10.4): the app queues a held push and sends it when the window ends (it runs\n * the notifications tick); the description says so. Default `false`: dropped. */\n held?: boolean;\n /**\n * Save one change — `PATCH /notifications/settings`: a time, or `null` when cleared.\n * Never a zone. The field shows the new value while the call runs and goes back if it\n * rejects.\n */\n onChange: (patch: NotificationSettingsPatch) => Promise<unknown> | void;\n /** The zone named while `zone` names none: the one the server\n * applies. Default `\"Europe/Zurich\"`, every app's. */\n fallbackZone?: string;\n /** The app keeps an inbox, and the description says a held push still reaches it. */\n inbox?: boolean;\n disabled?: boolean;\n /** The app's own words for a failed save, or `undefined` for the kit's `failed`. */\n describeError?: (error: unknown) => ReactNode | undefined;\n labels?: LabelOverride<NotificationsLabels>;\n}\n\ntype QuietField = \"quiet_hours_start\" | \"quiet_hours_end\";\n\n/**\n * Quiet hours (docs/notifications-harmonization.md §2.4, §2.6, §4.2): a window of local\n * times in the account's zone, which may cross midnight, when no push of a machine-sent\n * type is sent. A held push is dropped — or, with `held` (0.46, §10.4), sent when the\n * window ends; the inbox row and the mail go as usual. keksdose's block, as a card of its\n * own.\n *\n * **0.46 (§10.3, amended 2026-10-20, Marcel): the zone is the account's, automatic\n * only** — the zone of the device last used, else `Europe/Zurich`. The card names it\n * read-only and never sends one; there is no picker and no \"Use it\".\n *\n * - **Two times**, each saved as it is entered; a cleared time sends `null`. While only\n * one is set the card says so: the window fails open until both are.\n * - **The zone** shows while a time is set: `zone`, else `fallbackZone`.\n *\n * Under a write lock the fields are `commit` controls exempt from billing.\n */\nexport function QuietHoursSetting({\n start,\n end,\n zone,\n held = false,\n onChange,\n fallbackZone = DEFAULT_FALLBACK_ZONE,\n inbox = false,\n disabled = false,\n describeError,\n labels: labelsProp,\n className,\n ...rest\n}: QuietHoursSettingProps) {\n const labels = useNotificationsLabels(labelsProp);\n const overlay = useWriteOverlay<string | null>(`${start}|${end}`);\n const [failure, setFailure] = useState<ReactNode>(null);\n const mounted = useMounted();\n const id = useId();\n\n const shown = (field: QuietField, server: string | null) =>\n overlay.has(field) ? (overlay.get(field) ?? null) : server;\n const startShown = shown(\"quiet_hours_start\", start);\n const endShown = shown(\"quiet_hours_end\", end);\n const accountZone = zone ?? fallbackZone;\n\n const save = (patch: NotificationSettingsPatch) => {\n const fields = Object.keys(patch) as QuietField[];\n setFailure(null);\n for (const field of fields) overlay.set(field, patch[field] ?? null);\n settle(() => onChange(patch)).then(\n () => {\n if (mounted.current) for (const field of fields) overlay.settle(field, true);\n },\n (error: unknown) => {\n if (!mounted.current) return;\n for (const field of fields) overlay.settle(field, false);\n setFailure(describeFailure(describeError?.(error), labels.failed));\n },\n );\n };\n\n const saveTime = (field: \"quiet_hours_start\" | \"quiet_hours_end\", value: string) => {\n const next = value || null;\n if (next === shown(field, field === \"quiet_hours_start\" ? start : end)) return;\n save({ [field]: next });\n };\n\n const anySet = Boolean(startShown || endShown);\n const halfSet = Boolean(startShown) !== Boolean(endShown);\n\n return (\n <Card {...rest} className={cn(\"p-4 space-y-3\", className)}>\n <div>\n <SettingsCardTitle>{labels.quietTitle}</SettingsCardTitle>\n <div className={CARD_DESCRIPTION_CLASS}>{labels.quietDescription(inbox, held)}</div>\n </div>\n <div className=\"flex flex-wrap items-end gap-3\">\n <TimeInput\n id={`${id}-start`}\n label={labels.quietFrom}\n value={startShown ?? \"\"}\n disabled={disabled}\n commit={COMMIT_EXCEPT_BILLING}\n onValueChange={(value) => saveTime(\"quiet_hours_start\", value)}\n className=\"w-36\"\n />\n <TimeInput\n id={`${id}-end`}\n label={labels.quietUntil}\n value={endShown ?? \"\"}\n disabled={disabled}\n commit={COMMIT_EXCEPT_BILLING}\n onValueChange={(value) => saveTime(\"quiet_hours_end\", value)}\n className=\"w-36\"\n />\n </div>\n {halfSet && (\n <p className=\"text-xs text-warning\" data-quiet-incomplete=\"\">\n {labels.quietIncomplete}\n </p>\n )}\n {anySet && (\n <p className={CARD_DESCRIPTION_CLASS} data-quiet-zone=\"\">\n {labels.quietZone(zoneName(accountZone, labels.regions))}\n </p>\n )}\n <FailureLine>{failure}</FailureLine>\n </Card>\n );\n}\n"],"mappings":"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","names":[]}
@@ -16,10 +16,10 @@ export interface ThemeState {
16
16
  /**
17
17
  * Pre-hydration: read the theme PREFERENCE persisted under `storageKey` straight
18
18
  * from localStorage, resolve it to a concrete light/dark mode, and toggle the
19
- * `.dark` class on <html> — BEFORE React and the store mount, so the first paint
20
- * already has the right theme (no flash). Returns the resolved mode so the caller
21
- * can chain {@link applyPersistedPalette}. Encapsulates the zustand-persist
22
- * envelope + the legacy `mode`→`preference` fallback so apps never re-type the
19
+ * `.dark` class (and `color-scheme`) on <html> — BEFORE React and the store mount,
20
+ * so the first paint already has the right theme (no flash). Returns the resolved
21
+ * mode so the caller can chain {@link applyPersistedPalette}. Encapsulates the
22
+ * zustand-persist envelope + the legacy `mode`→`preference` fallback so apps never re-type the
23
23
  * storage shape in their entrypoint.
24
24
  */
25
25
  export declare function applyPersistedTheme(storageKey: string): ThemeMode;
@@ -25,6 +25,7 @@ function applyThemeClass(mode) {
25
25
  const root = document.documentElement;
26
26
  if (mode === "dark") root.classList.add("dark");
27
27
  else root.classList.remove("dark");
28
+ root.style.colorScheme = mode;
28
29
  }
29
30
  function applyPersistedTheme(storageKey) {
30
31
  let preference = "system";
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/theme/theme-store.ts"],"sourcesContent":["import { useEffect } from \"react\";\nimport { create } from \"zustand\";\nimport { persist } from \"zustand/middleware\";\nimport { logger } from \"../lib/logger\";\nimport { registerDeviceSetting } from \"../lib/device-settings\";\n\nexport type ThemeMode = \"light\" | \"dark\";\n/** What the user chose; \"system\" tracks the OS `prefers-color-scheme`. */\nexport type ThemePreference = \"light\" | \"dark\" | \"system\";\n\nexport interface ThemeState {\n /** The user's choice (persisted). */\n preference: ThemePreference;\n /** The RESOLVED light/dark actually in effect (system → OS). Read this for\n * anything that needs the concrete theme (e.g. token selection, toasts). */\n mode: ThemeMode;\n setPreference: (preference: ThemePreference) => void;\n /** Set an explicit light/dark choice (preference follows). */\n setMode: (mode: ThemeMode) => void;\n /** Flip the effective theme to the opposite explicit choice. */\n toggle: () => void;\n}\n\nfunction systemPreference(): ThemeMode {\n try {\n return window.matchMedia(\"(prefers-color-scheme: dark)\").matches ? \"dark\" : \"light\";\n } catch {\n return \"light\";\n }\n}\n\nfunction resolve(preference: ThemePreference): ThemeMode {\n return preference === \"system\" ? systemPreference() : preference;\n}\n\n/** Normalize a persisted theme-state blob to a valid preference. Older blobs\n * persisted the concrete `mode` (light/dark); carry that over as an explicit\n * preference so the choice survives. Shared by the store's `migrate` and the\n * pre-hydration {@link applyPersistedTheme}. */\nfunction preferenceFromState(\n s: { mode?: string; preference?: string } | null | undefined,\n): ThemePreference {\n if (s?.preference === \"light\" || s?.preference === \"dark\" || s?.preference === \"system\") {\n return s.preference;\n }\n if (s?.mode === \"light\" || s?.mode === \"dark\") return s.mode;\n return \"system\";\n}\n\n/** Toggle the `.dark` class on <html> for a resolved light/dark mode. */\nfunction applyThemeClass(mode: ThemeMode): void {\n const root = document.documentElement;\n if (mode === \"dark\") root.classList.add(\"dark\");\n else root.classList.remove(\"dark\");\n}\n\n/**\n * Pre-hydration: read the theme PREFERENCE persisted under `storageKey` straight\n * from localStorage, resolve it to a concrete light/dark mode, and toggle the\n * `.dark` class on <html> — BEFORE React and the store mount, so the first paint\n * already has the right theme (no flash). Returns the resolved mode so the caller\n * can chain {@link applyPersistedPalette}. Encapsulates the zustand-persist\n * envelope + the legacy `mode`→`preference` fallback so apps never re-type the\n * storage shape in their entrypoint.\n */\nexport function applyPersistedTheme(storageKey: string): ThemeMode {\n let preference: ThemePreference = \"system\";\n try {\n const blob = JSON.parse(localStorage.getItem(storageKey) ?? \"{}\") as {\n state?: { mode?: string; preference?: string };\n };\n preference = preferenceFromState(blob.state);\n } catch {\n /* malformed storage → system */\n }\n const mode = resolve(preference);\n applyThemeClass(mode);\n return mode;\n}\n\n/**\n * Build a theme store bound to a consumer-supplied localStorage key. Tracks a\n * light/dark/system PREFERENCE (feedback #330) and exposes the resolved `mode`.\n * Returns the `useTheme` hook plus a `useApplyTheme` effect that toggles the\n * `.dark` class on <html> and, while on \"system\", follows OS theme changes live.\n */\nexport function createThemeStore(storageKey: string) {\n const useTheme = create<ThemeState>()(\n logger(\n persist(\n (set, get) => ({\n preference: \"system\" as ThemePreference,\n mode: systemPreference(),\n setPreference: (preference) => set({ preference, mode: resolve(preference) }),\n setMode: (mode) => set({ preference: mode, mode }),\n toggle: () => {\n const next: ThemeMode = get().mode === \"dark\" ? \"light\" : \"dark\";\n set({ preference: next, mode: next });\n },\n }),\n {\n name: storageKey,\n version: 2,\n // Only the preference is persisted; `mode` is derived on load.\n partialize: (s) => ({ preference: s.preference }),\n migrate: (persistedState): { preference: ThemePreference } => ({\n preference: preferenceFromState(\n persistedState as { mode?: string; preference?: string } | null,\n ),\n }),\n onRehydrateStorage: () => (state) => {\n // The persisted preference decides the concrete mode at load.\n if (state) state.mode = resolve(state.preference);\n },\n },\n ),\n \"theme\",\n ),\n );\n // Reset this device's settings (docs/settings-harmonization.md §11.4): back to\n // \"System\" through the setter, which re-resolves `mode`; `useApplyTheme` puts the\n // class back and follows the OS again, and the persisted blob is what the pre-paint\n // `applyPersistedTheme` reads next load.\n registerDeviceSetting({\n key: storageKey,\n kind: \"appearance\",\n reset: () => useTheme.getState().setPreference(\"system\"),\n });\n\n function useApplyTheme(): void {\n const mode = useTheme((s) => s.mode);\n const preference = useTheme((s) => s.preference);\n useEffect(() => {\n applyThemeClass(mode);\n }, [mode]);\n // While tracking the system, follow OS theme changes live.\n useEffect(() => {\n if (preference !== \"system\") return;\n let mq: MediaQueryList;\n try {\n mq = window.matchMedia(\"(prefers-color-scheme: dark)\");\n } catch {\n return;\n }\n const onChange = () => useTheme.setState({ mode: mq.matches ? \"dark\" : \"light\" });\n onChange();\n mq.addEventListener?.(\"change\", onChange);\n return () => mq.removeEventListener?.(\"change\", onChange);\n }, [preference]);\n }\n\n return { useTheme, useApplyTheme };\n}\n\nexport type ThemeStore = ReturnType<typeof createThemeStore>;\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/theme/theme-store.ts"],"sourcesContent":["import { useEffect } from \"react\";\nimport { create } from \"zustand\";\nimport { persist } from \"zustand/middleware\";\nimport { logger } from \"../lib/logger\";\nimport { registerDeviceSetting } from \"../lib/device-settings\";\n\nexport type ThemeMode = \"light\" | \"dark\";\n/** What the user chose; \"system\" tracks the OS `prefers-color-scheme`. */\nexport type ThemePreference = \"light\" | \"dark\" | \"system\";\n\nexport interface ThemeState {\n /** The user's choice (persisted). */\n preference: ThemePreference;\n /** The RESOLVED light/dark actually in effect (system → OS). Read this for\n * anything that needs the concrete theme (e.g. token selection, toasts). */\n mode: ThemeMode;\n setPreference: (preference: ThemePreference) => void;\n /** Set an explicit light/dark choice (preference follows). */\n setMode: (mode: ThemeMode) => void;\n /** Flip the effective theme to the opposite explicit choice. */\n toggle: () => void;\n}\n\nfunction systemPreference(): ThemeMode {\n try {\n return window.matchMedia(\"(prefers-color-scheme: dark)\").matches ? \"dark\" : \"light\";\n } catch {\n return \"light\";\n }\n}\n\nfunction resolve(preference: ThemePreference): ThemeMode {\n return preference === \"system\" ? systemPreference() : preference;\n}\n\n/** Normalize a persisted theme-state blob to a valid preference. Older blobs\n * persisted the concrete `mode` (light/dark); carry that over as an explicit\n * preference so the choice survives. Shared by the store's `migrate` and the\n * pre-hydration {@link applyPersistedTheme}. */\nfunction preferenceFromState(\n s: { mode?: string; preference?: string } | null | undefined,\n): ThemePreference {\n if (s?.preference === \"light\" || s?.preference === \"dark\" || s?.preference === \"system\") {\n return s.preference;\n }\n if (s?.mode === \"light\" || s?.mode === \"dark\") return s.mode;\n return \"system\";\n}\n\n/**\n * Toggle the `.dark` class on <html> for a resolved light/dark mode, and say the same\n * to the BROWSER through `color-scheme` (0.47, keksdose dev #600): the class switches\n * the kit's tokens, but everything the browser paints itself — a native `<select>`'s\n * open list, a date input's popup, scrollbars, autofill — follows `color-scheme` only,\n * and stayed light on the dark theme. Inline on <html>, so it holds whatever an app's\n * stylesheet says, and it is set by the pre-paint {@link applyPersistedTheme} too.\n */\nfunction applyThemeClass(mode: ThemeMode): void {\n const root = document.documentElement;\n if (mode === \"dark\") root.classList.add(\"dark\");\n else root.classList.remove(\"dark\");\n root.style.colorScheme = mode;\n}\n\n/**\n * Pre-hydration: read the theme PREFERENCE persisted under `storageKey` straight\n * from localStorage, resolve it to a concrete light/dark mode, and toggle the\n * `.dark` class (and `color-scheme`) on <html> — BEFORE React and the store mount,\n * so the first paint already has the right theme (no flash). Returns the resolved\n * mode so the caller can chain {@link applyPersistedPalette}. Encapsulates the\n * zustand-persist envelope + the legacy `mode`→`preference` fallback so apps never re-type the\n * storage shape in their entrypoint.\n */\nexport function applyPersistedTheme(storageKey: string): ThemeMode {\n let preference: ThemePreference = \"system\";\n try {\n const blob = JSON.parse(localStorage.getItem(storageKey) ?? \"{}\") as {\n state?: { mode?: string; preference?: string };\n };\n preference = preferenceFromState(blob.state);\n } catch {\n /* malformed storage → system */\n }\n const mode = resolve(preference);\n applyThemeClass(mode);\n return mode;\n}\n\n/**\n * Build a theme store bound to a consumer-supplied localStorage key. Tracks a\n * light/dark/system PREFERENCE (feedback #330) and exposes the resolved `mode`.\n * Returns the `useTheme` hook plus a `useApplyTheme` effect that toggles the\n * `.dark` class on <html> and, while on \"system\", follows OS theme changes live.\n */\nexport function createThemeStore(storageKey: string) {\n const useTheme = create<ThemeState>()(\n logger(\n persist(\n (set, get) => ({\n preference: \"system\" as ThemePreference,\n mode: systemPreference(),\n setPreference: (preference) => set({ preference, mode: resolve(preference) }),\n setMode: (mode) => set({ preference: mode, mode }),\n toggle: () => {\n const next: ThemeMode = get().mode === \"dark\" ? \"light\" : \"dark\";\n set({ preference: next, mode: next });\n },\n }),\n {\n name: storageKey,\n version: 2,\n // Only the preference is persisted; `mode` is derived on load.\n partialize: (s) => ({ preference: s.preference }),\n migrate: (persistedState): { preference: ThemePreference } => ({\n preference: preferenceFromState(\n persistedState as { mode?: string; preference?: string } | null,\n ),\n }),\n onRehydrateStorage: () => (state) => {\n // The persisted preference decides the concrete mode at load.\n if (state) state.mode = resolve(state.preference);\n },\n },\n ),\n \"theme\",\n ),\n );\n // Reset this device's settings (docs/settings-harmonization.md §11.4): back to\n // \"System\" through the setter, which re-resolves `mode`; `useApplyTheme` puts the\n // class back and follows the OS again, and the persisted blob is what the pre-paint\n // `applyPersistedTheme` reads next load.\n registerDeviceSetting({\n key: storageKey,\n kind: \"appearance\",\n reset: () => useTheme.getState().setPreference(\"system\"),\n });\n\n function useApplyTheme(): void {\n const mode = useTheme((s) => s.mode);\n const preference = useTheme((s) => s.preference);\n useEffect(() => {\n applyThemeClass(mode);\n }, [mode]);\n // While tracking the system, follow OS theme changes live.\n useEffect(() => {\n if (preference !== \"system\") return;\n let mq: MediaQueryList;\n try {\n mq = window.matchMedia(\"(prefers-color-scheme: dark)\");\n } catch {\n return;\n }\n const onChange = () => useTheme.setState({ mode: mq.matches ? \"dark\" : \"light\" });\n onChange();\n mq.addEventListener?.(\"change\", onChange);\n return () => mq.removeEventListener?.(\"change\", onChange);\n }, [preference]);\n }\n\n return { useTheme, useApplyTheme };\n}\n\nexport type ThemeStore = ReturnType<typeof createThemeStore>;\n"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eifi1/ui-kit",
3
- "version": "0.46.1",
3
+ "version": "0.47.0",
4
4
  "type": "module",
5
5
  "description": "App-agnostic shared design system: domain-free React components, theme and tokens.",
6
6
  "_sideEffects": "The barrel (src/index.ts) re-exports EVERY component, including components/chart, which statically imports recharts. Without this field a bundler must assume each of those modules has side effects, so it cannot drop the unused ones: importing a Button from '@eifi1/ui-kit' dragged all 433KB of recharts into the consumer's ENTRY chunk, and Keksdose was preloading it on every page load, login included. Nothing in src/ imports for side effects (no bare imports, no CSS imports from JS, no module-level global writes) — the token stylesheet is its own './tokens.css' export — so this is safe. CSS is still listed so any future stylesheet survives. Re-checked when `flag-icons` left `dependencies` and tsup started emitting a `\"use client\"` banner: neither touches this. The flag stylesheet was never imported from JS in the first place (that was the finding), and a directive prologue is a string literal every bundler lifts out before it decides whether a module is inert.",
@@ -213,7 +213,7 @@
213
213
  "tsup": "^8.5.0",
214
214
  "typescript": "~5.9.3",
215
215
  "typescript-eslint": "^8.70.1",
216
- "vite": "^8.3.1",
216
+ "vite": "^8.3.4",
217
217
  "vitest": "^5.0.2"
218
218
  },
219
219
  "license": "MIT",
@@ -1213,6 +1213,17 @@ export function DataTable<T>({
1213
1213
  const isMdUp = !usePhoneLayout();
1214
1214
  // `edgeFade` (0.25) on the desktop scroller; the phone list never scrolls sideways.
1215
1215
  const desktopScroller = useRef<HTMLDivElement | null>(null);
1216
+ // 0.47 (keksdose S4): the scroller's visible width, as `--dt-visible-w` on it, for the
1217
+ // expansion row's pinned content (below). Set on the element, not in state: a resize
1218
+ // re-renders nothing. Only a table that can expand a row measures.
1219
+ const measuresVisibleWidth = isMdUp && !!expandedRow;
1220
+ useEffect(() => {
1221
+ const el = desktopScroller.current;
1222
+ if (!measuresVisibleWidth || !el || typeof ResizeObserver === "undefined") return;
1223
+ const ro = new ResizeObserver(() => el.style.setProperty("--dt-visible-w", `${el.clientWidth}px`));
1224
+ ro.observe(el);
1225
+ return () => ro.disconnect();
1226
+ }, [measuresVisibleWidth]);
1216
1227
  const edgeFadeProps = useEdgeFade(desktopScroller, edgeFade && isMdUp);
1217
1228
 
1218
1229
  // ---- Mobile card list (md:hidden) ----
@@ -1926,10 +1937,25 @@ export function DataTable<T>({
1926
1937
  content in `w-0 min-w-full` pins the cell's preferred
1927
1938
  width to ~0 (the explicit width:0 child), so it can never
1928
1939
  perturb the columns; the div then fills to the full row
1929
- width only at paint time, and long text wraps within it. */}
1940
+ width only at paint time, and long text wraps within it.
1941
+ Inside it the content is pinned to what the scroller SHOWS
1942
+ (0.47, keksdose S4): on a table wider than its scroller a
1943
+ table-wide panel put an inline editor's Save/Cancel off-screen.
1944
+ `sticky start-0` holds it at the scroller's start edge (the
1945
+ right one in RTL) as the row scrolls sideways, and
1946
+ `--dt-visible-w` (the scroller's clientWidth, measured above)
1947
+ sizes it; before the first measure, and in a table no wider
1948
+ than its scroller, it is the full width as before. */}
1930
1949
  <td colSpan={totalColSpan} className="p-0">
1931
- <div className={cn("w-0 min-w-full", compact ? "px-2 py-2" : "px-3 py-3")}>
1932
- {expansion}
1950
+ <div className="w-0 min-w-full">
1951
+ <div
1952
+ className={cn(
1953
+ "sticky start-0 w-[var(--dt-visible-w,100%)] max-w-full",
1954
+ compact ? "px-2 py-2" : "px-3 py-3",
1955
+ )}
1956
+ >
1957
+ {expansion}
1958
+ </div>
1933
1959
  </div>
1934
1960
  </td>
1935
1961
  </tr>
@@ -13,6 +13,7 @@ import { useCommitReason, type CommitScope } from "./write-lock";
13
13
  import type { IconButtonGlyphSize, IconButtonSize } from "./icon-button";
14
14
  import type { TooltipSide } from "./tooltip";
15
15
  import type { DataTableColumn } from "./data-table";
16
+ import { TableHeaderCell, type TableHeaderCellProps } from "./table";
16
17
  import { DEFAULT_ROW_ACTIONS_LABELS } from "./row-actions-labels";
17
18
 
18
19
  /**
@@ -538,6 +539,43 @@ function RowActionsHeader({
538
539
  return hidden ? <span className="sr-only">{word}</span> : <>{word}</>;
539
540
  }
540
541
 
542
+ /** The actions cell's classes — `rowActionsColumn`'s `className`: the column hugs its
543
+ * buttons, on one line, at the row's end. A hand-built table's `<TableCell>` takes them
544
+ * around its {@link RowActions} (0.47, keksdose N17). */
545
+ export const ROW_ACTIONS_CELL_CLASS = "w-px whitespace-nowrap text-end";
546
+ /** The actions heading's classes — `rowActionsColumn`'s `headClassName`, which
547
+ * {@link RowActionsHead} draws: hugging, end-aligned over the buttons (0.47, N17). */
548
+ export const ROW_ACTIONS_HEAD_CLASS = "w-px text-end";
549
+
550
+ export interface RowActionsHeadProps extends Omit<TableHeaderCellProps, "children"> {
551
+ /** The heading. Default: the provider's `rowActions.actions` ("Actions"). */
552
+ children?: ReactNode;
553
+ /** Named for a screen reader only — `rowActionsColumn`'s `header: false`. */
554
+ hideLabel?: boolean;
555
+ labels?: LabelOverride<RowActionsLabels>;
556
+ }
557
+
558
+ /**
559
+ * A hand-built table's actions heading (0.47, keksdose N17): the `<th>` `rowActionsColumn`
560
+ * gives a DataTable — "Actions" on screen in the provider's words, end-aligned over the
561
+ * buttons, hugging them. Pair it with `<TableCell className={ROW_ACTIONS_CELL_CLASS}>`
562
+ * around each row's {@link RowActions}. Every other `TableHeaderCell` prop passes on
563
+ * (`size`, the app's `className`, merged last).
564
+ *
565
+ * <RowActionsHead size="sm" className={TH} />
566
+ */
567
+ export function RowActionsHead({ children, hideLabel, labels, className, ...rest }: RowActionsHeadProps) {
568
+ return (
569
+ <TableHeaderCell align="end" {...rest} className={cn(ROW_ACTIONS_HEAD_CLASS, className)}>
570
+ {typeof children === "string" || children == null ? (
571
+ <RowActionsHeader text={children ?? undefined} labels={labels} hidden={hideLabel} />
572
+ ) : (
573
+ children
574
+ )}
575
+ </TableHeaderCell>
576
+ );
577
+ }
578
+
541
579
  export interface RowActionsColumnOptions<T> {
542
580
  /** The column's key. Default `"actions"`. */
543
581
  key?: string;
@@ -617,9 +655,10 @@ export function rowActionsColumn<T>({
617
655
  labels={labels}
618
656
  />
619
657
  ),
620
- className: "w-px whitespace-nowrap text-end",
621
- // The heading over the buttons, at the row's end (0.46.1).
622
- headClassName: "w-px text-end",
658
+ className: ROW_ACTIONS_CELL_CLASS,
659
+ // The heading over the buttons, at the row's end (0.46.1); a hand-built table's
660
+ // `RowActionsHead` (0.47).
661
+ headClassName: ROW_ACTIONS_HEAD_CLASS,
623
662
  noRowLink: true,
624
663
  ...column,
625
664
  };
@@ -48,10 +48,16 @@ export interface TabsLabels {
48
48
  add: string;
49
49
  /** Accessible name of a tab's × — "Remove <tab>". */
50
50
  remove: (tab: string) => string;
51
- /** The `overflowCue` caret while tabs lie past the strip's end: "More tabs". */
51
+ /** The `overflowCue` › over the strip's end, while tabs lie past it: "More tabs". */
52
52
  more: string;
53
- /** The `overflowCue` caret at the strip's end, which goes back to the first tab. */
54
- backToFirst: string;
53
+ /** The `overflowCue` ‹ over the strip's start, while tabs lie before it: "Previous tabs". */
54
+ previous: string;
55
+ /**
56
+ * @deprecated 0.47: not drawn any more — the caret that turned into "back to the
57
+ * first tab" at the strip's end is now a ‹ of its own (`previous`). Accepted and
58
+ * ignored; removed in 0.48.
59
+ */
60
+ backToFirst?: string;
55
61
  }
56
62
 
57
63
  // Defined in ./ui-labels, which holds no component code (0.34, KS U8).
@@ -78,20 +84,22 @@ export interface TabsProps<T extends string>
78
84
  onChange: (id: T) => void;
79
85
  className?: string;
80
86
  /**
81
- * The caret beside a SCROLLING strip (0.40, kk run 79 gap 1): a › while
82
- * tabs lie past the strip's end — a tap scrolls it on by three quarters of its width —
83
- * turning into a ‹ at the end, which goes back to the first. Shown only while the strip
84
- * actually overflows.
87
+ * Carets over the ends of a SCROLLING strip (0.40, kk run 79 gap 1; 0.47, keksdose
88
+ * live #410 round 4): a › over the strip's end while tabs lie past it, and a ‹ over
89
+ * its start while tabs lie before it — each there only while something is hidden
90
+ * past ITS edge (1 px slack, so a few cut-off pixels count), each a step of three
91
+ * quarters of the strip's width toward its own side. Neither ever changes direction:
92
+ * until 0.46 one caret turned round into "back to the first" at the end, and read as
93
+ * an arrow pointing the wrong way. Start and end follow the reading direction.
85
94
  *
86
- * The edge fade alone could read as the end of the row: at the default scroll position
87
- * the last visible tab can finish exactly where the fade begins, so nothing looks cut
88
- * off (keksdose live #410, whose `tab-strip-more.tsx` this replaces). One button that
89
- * turns round rather than two that come and go, so the strip's width never changes
90
- * under the thumb. Out of the tab order — the keyboard walks the strip with the arrow
91
- * keys, which bring the focused tab into view. At every width since 0.46 (keksdose live
92
- * #410 round 3: eleven report tabs overflow a laptop too); before, `md:hidden`. Ignored on
93
- * a vertical strip (a side nav is a column, not a row). Names:
94
- * `tabs.more` / `tabs.backToFirst`.
95
+ * The edge fade alone could read as the end of the row: the last visible tab can
96
+ * finish exactly where the fade begins, so nothing looks cut off (keksdose live #410).
97
+ * The carets sit OVER the strip's ends (positioned, on the page's surface), not beside
98
+ * it, so the strip's width never changes as they come and go. Out of the tab order —
99
+ * the keyboard walks the strip with the arrow keys, which bring the focused tab into
100
+ * view. At every width. Ignored on a vertical strip (a side nav is a column, not a
101
+ * row). Names: `tabs.more` / `tabs.previous`. keksdose's `scroll-edge-cues.tsx` is
102
+ * the reference this replaces.
95
103
  */
96
104
  overflowCue?: boolean;
97
105
  /**
@@ -254,36 +262,43 @@ export function Tabs<T extends string>({
254
262
  const stripRef = useRef<HTMLDivElement>(null);
255
263
  const setStrip = useMemo(() => mergeRefs(stripRef, ref), [ref]);
256
264
  const fade = useStripFade(stripRef);
257
- // See `overflowCue`. `start` alone means the strip is scrolled to its end.
258
- const cue = overflowCue && !vertical && fade.overflow !== undefined;
259
- const cueAtEnd = fade.overflow === "start";
260
- const onCue = () => {
265
+ // See `overflowCue`: a ‹ over the start while tabs lie before it, a › over the end
266
+ // while tabs lie past it — `fade.overflow` names the cut ends in reading-direction terms.
267
+ const cue = overflowCue && !vertical;
268
+ const cueStart = cue && (fade.overflow === "start" || fade.overflow === "both");
269
+ const cueEnd = cue && (fade.overflow === "end" || fade.overflow === "both");
270
+ // Three quarters of the visible width toward `side`; an RTL strip runs leftward.
271
+ const onCue = (side: "start" | "end") => {
261
272
  const strip = stripRef.current;
262
273
  if (!strip) return;
263
274
  const behavior: ScrollBehavior = prefersReducedMotion() ? "auto" : "smooth";
264
- if (cueAtEnd) {
265
- strip.scrollTo({ left: 0, behavior });
266
- return;
267
- }
268
275
  const rtl = getComputedStyle(strip).direction === "rtl";
269
- strip.scrollBy({ left: (rtl ? -1 : 1) * Math.round(strip.clientWidth * 0.75), behavior });
276
+ const toward = (side === "end" ? 1 : -1) * (rtl ? -1 : 1);
277
+ strip.scrollBy({ left: toward * Math.round(strip.clientWidth * 0.75), behavior });
270
278
  };
271
- const cueButton = cue ? (
272
- // The strip's bottom rule continues under the caret, so the row reads as one.
273
- <div data-tabs-overflow-cue="" className="flex shrink-0 items-center self-stretch border-b border-subtle">
279
+ // OVER the strip's end, on the page's surface, so the caret reads as a control and not
280
+ // as one more faded tab — and so it can come and go without the strip changing width
281
+ // under the thumb. The position sits on a wrapper of ours: IconButton puts its
282
+ // tooltip's span around the button, and a class on the button would land inside it.
283
+ const cueAt = (side: "start" | "end") => (
284
+ <div
285
+ data-tabs-overflow-cue={side}
286
+ className={cn("absolute top-1/2 z-10 flex -translate-y-1/2", side === "start" ? "start-0" : "end-0")}
287
+ >
274
288
  <IconButton
275
289
  size="sm"
276
- variant="ghost"
277
- tone="muted"
290
+ variant="secondary"
291
+ className="bg-surface shadow-sm"
278
292
  tabIndex={-1}
279
293
  type="button"
280
- aria-label={cueAtEnd ? text.backToFirst : text.more}
281
- onClick={onCue}
294
+ label={side === "start" ? text.previous : text.more}
295
+ labelVisible={false}
296
+ onClick={() => onCue(side)}
282
297
  >
283
- {cueAtEnd ? <ChevronLeft className="rtl:-scale-x-100" /> : <ChevronRight className="rtl:-scale-x-100" />}
298
+ {side === "start" ? <ChevronLeft className="rtl:-scale-x-100" /> : <ChevronRight className="rtl:-scale-x-100" />}
284
299
  </IconButton>
285
300
  </div>
286
- ) : null;
301
+ );
287
302
  // The OPEN tab is scrolled into the strip whenever it changes — on mount too, for a
288
303
  // deep link to the seventh report. A selection behind the cut edge was the worst
289
304
  // case of the overflow above: the one tab the panel below belongs to, sliced to
@@ -407,7 +422,7 @@ export function Tabs<T extends string>({
407
422
  // — it is the box a caller's margin or width is meant for.
408
423
  className={cn(
409
424
  vertical ? TABLIST_VERTICAL_CLASSES : TABLIST_CLASSES,
410
- onAdd ? "min-w-0" : overflowCue && !vertical ? "min-w-0 flex-1" : className,
425
+ cue ? "min-w-0 flex-1" : onAdd ? "min-w-0" : className,
411
426
  )}
412
427
  >
413
428
  {tabs.map((tab) => {
@@ -575,16 +590,24 @@ export function Tabs<T extends string>({
575
590
  {text.remove(nameOf(activeTab))}
576
591
  </button>
577
592
  ) : null;
578
- if (!onAdd && overflowCue && !vertical) {
579
- // The caret needs a row to sit in beside the strip, and `className` goes there — it
580
- // is the box a caller's margin or width is meant for, as with an add button. The box
581
- // is there whether or not the strip overflows right now, so a resize never moves
582
- // the caller's class from one element to another.
593
+ // The positioned box the carets sit in, over the strip's two ends (see `overflowCue`).
594
+ // There whether or not the strip overflows right now, so a resize never re-parents it.
595
+ const stripBox = cue ? (
596
+ <div className={cn("relative flex min-w-0", !onAdd && "flex-1")}>
597
+ {tablist}
598
+ {cueStart && cueAt("start")}
599
+ {cueEnd && cueAt("end")}
600
+ </div>
601
+ ) : (
602
+ tablist
603
+ );
604
+ if (!onAdd && cue) {
605
+ // `className` goes on the row around the strip — it is the box a caller's margin or
606
+ // width is meant for, as with an add button — and stays there at every scroll position.
583
607
  return (
584
608
  <div className={cn("flex items-end", className)}>
585
- {tablist}
609
+ {stripBox}
586
610
  {removeButton}
587
- {cueButton}
588
611
  </div>
589
612
  );
590
613
  }
@@ -614,9 +637,8 @@ export function Tabs<T extends string>({
614
637
  }
615
638
  return (
616
639
  <div className={cn("flex items-end", className)}>
617
- {tablist}
640
+ {stripBox}
618
641
  {removeButton}
619
- {cueButton}
620
642
  {/* Outside the tablist: an action, not a tab — and a tablist may own only
621
643
  tabs. The rule under the strip carries on beneath it. */}
622
644
  <div
@@ -20,5 +20,5 @@ export const DEFAULT_TABS_LABELS: TabsLabels = {
20
20
  add: "Add tab",
21
21
  remove: (tab) => `Remove ${tab}`,
22
22
  more: "More tabs",
23
- backToFirst: "Back to the first tab",
23
+ previous: "Previous tabs",
24
24
  };
@@ -0,0 +1,77 @@
1
+ import { useState } from "react";
2
+ import type { MouseEvent } from "react";
3
+ import { Download, FileText } from "lucide-react";
4
+ import { Button } from "../components/button";
5
+ import { toast } from "../components/toast";
6
+ import type { AuthedFetcher } from "../hooks/use-authed-src";
7
+ import { useKitLabels } from "../i18n/kit-labels";
8
+ import { filenameFromDisposition, saveBlob } from "../lib/download";
9
+ import { attachmentName } from "./feedback-record";
10
+ import { DEFAULT_FEEDBACK_DETAIL_LABELS } from "./feedback-row-detail-labels";
11
+
12
+ // Internal (not in the `./feedback` entry): the row detail's download button and its
13
+ // plain fetch, shared since 0.47 with ./feedback-entry-file's inline entry files.
14
+
15
+ /** The bytes behind a fetcher's answer — a `Blob` as it is, a `Response` checked. */
16
+ async function toBlob(answer: Blob | Response): Promise<Blob> {
17
+ if (typeof Response !== "undefined" && answer instanceof Response) {
18
+ if (!answer.ok) throw new Error(`HTTP ${answer.status}`);
19
+ return answer.blob();
20
+ }
21
+ return answer as Blob;
22
+ }
23
+
24
+ /** No fetcher: the browser's own `fetch`, with the cookies a same-origin app uses. */
25
+ export const plainFeedbackFetch: AuthedFetcher = (url, { signal }) =>
26
+ fetch(url, { signal, credentials: "same-origin" });
27
+
28
+ /**
29
+ * A PDF or a text log (keksdose `FeedbackFileDownload`, dev #578): the bytes sit behind
30
+ * an authenticated route that serves them as `attachment`, never inline (§3.5), so a
31
+ * plain link would be a 401 the moment it was opened. Fetched through the app's
32
+ * fetcher and saved with {@link saveBlob}; a failure says so in a toast.
33
+ *
34
+ * Saved under the name the server gives it when the fetcher answers with a `Response`
35
+ * (server-kit's `content_disposition` puts the uploader's own name in `filename*`), and
36
+ * under the URL's last segment — the name on the button — when it answers with a bare
37
+ * `Blob`, which has no headers to ask. Words: `feedbackDetail.download` /
38
+ * `downloadFailed`.
39
+ */
40
+ export function FeedbackAttachmentDownload({ url, fetcher }: { url: string; fetcher: AuthedFetcher }) {
41
+ const labels = useKitLabels("feedbackDetail", DEFAULT_FEEDBACK_DETAIL_LABELS);
42
+ const [busy, setBusy] = useState(false);
43
+ const name = attachmentName(url);
44
+ const download = async (event: MouseEvent) => {
45
+ // The row around the detail may toggle on a click.
46
+ event.stopPropagation();
47
+ setBusy(true);
48
+ try {
49
+ const answer = await fetcher(url, { signal: new AbortController().signal });
50
+ const blob = await toBlob(answer);
51
+ const saveAs =
52
+ typeof Response !== "undefined" && answer instanceof Response
53
+ ? filenameFromDisposition(answer.headers.get("Content-Disposition"), name)
54
+ : name;
55
+ saveBlob(blob, saveAs);
56
+ } catch {
57
+ toast.error(labels.downloadFailed);
58
+ } finally {
59
+ setBusy(false);
60
+ }
61
+ };
62
+ return (
63
+ <Button
64
+ type="button"
65
+ variant="secondary"
66
+ size="sm"
67
+ pending={busy}
68
+ onClick={(event) => void download(event)}
69
+ aria-label={labels.download(name)}
70
+ className="max-w-full"
71
+ >
72
+ <FileText className="size-3.5 shrink-0" aria-hidden />
73
+ <span className="min-w-0 max-w-[14rem] truncate-until-large">{name}</span>
74
+ <Download className="size-3.5 shrink-0" aria-hidden />
75
+ </Button>
76
+ );
77
+ }
@@ -4,11 +4,13 @@ import { EyeOff } from "lucide-react";
4
4
  import { Button } from "../components/button";
5
5
  import { useConfirm } from "../components/confirm-dialog";
6
6
  import type { CommitScope } from "../components/write-lock";
7
+ import type { AuthedFetcher } from "../hooks/use-authed-src";
7
8
  import { useKitLabels, type LabelOverride } from "../i18n/kit-labels";
8
9
  import { FeedbackStatusBadge } from "./feedback-inbox";
9
10
  import type { FeedbackComment, FeedbackReply } from "./feedback-public-types";
10
11
  import { DEFAULT_FEEDBACK_PUBLIC_LABELS } from "./feedback-public-labels";
11
12
  import { FEEDBACK_COMMENT_MAX, attachmentName } from "./feedback-record";
13
+ import { FeedbackEntryFile, useFeedbackEntryUpload, type FeedbackEntryUploadOptions } from "./feedback-entry-file";
12
14
  import { useFeedbackRepliesLabels } from "./feedback-replies";
13
15
  import { FeedbackRepliesDrawnAboveContext } from "./feedback-replies-context";
14
16
  import { FeedbackReworkEntryChip } from "./feedback-rework-entry-chip";
@@ -34,8 +36,13 @@ import { DEFAULT_FEEDBACK_COMMENTS_LABELS } from "./feedback-thread-parts-labels
34
36
  * sees their text with that line; the admin sees it struck through, with **Unhide**.
35
37
  * An admin has **Hide** on every visible comment; the writer **Delete** on their own,
36
38
  * after a confirm (it is a hard delete). Nobody edits a comment.
37
- * - **The composer** (1–4 000 characters, no files) only with `onComment`: left out for
38
- * a reader who may not write (a demo, a CRASH report — the app knows).
39
+ * - **The composer** (1–4 000 characters) only with `onComment`: left out for a reader
40
+ * who may not write (a demo, a CRASH report — the app knows). Since 0.47 (§12) with
41
+ * `onUpload` it takes one file as the rework note does — pasted into the box,
42
+ * captured or picked — uploaded first, its URL `onComment`'s second argument.
43
+ * - **Files** (0.47, §12): a comment's or an entry's file under its text, a picture
44
+ * INLINE — a thumbnail through `fetcher` that opens full size — any other file a
45
+ * download ({@link FeedbackEntryFile}). A veiled comment shows none.
39
46
  *
40
47
  * Delete asks through `useConfirm`, so a thread with `onDelete` and a comment of the
41
48
  * viewer's needs the app's `ConfirmProvider` (each app mounts one already).
@@ -75,7 +82,7 @@ export { DEFAULT_FEEDBACK_COMMENTS_LABELS };
75
82
 
76
83
  /* ── The thread ───────────────────────────────────────────────────────────── */
77
84
 
78
- export interface FeedbackCommentThreadProps {
85
+ export interface FeedbackCommentThreadProps extends FeedbackEntryUploadOptions {
79
86
  /** The report's replies (§9.7), oldest first. Left out or `[]`: none.
80
87
  *
81
88
  * Inside `FeedbackRowDetail`'s `comments` they are dropped (0.38.1): the detail's
@@ -92,8 +99,12 @@ export interface FeedbackCommentThreadProps {
92
99
  /** `POST /feedback/{id}/comments`. Return the promise: the box clears when it
93
100
  * resolves, and keeps the draft when it rejects. A failure is the app's to toast —
94
101
  * the server's `detail`, else `feedbackToast.commentFailed` (0.38.1). Left out, no
95
- * composer. */
96
- onComment?: (body: string) => void | Promise<unknown>;
102
+ * composer. 0.47 (§12): `attachmentUrl` is the one file uploaded with it (with
103
+ * `onUpload`), `undefined` for none — `attachment_url` in the body (server-kit 0.18). */
104
+ onComment?: (body: string, attachmentUrl?: string) => void | Promise<unknown>;
105
+ /** 0.47: the app's authenticated GET (`AuthedImage`'s) for the files in the thread.
106
+ * Left out, the browser's own fetch with same-origin cookies. */
107
+ fetcher?: AuthedFetcher | null;
97
108
  /** `PATCH /feedback/{id}/comments/{comment_id} {hidden}` — the admin's. A rejection
98
109
  * is caught (0.38.1) and is the app's to toast (`feedbackToast.updateFailed`). Left
99
110
  * out, no Hide / Unhide. */
@@ -141,9 +152,18 @@ export function FeedbackCommentThread({
141
152
  loading = false,
142
153
  commit,
143
154
  now,
155
+ fetcher,
156
+ onUpload,
157
+ onCaptureScreenshot,
158
+ attachment,
144
159
  labels: labelsProp,
145
160
  className,
146
161
  }: FeedbackCommentThreadProps) {
162
+ const { composerAttachment, upload, uploading } = useFeedbackEntryUpload({
163
+ onUpload,
164
+ onCaptureScreenshot,
165
+ attachment,
166
+ });
147
167
  const labels = useKitLabels("feedbackComments", DEFAULT_FEEDBACK_COMMENTS_LABELS, labelsProp);
148
168
  const anonymous = useKitLabels("feedbackPublic", DEFAULT_FEEDBACK_PUBLIC_LABELS).anonymous;
149
169
  const replyLabels = useFeedbackRepliesLabels();
@@ -189,6 +209,10 @@ export function FeedbackCommentThread({
189
209
  author: reply.author_name ?? replyLabels.admin,
190
210
  createdAt: reply.created_at,
191
211
  body: reply.body,
212
+ // 0.47 (§12): an admin reply's file, where the view carries it.
213
+ attachments: reply.attachment_url
214
+ ? [{ name: attachmentName(reply.attachment_url), url: reply.attachment_url }]
215
+ : undefined,
192
216
  staff: true,
193
217
  badge: reply.status_to ? (
194
218
  <span className="inline-flex min-w-0 max-w-full flex-wrap items-center gap-1" data-feedback-reply-status={reply.status_to}>
@@ -244,6 +268,11 @@ export function FeedbackCommentThread({
244
268
  author: veiled ? undefined : comment.mine ? undefined : (comment.author_name ?? anonymous),
245
269
  createdAt: comment.created_at,
246
270
  body: veiled ? "" : (comment.body ?? ""),
271
+ // 0.47 (§12): its one file; none when veiled (the server sends none either).
272
+ attachments:
273
+ !veiled && comment.attachment_url
274
+ ? [{ name: attachmentName(comment.attachment_url), url: comment.attachment_url }]
275
+ : undefined,
247
276
  staff: !veiled && comment.staff,
248
277
  struck: comment.hidden && isAdmin && !veiled,
249
278
  footer: actions.length > 0 ? actions : undefined,
@@ -258,13 +287,19 @@ export function FeedbackCommentThread({
258
287
  messages={entries.map((entry) => entry.message)}
259
288
  loading={loading}
260
289
  now={now}
290
+ renderAttachment={(file) => (file.url ? <FeedbackEntryFile url={file.url} fetcher={fetcher} /> : file.name)}
261
291
  labels={{ thread: labels.comments, empty: labels.empty }}
262
292
  />
263
293
  {onComment && (
264
294
  <ChatComposer
265
295
  className="mt-3"
266
- onSend={(body) => onComment(body)}
267
- pending={pending}
296
+ onSend={async (body, file) => {
297
+ const url = await upload(file);
298
+ // The second argument only with a file, so a text comment calls as before 0.47.
299
+ return url === undefined ? onComment(body) : onComment(body, url);
300
+ }}
301
+ pending={pending || uploading}
302
+ attachment={composerAttachment}
268
303
  commit={commit}
269
304
  maxLength={FEEDBACK_COMMENT_MAX}
270
305
  showCount