@eifi1/ui-kit 0.41.0 → 0.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/README.md +19 -6
  2. package/dist/admin/admin-area-labels.d.ts +55 -0
  3. package/dist/admin/admin-area-labels.js +33 -0
  4. package/dist/admin/admin-area-labels.js.map +1 -0
  5. package/dist/admin/admin-forbidden.d.ts +35 -0
  6. package/dist/admin/admin-forbidden.js +36 -0
  7. package/dist/admin/admin-forbidden.js.map +1 -0
  8. package/dist/admin/admin-overview.d.ts +93 -0
  9. package/dist/admin/admin-overview.js +69 -0
  10. package/dist/admin/admin-overview.js.map +1 -0
  11. package/dist/admin/admin-sections.d.ts +63 -0
  12. package/dist/admin/admin-sections.js +64 -0
  13. package/dist/admin/admin-sections.js.map +1 -0
  14. package/dist/admin/admin-tool-links.d.ts +48 -0
  15. package/dist/admin/admin-tool-links.js +31 -0
  16. package/dist/admin/admin-tool-links.js.map +1 -0
  17. package/dist/billing/billing-banner.d.ts +25 -2
  18. package/dist/billing/billing-banner.js +5 -2
  19. package/dist/billing/billing-banner.js.map +1 -1
  20. package/dist/billing/billing-labels.d.ts +32 -0
  21. package/dist/billing/billing-labels.js +9 -0
  22. package/dist/billing/billing-labels.js.map +1 -1
  23. package/dist/billing/billing-overview.d.ts +33 -0
  24. package/dist/billing/billing-overview.js +6 -0
  25. package/dist/billing/billing-overview.js.map +1 -1
  26. package/dist/billing/billing-standing.d.ts +8 -0
  27. package/dist/billing/billing-standing.js.map +1 -1
  28. package/dist/billing/cancel-subscription.d.ts +46 -0
  29. package/dist/billing/cancel-subscription.js +57 -0
  30. package/dist/billing/cancel-subscription.js.map +1 -0
  31. package/dist/billing/plan-card.d.ts +45 -1
  32. package/dist/billing/plan-card.js +53 -2
  33. package/dist/billing/plan-card.js.map +1 -1
  34. package/dist/billing/plan-switch.d.ts +10 -1
  35. package/dist/billing/plan-switch.js +22 -13
  36. package/dist/billing/plan-switch.js.map +1 -1
  37. package/dist/billing/subscription-actions.d.ts +37 -4
  38. package/dist/billing/subscription-actions.js +10 -3
  39. package/dist/billing/subscription-actions.js.map +1 -1
  40. package/dist/components/account-chips.d.ts +42 -1
  41. package/dist/components/account-chips.js +33 -0
  42. package/dist/components/account-chips.js.map +1 -1
  43. package/dist/components/empty-state.d.ts +2 -1
  44. package/dist/components/empty-state.js.map +1 -1
  45. package/dist/eslint/index.js +1 -1
  46. package/dist/i18n/defaults.js +5 -1
  47. package/dist/i18n/defaults.js.map +1 -1
  48. package/dist/i18n/german.js +79 -0
  49. package/dist/i18n/german.js.map +1 -1
  50. package/dist/i18n/kit-labels.d.ts +9 -0
  51. package/dist/i18n/kit-labels.js.map +1 -1
  52. package/dist/i18n/locales/en.js +93 -1
  53. package/dist/i18n/locales/en.js.map +3 -3
  54. package/dist/i18n/locales/es.js +76 -0
  55. package/dist/i18n/locales/es.js.map +1 -1
  56. package/dist/i18n/locales/fr.js +75 -0
  57. package/dist/i18n/locales/fr.js.map +1 -1
  58. package/dist/i18n/locales/hu.js +76 -0
  59. package/dist/i18n/locales/hu.js.map +1 -1
  60. package/dist/i18n/locales/it.js +76 -0
  61. package/dist/i18n/locales/it.js.map +1 -1
  62. package/dist/i18n/locales/zh.js +75 -0
  63. package/dist/i18n/locales/zh.js.map +1 -1
  64. package/dist/i18n/review.js +20 -1
  65. package/dist/i18n/review.js.map +1 -1
  66. package/dist/index.d.ts +18 -2
  67. package/dist/index.js +23 -1
  68. package/dist/index.js.map +1 -1
  69. package/dist/settings/settings-layout.d.ts +7 -1
  70. package/dist/settings/settings-layout.js +35 -27
  71. package/dist/settings/settings-layout.js.map +1 -1
  72. package/dist/shell/account-menu-language.d.ts +46 -0
  73. package/dist/shell/account-menu-language.js +64 -0
  74. package/dist/shell/account-menu-language.js.map +1 -0
  75. package/dist/shell/admin-menu.d.ts +46 -0
  76. package/dist/shell/admin-menu.js +60 -0
  77. package/dist/shell/admin-menu.js.map +1 -0
  78. package/dist/shell/app-shell.d.ts +10 -0
  79. package/dist/shell/app-shell.js +22 -4
  80. package/dist/shell/app-shell.js.map +1 -1
  81. package/dist/shell/dev-switchers.d.ts +53 -0
  82. package/dist/shell/dev-switchers.js +102 -0
  83. package/dist/shell/dev-switchers.js.map +1 -0
  84. package/dist/shell/sync-status-indicator.d.ts +86 -0
  85. package/dist/shell/sync-status-indicator.js +170 -0
  86. package/dist/shell/sync-status-indicator.js.map +1 -0
  87. package/dist/shell/top-bar-actions.d.ts +49 -0
  88. package/dist/shell/top-bar-actions.js +31 -0
  89. package/dist/shell/top-bar-actions.js.map +1 -0
  90. package/dist/shell/top-bar-parts-labels.d.ts +84 -0
  91. package/dist/shell/top-bar-parts-labels.js +48 -0
  92. package/dist/shell/top-bar-parts-labels.js.map +1 -0
  93. package/dist/shell/topbar-action-menu.d.ts +13 -1
  94. package/dist/shell/topbar-action-menu.js +5 -2
  95. package/dist/shell/topbar-action-menu.js.map +1 -1
  96. package/dist/shell/topbar-controls.d.ts +16 -3
  97. package/dist/shell/topbar-controls.js +5 -1
  98. package/dist/shell/topbar-controls.js.map +1 -1
  99. package/dist/shell/tour-menu.d.ts +58 -0
  100. package/dist/shell/tour-menu.js +73 -0
  101. package/dist/shell/tour-menu.js.map +1 -0
  102. package/dist/shell.d.ts +14 -0
  103. package/dist/shell.js +19 -0
  104. package/dist/shell.js.map +1 -1
  105. package/package.json +1 -1
  106. package/src/admin/admin-area-labels.ts +85 -0
  107. package/src/admin/admin-forbidden.tsx +75 -0
  108. package/src/admin/admin-overview.tsx +166 -0
  109. package/src/admin/admin-sections.tsx +137 -0
  110. package/src/admin/admin-tool-links.tsx +84 -0
  111. package/src/billing/billing-banner.tsx +32 -4
  112. package/src/billing/billing-labels.ts +53 -0
  113. package/src/billing/billing-overview.ts +37 -0
  114. package/src/billing/billing-standing.ts +8 -0
  115. package/src/billing/cancel-subscription.tsx +102 -0
  116. package/src/billing/plan-card.tsx +97 -1
  117. package/src/billing/plan-switch.tsx +16 -0
  118. package/src/billing/subscription-actions.tsx +64 -15
  119. package/src/components/account-chips.tsx +81 -1
  120. package/src/components/empty-state.tsx +2 -1
  121. package/src/i18n/defaults.ts +4 -0
  122. package/src/i18n/german.ts +92 -0
  123. package/src/i18n/kit-labels.tsx +9 -0
  124. package/src/i18n/locales/en.ts +19 -0
  125. package/src/i18n/locales/es.ts +86 -0
  126. package/src/i18n/locales/fr.ts +86 -0
  127. package/src/i18n/locales/hu.ts +84 -0
  128. package/src/i18n/locales/it.ts +87 -0
  129. package/src/i18n/locales/zh.ts +79 -0
  130. package/src/i18n/review.ts +19 -0
  131. package/src/index.ts +30 -2
  132. package/src/settings/settings-layout.tsx +58 -34
  133. package/src/shell/account-menu-language.tsx +116 -0
  134. package/src/shell/admin-menu.tsx +117 -0
  135. package/src/shell/app-shell.tsx +44 -3
  136. package/src/shell/dev-switchers.tsx +171 -0
  137. package/src/shell/sync-status-indicator.tsx +301 -0
  138. package/src/shell/top-bar-actions.tsx +79 -0
  139. package/src/shell/top-bar-parts-labels.ts +133 -0
  140. package/src/shell/topbar-action-menu.tsx +15 -2
  141. package/src/shell/topbar-controls.tsx +20 -3
  142. package/src/shell/tour-menu.tsx +144 -0
  143. package/src/shell.ts +24 -0
@@ -0,0 +1,171 @@
1
+ import { useState } from "react";
2
+ import type { ReactNode } from "react";
3
+ import { FlaskConical, Undo2 } from "lucide-react";
4
+ import { DEFAULT_TOP_BAR_LABELS, useKitLabels, type LabelOverride } from "../i18n/kit-labels";
5
+ import { TopBarActionMenu, type TopBarMenuEntry } from "./topbar-action-menu";
6
+ import { useTopBarPartsLabels, type DevSwitchersLabels } from "./top-bar-parts-labels";
7
+
8
+ /** Where the role the session started with is kept, unless the app names its own key. */
9
+ export const DEV_ORIGINAL_ROLE_KEY = "ui-kit:dev-original-role";
10
+
11
+ function readKey(key: string): string | null {
12
+ try {
13
+ return sessionStorage.getItem(key);
14
+ } catch {
15
+ return null;
16
+ }
17
+ }
18
+
19
+ function writeKey(key: string, value: string | null): void {
20
+ try {
21
+ if (value === null) sessionStorage.removeItem(key);
22
+ else sessionStorage.setItem(key, value);
23
+ } catch {
24
+ /* storage blocked: "Back to" lasts as long as this page */
25
+ }
26
+ }
27
+
28
+ export interface DevSwitchersProps<R extends string, P extends string> {
29
+ /** Draw anything at all: the app passes `import.meta.env.DEV`, so a production build
30
+ * tree-shakes the switchers with the constant. */
31
+ enabled: boolean;
32
+ roles?: readonly R[];
33
+ /** The role the server enforces now. Named `currentRole`, not `role`, because `role` on
34
+ * a JSX element is the ARIA role (as `RoleChip`'s `value`). */
35
+ currentRole?: R;
36
+ /** Apply a role — the app calls server-kit's `POST /dev/switch {role}` and re-reads
37
+ * `/auth/me` (§2.7, §6.2). */
38
+ onRole?: (role: R) => void;
39
+ plans?: readonly P[];
40
+ /** The plan the server enforces now. Left out (not known yet — kastlan's overview is a
41
+ * 404 while billing is off), the plan menu still renders, with no row checked; a
42
+ * `GET /dev/switch` answers it. */
43
+ currentPlan?: P;
44
+ /** Apply a plan — `POST /dev/switch {plan}`, then the overview (§6.3). */
45
+ onPlan?: (plan: P) => void;
46
+ /**
47
+ * The role to offer "Back to {role}" for. Left out, the kit keeps it itself: the role
48
+ * before the first switch of this browser session, in `sessionStorage` under
49
+ * `storageKey` — the server answers with the NEW role after a switch, so only the
50
+ * browser still knows the old one.
51
+ */
52
+ originalRole?: R;
53
+ /** The `sessionStorage` key for the kept role. Default {@link DEV_ORIGINAL_ROLE_KEY};
54
+ * an app with several accounts on one browser may scope it per user. */
55
+ storageKey?: string;
56
+ /** Billing is off locally: the plan menu says plans change nothing, and stays usable
57
+ * (§6.3). */
58
+ billingOff?: boolean;
59
+ /** A role's or a plan's words in the menu. Default: the value itself. */
60
+ roleLabel?: (role: R) => ReactNode;
61
+ planLabel?: (plan: P) => ReactNode;
62
+ /** Prop > `<UiKitProvider labels={{ topBarParts: { dev } }}>` > English. */
63
+ labels?: LabelOverride<DevSwitchersLabels>;
64
+ }
65
+
66
+ const flask = <FlaskConical className="size-5 text-warning" />;
67
+
68
+ /** A label's text for a sentence, or the raw value when the label is not text. */
69
+ const textOf = (label: ReactNode, value: string) =>
70
+ typeof label === "string" || typeof label === "number" ? String(label) : value;
71
+
72
+ /**
73
+ * The top bar's local-dev switchers (docs/top-bar-harmonization.md §2.7, §6.2, §6.3): the
74
+ * role, then the plan, each a menu behind `RoleSwitcher`'s amber flask. Nothing renders
75
+ * unless `enabled`; either menu renders only with its list and handler.
76
+ *
77
+ * The role menu ends in "Back to {role}" once the role differs from the one the session
78
+ * started with: the dev route needs only a signed-in user, so a switch to a lesser role
79
+ * can always switch back. With `billingOff` the plan menu notes that plans change nothing
80
+ * while billing is off.
81
+ */
82
+ export function DevSwitchers<R extends string, P extends string>({
83
+ enabled,
84
+ roles,
85
+ currentRole: role,
86
+ onRole,
87
+ plans,
88
+ currentPlan: plan,
89
+ onPlan,
90
+ originalRole,
91
+ storageKey = DEV_ORIGINAL_ROLE_KEY,
92
+ billingOff = false,
93
+ roleLabel = (r) => r,
94
+ planLabel = (p) => p,
95
+ labels: labelsProp,
96
+ }: DevSwitchersProps<R, P>) {
97
+ const bar = useKitLabels("topBar", DEFAULT_TOP_BAR_LABELS);
98
+ const words = useTopBarPartsLabels(labelsProp ? { dev: labelsProp } : undefined).dev;
99
+ const [kept, setKept] = useState<string | null>(() => (enabled ? readKey(storageKey) : null));
100
+ if (!enabled) return null;
101
+
102
+ const original = (originalRole ?? kept ?? undefined) as R | undefined;
103
+
104
+ const pickRole = (next: R) => {
105
+ if (originalRole === undefined) {
106
+ if (next === original) {
107
+ // Back where the session began: nothing left to go back to.
108
+ writeKey(storageKey, null);
109
+ setKept(null);
110
+ } else if (kept === null && role !== undefined && next !== role) {
111
+ writeKey(storageKey, role);
112
+ setKept(role);
113
+ }
114
+ }
115
+ onRole?.(next);
116
+ };
117
+
118
+ const roleMenu =
119
+ roles && roles.length > 0 && role !== undefined && onRole ? (
120
+ <TopBarActionMenu
121
+ icon={flask}
122
+ aria-label={`${bar.switchRole} · ${bar.role(role)}`}
123
+ entries={[
124
+ { kind: "heading", key: "head-role", label: words.roleHeading },
125
+ ...roles.map(
126
+ (r): TopBarMenuEntry => ({ key: `role-${r}`, label: roleLabel(r), checked: r === role, onSelect: () => pickRole(r) }),
127
+ ),
128
+ ...(original !== undefined && original !== role
129
+ ? ([
130
+ { kind: "divider", key: "sep-back" },
131
+ {
132
+ key: "back",
133
+ icon: <Undo2 className="size-4" />,
134
+ label: words.backTo(textOf(roleLabel(original), original)),
135
+ onSelect: () => pickRole(original),
136
+ },
137
+ ] satisfies TopBarMenuEntry[])
138
+ : []),
139
+ ]}
140
+ />
141
+ ) : null;
142
+
143
+ const planMenu =
144
+ plans && plans.length > 0 && onPlan ? (
145
+ <TopBarActionMenu
146
+ icon={flask}
147
+ aria-label={plan !== undefined ? `${words.switchPlan} · ${words.plan(plan)}` : words.switchPlan}
148
+ entries={[
149
+ { kind: "heading", key: "head-plan", label: words.planHeading },
150
+ ...plans.map(
151
+ (p): TopBarMenuEntry => ({ key: `plan-${p}`, label: planLabel(p), checked: p === plan, onSelect: () => onPlan(p) }),
152
+ ),
153
+ ]}
154
+ >
155
+ {billingOff
156
+ ? () => (
157
+ <li data-dev-billing-off="" className="mt-1 max-w-64 border-t border-subtle px-3 py-2 text-xs text-muted">
158
+ {words.billingOff}
159
+ </li>
160
+ )
161
+ : undefined}
162
+ </TopBarActionMenu>
163
+ ) : null;
164
+
165
+ return (
166
+ <>
167
+ {roleMenu}
168
+ {planMenu}
169
+ </>
170
+ );
171
+ }
@@ -0,0 +1,301 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import type { ComponentType } from "react";
3
+ import { Check, Cloud, CloudAlert, CloudOff, RefreshCw, UploadCloud } from "lucide-react";
4
+ import type { LucideProps } from "lucide-react";
5
+ import { cn } from "../lib/cn";
6
+ import { IconButton } from "../components/icon-button";
7
+ import { Tooltip } from "../components/tooltip";
8
+ import { useBillingLabels } from "../billing/billing-labels";
9
+ import { useKitLocale, type LabelOverride } from "../i18n/kit-labels";
10
+ import { useTopBarPartsLabels, type SyncStatusLabels } from "./top-bar-parts-labels";
11
+
12
+ /**
13
+ * What the indicator shows, fed by the app's sync engine (docs/top-bar-harmonization.md
14
+ * §2.6). Small on purpose: keksdose's sync-status store, kastlan's handover controller and
15
+ * Kurvenschmiede's online state + `useIsMutating` all fill it.
16
+ */
17
+ export interface SyncStatus {
18
+ /** What the engine is doing. `offline` is the engine's own verdict; `online: false` is
19
+ * the browser's — either shows the offline state. */
20
+ phase: "idle" | "syncing" | "offline" | "error";
21
+ /** The browser's `navigator.onLine` (or the app's better guess). */
22
+ online: boolean;
23
+ /** Writes kept on this device and not yet sent. */
24
+ pending: number;
25
+ /** Writes this device cannot send at all (keksdose: no key for the budget). Ranked first. */
26
+ blocked?: number;
27
+ /** Writes the server refused as read-only and keeps until a plan (billing §12.5). */
28
+ waitingForPlan?: number;
29
+ /** When the last good round ended — epoch ms, an ISO string or a `Date`. */
30
+ lastSyncedAt?: number | string | Date | null;
31
+ }
32
+
33
+ /** The seven states, in their ranking: the first that holds wins. */
34
+ export type SyncState = "blocked" | "waitingForPlan" | "offline" | "error" | "syncing" | "pending" | "synced";
35
+
36
+ /**
37
+ * Which of the seven states a {@link SyncStatus} is in — keksdose's ranking
38
+ * (sync-status-indicator.tsx): a write that cannot be sent at all ranks above everything,
39
+ * because waiting cannot help it; then one waiting for a plan; then offline; then a refused
40
+ * round; then a round under way; then writes waiting; else in sync. `syncing` also holds
41
+ * while a tap's own round runs (`refreshing`), before the engine publishes its phase.
42
+ */
43
+ export function syncState(status: SyncStatus, refreshing = false): SyncState {
44
+ if ((status.blocked ?? 0) > 0) return "blocked";
45
+ if ((status.waitingForPlan ?? 0) > 0) return "waitingForPlan";
46
+ if (!status.online || status.phase === "offline") return "offline";
47
+ if (status.phase === "error") return "error";
48
+ if (status.phase === "syncing" || refreshing) return "syncing";
49
+ if (status.pending > 0) return "pending";
50
+ return "synced";
51
+ }
52
+
53
+ type Tone = "danger" | "warning" | "info" | "success";
54
+
55
+ const LOOK: Record<SyncState, { Icon: ComponentType<LucideProps>; tone: Tone }> = {
56
+ // The one the user must act on: the kit's danger, where keksdose had rose.
57
+ blocked: { Icon: CloudAlert, tone: "danger" },
58
+ // Safe, and a payment sends them: amber, the kit's warning.
59
+ waitingForPlan: { Icon: CloudAlert, tone: "warning" },
60
+ offline: { Icon: CloudOff, tone: "warning" },
61
+ // Usually resolves by itself (a deploy, a read-only view): amber, not danger.
62
+ error: { Icon: CloudAlert, tone: "warning" },
63
+ syncing: { Icon: RefreshCw, tone: "info" },
64
+ pending: { Icon: UploadCloud, tone: "warning" },
65
+ synced: { Icon: Cloud, tone: "success" },
66
+ };
67
+
68
+ const TEXT_TONE: Record<Tone, string> = {
69
+ danger: "text-danger",
70
+ warning: "text-warning",
71
+ info: "text-info",
72
+ success: "text-success",
73
+ };
74
+
75
+ /** The scope words of an indicator that covers only part of the app (§6.10). */
76
+ export interface SyncStatusScope {
77
+ /** Put before every state in the tooltip: "Handovers on this device" →
78
+ * "Handovers on this device: 3 changes waiting to sync". */
79
+ name: string;
80
+ /** The whole offline sentence instead of the kit's, when offline means something
81
+ * narrower: "Offline: handovers keep working on this device; everything else needs a
82
+ * connection". */
83
+ offline?: string;
84
+ }
85
+
86
+ export interface SyncStatusIndicatorProps {
87
+ status: SyncStatus;
88
+ /**
89
+ * Run a round now (push, then pull, then re-read what is on screen). With it the
90
+ * indicator is a "sync now" button, disabled offline and mid-round but keeping its
91
+ * colour; a returned promise keeps the spin until it settles. Without it, a status only
92
+ * (§6.9).
93
+ */
94
+ onSync?: () => void | Promise<unknown>;
95
+ /** Render nothing while in sync — kastlan and Kurvenschmiede show it only while offline,
96
+ * syncing or with changes waiting, held or refused (§2.6). */
97
+ hideWhenIdle?: boolean;
98
+ /** The app's words for its part of the app (§6.10). */
99
+ scope?: SyncStatusScope;
100
+ /**
101
+ * Where the app keeps a change before the server has it. `"device"` (the default): an
102
+ * offline-first app (keksdose, kastlan's handovers) — offline means "kept here, sent
103
+ * later". `"none"`: an online-only app that keeps nothing on the device (Kurvenschmiede,
104
+ * build review B1) — offline reads "changes can't be saved until you reconnect", a
105
+ * round reads "Saving…", and `pending`, `blocked` and `waitingForPlan` are ignored,
106
+ * since there is nowhere for a change to wait. "Up to date" when settled.
107
+ */
108
+ storage?: "device" | "none";
109
+ /** Short words beside the cloud ("Offline", "Syncing…", "3 waiting") while not in sync. */
110
+ showLabel?: boolean;
111
+ /** Prop > `<UiKitProvider labels={{ topBarParts: { sync } }}>` > English. */
112
+ labels?: LabelOverride<SyncStatusLabels>;
113
+ /** The clock's language for "Synced at 14:05". Default: the provider's locale. */
114
+ locale?: string;
115
+ className?: string;
116
+ /** `data-*` attributes go on the trigger — a tour's anchor (`data-tour="sync-status"`). */
117
+ [key: `data-${string}`]: string | number | boolean | undefined;
118
+ }
119
+
120
+ /** The `data-*` attributes of a rest object, for a trigger. */
121
+ function dataAttributes(rest: Record<string, unknown>): Record<string, unknown> {
122
+ return Object.fromEntries(Object.entries(rest).filter(([k]) => k.startsWith("data-")));
123
+ }
124
+
125
+ /**
126
+ * The top bar's sync status (docs/top-bar-harmonization.md §2.6), lifted from keksdose's
127
+ * `SyncStatusIndicator`: the seven states and their ranking ({@link syncState}), one cloud
128
+ * per state, a ✓ badge when in sync and a spin while syncing. With `onSync` it is also the
129
+ * "sync now" button, and its name says both what it is and what a tap does.
130
+ *
131
+ * Colours are the kit's roles — danger for a write that cannot be sent, warning for
132
+ * anything waiting, info while syncing, success when in sync — where keksdose had raw
133
+ * rose/amber/sky/emerald pairs.
134
+ */
135
+ export function SyncStatusIndicator({
136
+ status,
137
+ onSync,
138
+ hideWhenIdle = false,
139
+ scope,
140
+ showLabel = false,
141
+ labels: labelsProp,
142
+ locale: localeProp,
143
+ className,
144
+ storage = "device",
145
+ ...rest
146
+ }: SyncStatusIndicatorProps) {
147
+ const words = useTopBarPartsLabels(labelsProp ? { sync: labelsProp } : undefined).sync;
148
+ const billing = useBillingLabels();
149
+ const locale = useKitLocale(localeProp);
150
+ // The tap's own round, local rather than in the app's store: the store's phase belongs
151
+ // to the sync engine, and a second writer would race the round itself.
152
+ const [refreshing, setRefreshing] = useState(false);
153
+ const mounted = useRef(true);
154
+ useEffect(() => {
155
+ mounted.current = true;
156
+ return () => {
157
+ mounted.current = false;
158
+ };
159
+ }, []);
160
+
161
+ const onlineOnly = storage === "none";
162
+ // Online-only: nothing waits on the device, so only offline, error, saving and settled.
163
+ const state = syncState(
164
+ onlineOnly ? { ...status, pending: 0, blocked: 0, waitingForPlan: 0 } : status,
165
+ refreshing,
166
+ );
167
+ const data = dataAttributes(rest);
168
+ if (hideWhenIdle && state === "synced") return null;
169
+
170
+ const { Icon, tone } = LOOK[state];
171
+ const spin = state === "syncing";
172
+ let text: string;
173
+ switch (state) {
174
+ case "blocked":
175
+ text = words.blocked(status.blocked ?? 0);
176
+ break;
177
+ case "waitingForPlan":
178
+ text = billing.waitingChanges(status.waitingForPlan ?? 0);
179
+ break;
180
+ case "offline":
181
+ text = onlineOnly ? words.offlineUnsaved : words.offline;
182
+ break;
183
+ case "error":
184
+ text = words.refused;
185
+ break;
186
+ case "syncing":
187
+ text = onlineOnly ? words.saving : words.syncing;
188
+ break;
189
+ case "pending":
190
+ text = words.pending(status.pending);
191
+ break;
192
+ default:
193
+ text = !onlineOnly && status.lastSyncedAt
194
+ ? words.syncedAt(
195
+ // A clock time in the UI's language, never the browser's (keksdose live #246).
196
+ new Date(status.lastSyncedAt).toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" }),
197
+ )
198
+ : words.idle;
199
+ }
200
+ if (scope) {
201
+ text = state === "offline" && scope.offline ? scope.offline : words.scoped(scope.name, text);
202
+ }
203
+
204
+ const short =
205
+ state === "offline"
206
+ ? words.offlineShort
207
+ : state === "syncing"
208
+ ? onlineOnly
209
+ ? words.saving
210
+ : words.syncingShort
211
+ : state === "error"
212
+ ? words.refusedShort
213
+ : state === "pending" || state === "blocked" || state === "waitingForPlan"
214
+ ? words.waitingShort(
215
+ state === "pending"
216
+ ? status.pending
217
+ : state === "blocked"
218
+ ? (status.blocked ?? 0)
219
+ : (status.waitingForPlan ?? 0),
220
+ )
221
+ : null;
222
+
223
+ const offline = state === "offline";
224
+ const canSync = !!onSync && status.online && status.phase !== "offline" && !spin;
225
+ const name = onSync
226
+ ? canSync
227
+ ? words.withHint(text, words.syncHint)
228
+ : offline
229
+ ? words.withHint(text, words.offlineHint)
230
+ : text
231
+ : text;
232
+ const badge = state === "synced" ? <Check className="stroke-[3]" /> : undefined;
233
+ const glyph = <Icon aria-hidden className={spin ? "animate-spin" : undefined} />;
234
+
235
+ const sync = async () => {
236
+ if (!canSync || !onSync) return;
237
+ setRefreshing(true);
238
+ try {
239
+ await onSync();
240
+ } finally {
241
+ if (mounted.current) setRefreshing(false);
242
+ }
243
+ };
244
+
245
+ const control = onSync ? (
246
+ <IconButton
247
+ {...data}
248
+ data-sync-state={state}
249
+ size="2xs"
250
+ glyphSize={20}
251
+ shape="round"
252
+ // A status that is also an action: disabled (offline, mid-round) is exactly when
253
+ // the state matters most, so it keeps its colour.
254
+ disabledStyle="keep"
255
+ tone={tone}
256
+ quiet={false}
257
+ label={name}
258
+ labelVisible={false}
259
+ // Below: the bar is at the top edge, where a bubble above has no room.
260
+ tooltipSide="bottom"
261
+ onClick={() => void sync()}
262
+ disabled={!canSync}
263
+ // It announces itself as a status: a reader hears the state change.
264
+ aria-live="polite"
265
+ badge={badge}
266
+ >
267
+ {glyph}
268
+ </IconButton>
269
+ ) : (
270
+ <Tooltip label={name} side="bottom">
271
+ <span
272
+ {...data}
273
+ role="status"
274
+ data-sync-state={state}
275
+ className={cn("relative inline-flex size-9 shrink-0 items-center justify-center [&>svg]:size-5", TEXT_TONE[tone])}
276
+ >
277
+ {glyph}
278
+ <span className="sr-only">{name}</span>
279
+ {badge && (
280
+ <span
281
+ aria-hidden
282
+ className="pointer-events-none absolute bottom-1.5 end-1.5 inline-flex rounded-full bg-surface [&_svg]:size-3"
283
+ >
284
+ {badge}
285
+ </span>
286
+ )}
287
+ </span>
288
+ </Tooltip>
289
+ );
290
+
291
+ if (!showLabel || !short) return className ? <span className={cn("inline-flex", className)}>{control}</span> : control;
292
+ return (
293
+ <span className={cn("inline-flex items-center gap-1", className)}>
294
+ {control}
295
+ {/* The eye's short words; the name above already says it in full. */}
296
+ <span aria-hidden className={cn("whitespace-nowrap text-xs font-medium", TEXT_TONE[tone])}>
297
+ {short}
298
+ </span>
299
+ </span>
300
+ );
301
+ }
@@ -0,0 +1,79 @@
1
+ import { Fragment } from "react";
2
+ import type { ReactNode } from "react";
3
+
4
+ /**
5
+ * The top bar's items by name (docs/top-bar-harmonization.md §2.1, §6.6). Each is the
6
+ * app's own node — a kit part or its own — and any may be left out.
7
+ */
8
+ export interface TopBarActionsProps {
9
+ /** kastlan's `CompanySwitcher`: the context, first. */
10
+ companySwitcher?: ReactNode;
11
+ /** `GlobalSearch` (⌘K). */
12
+ search?: ReactNode;
13
+ /** `ThemeToggle`. */
14
+ theme?: ReactNode;
15
+ /** `FeedbackMenu`. */
16
+ feedback?: ReactNode;
17
+ /** `TourMenu`. */
18
+ tours?: ReactNode;
19
+ /** kastlan's assistant, beside the tours while it is on. */
20
+ assistant?: ReactNode;
21
+ /** `SyncStatusIndicator`. */
22
+ sync?: ReactNode;
23
+ /** `DevSwitchers` (role, then plan). */
24
+ dev?: ReactNode;
25
+ /** kastlan's platform button. */
26
+ platform?: ReactNode;
27
+ /** The account menu. When it is set the signed-out slots are not drawn. */
28
+ account?: ReactNode;
29
+ /** Signed out (§6.6): the bar's `LanguageMenu`, since there is no account menu to hold
30
+ * the language. Drawn only without `account`. */
31
+ language?: ReactNode;
32
+ /** Signed out (§6.6): the "Sign in" button, where the account menu would be. Drawn only
33
+ * without `account`. */
34
+ signIn?: ReactNode;
35
+ }
36
+
37
+ /** §2.1's order, then the signed-out pair in the account menu's place. */
38
+ export const TOP_BAR_ACTION_ORDER = [
39
+ "companySwitcher",
40
+ "search",
41
+ "theme",
42
+ "feedback",
43
+ "tours",
44
+ "assistant",
45
+ "sync",
46
+ "dev",
47
+ "platform",
48
+ "account",
49
+ "language",
50
+ "signIn",
51
+ ] as const satisfies readonly (keyof TopBarActionsProps)[];
52
+
53
+ const present = (node: ReactNode) => node !== undefined && node !== null && node !== false && node !== "";
54
+
55
+ /**
56
+ * The top bar's actions in the one order every app shares (§2.1): company switcher ·
57
+ * Search · Theme · Feedback · Tours (· Assistant) · Sync · dev role · dev plan · platform ·
58
+ * account menu — and, signed out, Language · Sign in where the account menu goes (§6.6).
59
+ * The app fills the slots it has; an empty slot is skipped, so it leaves no gap.
60
+ *
61
+ * It draws a fragment, no box of its own: hand it to `TopBar`'s `actions`, whose row
62
+ * spaces the items.
63
+ *
64
+ * ```tsx
65
+ * <TopBar brand={…} actions={<TopBarActions search={…} theme={…} tours={…} account={…} />} />
66
+ * ```
67
+ */
68
+ export function TopBarActions(props: TopBarActionsProps) {
69
+ const signedOut = !present(props.account);
70
+ return (
71
+ <>
72
+ {TOP_BAR_ACTION_ORDER.map((slot) => {
73
+ if ((slot === "language" || slot === "signIn") && !signedOut) return null;
74
+ const node = props[slot];
75
+ return present(node) ? <Fragment key={slot}>{node}</Fragment> : null;
76
+ })}
77
+ </>
78
+ );
79
+ }
@@ -0,0 +1,133 @@
1
+ import { useKitLabels, type LabelOverride } from "../i18n/kit-labels";
2
+
3
+ /**
4
+ * The `topBarParts` namespace (0.42, docs/top-bar-harmonization.md §3.1): the words of the
5
+ * top bar's shared parts — `TourMenu`, `SyncStatusIndicator` and `DevSwitchers`. A module
6
+ * of words with no component code, like every `*-labels.ts` (0.34, KS U8). The language
7
+ * sub-list (`AccountMenuLanguage`) names itself with `topBar.language`, and the dev role
8
+ * switcher's name and tooltip are `topBar.switchRole` / `topBar.role`, as `RoleSwitcher`'s.
9
+ */
10
+
11
+ /** `TourMenu`'s words (§2.4). */
12
+ export interface TourMenuLabels {
13
+ /** The compass's name, and the menu's. */
14
+ menu: string;
15
+ /** The NEW dot on the compass, for a reader: it joins the trigger's name. */
16
+ newDot: string;
17
+ /** The heading over the tours that start on the open page. */
18
+ thisPage: string;
19
+ /** The heading over the untaken new tours. */
20
+ newTours: string;
21
+ /** The last row, the way to the app's tours page (only with `href`). */
22
+ allTours: string;
23
+ /** The chip on a new tour's row. */
24
+ badgeNew: string;
25
+ /** What the ✓ on a taken tour's row says to a reader. */
26
+ completed: string;
27
+ }
28
+
29
+ /** `SyncStatusIndicator`'s words (§2.6). The "waiting for a plan" state is
30
+ * `billing.waitingChanges`, the kit's words for it since billing §12.5. */
31
+ export interface SyncStatusLabels {
32
+ /** Writes this device cannot send (keksdose: no key for the budget). Ranked first. */
33
+ blocked: (count: number) => string;
34
+ offline: string;
35
+ /** The server refused the last round. */
36
+ refused: string;
37
+ syncing: string;
38
+ pending: (count: number) => string;
39
+ /** Settled, with the time of the last good round (already formatted). */
40
+ syncedAt: (time: string) => string;
41
+ /** Settled, before the first round. */
42
+ idle: string;
43
+ /** Joined to the state when a tap syncs: what the tap does. */
44
+ syncHint: string;
45
+ /** Joined to the state while offline, when a tap can do nothing. */
46
+ offlineHint: string;
47
+ /** The state and what a tap does, as one tooltip: "Offline — nothing to sync…". */
48
+ withHint: (state: string, hint: string) => string;
49
+ /** The state with the app's `scope` words before it: "Handovers on this device: …". */
50
+ scoped: (scope: string, state: string) => string;
51
+ /** The short words beside the cloud (`showLabel`): offline. */
52
+ offlineShort: string;
53
+ /** The short words while syncing. */
54
+ syncingShort: string;
55
+ /** The short words while changes wait (pending, held for a plan, or blocked). */
56
+ waitingShort: (count: number) => string;
57
+ /** The short words after a refused round. */
58
+ refusedShort: string;
59
+ /** Offline in an online-only app (`storage="none"`): nothing is kept on the device. */
60
+ offlineUnsaved: string;
61
+ /** A round under way in an online-only app — it saves, it doesn't sync. Also its short
62
+ * words beside the cloud. */
63
+ saving: string;
64
+ }
65
+
66
+ /** `DevSwitchers`' words (§2.7, §6.2, §6.3). */
67
+ export interface DevSwitchersLabels {
68
+ /** The heading of the role menu. */
69
+ roleHeading: string;
70
+ /** The plan switcher's name. */
71
+ switchPlan: string;
72
+ /** The plan switcher's tooltip, given the active plan. */
73
+ plan: (value: string) => string;
74
+ /** The heading of the plan menu. */
75
+ planHeading: string;
76
+ /** The row back to the role the session started with. */
77
+ backTo: (role: string) => string;
78
+ /** The plan menu's note while billing is off locally. */
79
+ billingOff: string;
80
+ }
81
+
82
+ export interface TopBarPartsLabels {
83
+ tours: TourMenuLabels;
84
+ sync: SyncStatusLabels;
85
+ dev: DevSwitchersLabels;
86
+ }
87
+
88
+ export const DEFAULT_TOP_BAR_PARTS_LABELS: TopBarPartsLabels = {
89
+ tours: {
90
+ menu: "Guided tours",
91
+ newDot: "New tours",
92
+ thisPage: "On this page",
93
+ newTours: "New tours",
94
+ allTours: "All tours…",
95
+ badgeNew: "New",
96
+ completed: "Completed",
97
+ },
98
+ sync: {
99
+ blocked: (count) =>
100
+ count === 1
101
+ ? "1 change cannot be sent from this device"
102
+ : `${count} changes cannot be sent from this device`,
103
+ offline: "Offline — changes are saved on this device and will sync when you reconnect",
104
+ refused: "The server refused the last sync",
105
+ syncing: "Syncing…",
106
+ pending: (count) => (count === 1 ? "1 change waiting to sync" : `${count} changes waiting to sync`),
107
+ syncedAt: (time) => `Synced at ${time}`,
108
+ idle: "Up to date",
109
+ syncHint: "Tap to sync now",
110
+ offlineHint: "nothing to sync while offline",
111
+ withHint: (state, hint) => `${state} — ${hint}`,
112
+ scoped: (scope, state) => `${scope}: ${state}`,
113
+ offlineShort: "Offline",
114
+ syncingShort: "Syncing…",
115
+ waitingShort: (count) => `${count} waiting`,
116
+ refusedShort: "Not synced",
117
+ offlineUnsaved: "Offline — changes can't be saved until you reconnect",
118
+ saving: "Saving…",
119
+ },
120
+ dev: {
121
+ roleHeading: "Dev · role",
122
+ switchPlan: "Switch plan",
123
+ plan: (value) => `Plan: ${value}`,
124
+ planHeading: "Dev · plan",
125
+ backTo: (role) => `Back to ${role}`,
126
+ billingOff: "Billing is off locally: plans change nothing",
127
+ },
128
+ };
129
+
130
+ /** The `topBarParts` namespace, resolved — English, then the provider, then `labels`. */
131
+ export function useTopBarPartsLabels(labels?: LabelOverride<TopBarPartsLabels>): TopBarPartsLabels {
132
+ return useKitLabels("topBarParts", DEFAULT_TOP_BAR_PARTS_LABELS, labels);
133
+ }