@cosxai/ui 0.25.0 → 1.0.0-alpha.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 (133) hide show
  1. package/CHANGELOG.md +577 -0
  2. package/NOTICE +11 -0
  3. package/README.md +52 -0
  4. package/package.json +33 -7
  5. package/src/fonts.css +20 -0
  6. package/src/index.ts +8 -25
  7. package/src/lib/cn.ts +23 -6
  8. package/src/styles.css +17 -0
  9. package/src/theme.css +132 -0
  10. package/src/tokens/colors.css +112 -0
  11. package/src/tokens/compat.css +36 -0
  12. package/src/tokens/elevation.css +14 -0
  13. package/src/tokens/motion.css +42 -0
  14. package/src/tokens/spacing.css +55 -0
  15. package/src/tokens/typography-cjk.css +42 -0
  16. package/src/tokens/typography.css +81 -0
  17. package/src/actionbar/ActionBar.tsx +0 -837
  18. package/src/actionbar/ActionBarButton.tsx +0 -111
  19. package/src/actionbar/ActionBarMenuGroup.tsx +0 -116
  20. package/src/actionbar/ActionBarModeHandle.tsx +0 -171
  21. package/src/actionbar/ActionBarProvider.tsx +0 -91
  22. package/src/actionbar/actionbar-context.ts +0 -45
  23. package/src/actionbar/index.ts +0 -28
  24. package/src/actionbar/types.ts +0 -147
  25. package/src/actionbar/useActionBarItems.ts +0 -60
  26. package/src/actionbar/useActionBarMode.ts +0 -144
  27. package/src/actionbar/useActionBarPanel.ts +0 -56
  28. package/src/actionbar/useActionBarStatusDot.ts +0 -49
  29. package/src/actionbar/useActionBarToast.ts +0 -33
  30. package/src/ambient/AmbientBackdrop.tsx +0 -74
  31. package/src/ambient/CommandInput.tsx +0 -107
  32. package/src/ambient/SuperbarStrip.tsx +0 -36
  33. package/src/ambient/index.ts +0 -6
  34. package/src/bento/BentoCell.tsx +0 -66
  35. package/src/bento/BentoGrid.tsx +0 -42
  36. package/src/bento/index.ts +0 -2
  37. package/src/command/CommandPalette.tsx +0 -329
  38. package/src/command/CommandProvider.tsx +0 -57
  39. package/src/command/command-context.ts +0 -12
  40. package/src/command/index.ts +0 -6
  41. package/src/command/rank.ts +0 -50
  42. package/src/command/types.ts +0 -37
  43. package/src/command/useCommandSource.ts +0 -37
  44. package/src/dialogs/DialogsProvider.tsx +0 -216
  45. package/src/dialogs/Modal.tsx +0 -260
  46. package/src/dialogs/Toast.tsx +0 -85
  47. package/src/dialogs/dialogs-context.ts +0 -6
  48. package/src/dialogs/index.ts +0 -10
  49. package/src/dialogs/types.ts +0 -37
  50. package/src/dialogs/useDialogs.ts +0 -8
  51. package/src/editorial/EditorialSpotlight.tsx +0 -63
  52. package/src/editorial/Folio.tsx +0 -52
  53. package/src/editorial/PlateMarker.tsx +0 -33
  54. package/src/editorial/RomanSection.tsx +0 -65
  55. package/src/editorial/RunningMarginalia.tsx +0 -65
  56. package/src/editorial/index.ts +0 -10
  57. package/src/hooks/index.ts +0 -5
  58. package/src/hooks/useKeyboardHotkey.ts +0 -93
  59. package/src/hooks/useReducedMotion.ts +0 -20
  60. package/src/hooks/useViewport.ts +0 -61
  61. package/src/layout/Breadcrumb.tsx +0 -74
  62. package/src/layout/LeftNavRail.tsx +0 -139
  63. package/src/layout/MobileTabBar.tsx +0 -101
  64. package/src/layout/NavItem.tsx +0 -128
  65. package/src/layout/NavSearchTrigger.tsx +0 -88
  66. package/src/layout/NavSection.tsx +0 -40
  67. package/src/layout/RightSidebarPanel.tsx +0 -111
  68. package/src/layout/Shell.tsx +0 -91
  69. package/src/layout/SidePanel.tsx +0 -316
  70. package/src/layout/StickyBanner.tsx +0 -83
  71. package/src/layout/Topbar.tsx +0 -68
  72. package/src/layout/index.ts +0 -24
  73. package/src/layout/useNavRailState.ts +0 -69
  74. package/src/lib/time-utils.ts +0 -44
  75. package/src/neobrutalism/Marquee.tsx +0 -81
  76. package/src/neobrutalism/Sticker.tsx +0 -71
  77. package/src/neobrutalism/index.ts +0 -4
  78. package/src/primitives/Avatar.tsx +0 -53
  79. package/src/primitives/Button.tsx +0 -67
  80. package/src/primitives/Card.tsx +0 -41
  81. package/src/primitives/Checkbox.tsx +0 -87
  82. package/src/primitives/Chip.tsx +0 -191
  83. package/src/primitives/Combobox.tsx +0 -507
  84. package/src/primitives/CopyField.tsx +0 -139
  85. package/src/primitives/CountBadge.tsx +0 -50
  86. package/src/primitives/Input.tsx +0 -204
  87. package/src/primitives/Kbd.tsx +0 -45
  88. package/src/primitives/MentionCombobox.tsx +0 -612
  89. package/src/primitives/MultiCombobox.tsx +0 -327
  90. package/src/primitives/PageHeader.tsx +0 -77
  91. package/src/primitives/Radio.tsx +0 -131
  92. package/src/primitives/SegmentedControl.tsx +0 -135
  93. package/src/primitives/Select.tsx +0 -779
  94. package/src/primitives/SignaturePad.tsx +0 -461
  95. package/src/primitives/Tag.tsx +0 -56
  96. package/src/primitives/Textarea.tsx +0 -62
  97. package/src/primitives/ToggleSwitch.tsx +0 -79
  98. package/src/primitives/Tooltip.tsx +0 -213
  99. package/src/primitives/index.ts +0 -53
  100. package/src/pwa/InstallPromptBanner.tsx +0 -132
  101. package/src/pwa/index.ts +0 -4
  102. package/src/pwa/manifest.template.json +0 -20
  103. package/src/pwa/registerSW.ts +0 -55
  104. package/src/riso/Halftone.tsx +0 -85
  105. package/src/riso/Misregister.tsx +0 -63
  106. package/src/riso/RisoStamp.tsx +0 -76
  107. package/src/riso/index.ts +0 -3
  108. package/src/sketch/HandUnderline.tsx +0 -53
  109. package/src/sketch/RoughArrow.tsx +0 -91
  110. package/src/sketch/RoughBox.tsx +0 -73
  111. package/src/sketch/StickyNote.tsx +0 -56
  112. package/src/sketch/index.ts +0 -4
  113. package/src/styles/base.css +0 -186
  114. package/src/styles/chrome-ambient.css +0 -235
  115. package/src/styles/chrome-bento.css +0 -192
  116. package/src/styles/chrome-editorial.css +0 -168
  117. package/src/styles/chrome-neobrutalism.css +0 -326
  118. package/src/styles/chrome-riso.css +0 -339
  119. package/src/styles/chrome-sketch.css +0 -362
  120. package/src/styles/chrome-swiss.css +0 -265
  121. package/src/styles/chrome-terminal.css +0 -272
  122. package/src/styles/fonts.css +0 -1034
  123. package/src/styles/index.css +0 -343
  124. package/src/styles/tokens.css +0 -941
  125. package/src/terminal/AsciiBox.tsx +0 -65
  126. package/src/terminal/BrailleSpinner.tsx +0 -46
  127. package/src/terminal/index.ts +0 -4
  128. package/src/theme/ThemeProvider.tsx +0 -99
  129. package/src/theme/index.ts +0 -6
  130. package/src/theme/inline-script.ts +0 -40
  131. package/src/theme/theme-context.ts +0 -7
  132. package/src/theme/types.ts +0 -39
  133. package/src/theme/useTheme.ts +0 -8
@@ -1,837 +0,0 @@
1
- import {
2
- useCallback,
3
- useContext,
4
- useLayoutEffect,
5
- useEffect,
6
- useMemo,
7
- useRef,
8
- useState,
9
- type CSSProperties,
10
- type ReactNode,
11
- } from "react";
12
- import { ActionBarContext } from "./actionbar-context";
13
- import { ActionBarButton } from "./ActionBarButton";
14
- import { ActionBarMenuGroup } from "./ActionBarMenuGroup";
15
- import { useViewport } from "../hooks/useViewport";
16
- import type { ActionBarItem } from "./types";
17
-
18
- // Floating action bar. Reads items from the registry, groups items
19
- // sharing a category (2+) into inline-expandable menus, supports:
20
- // - drag-to-reposition on desktop (left grip; double-click resets)
21
- // - phone-only collapsed handle (left-edge peek tab)
22
- // - localStorage persistence of position + collapsed state
23
- // - spring entry animation at the default centred position
24
- // - ESC + outside-click dismissal of expanded groups
25
- // - label hiding below md breakpoint (icons + tooltips only)
26
- //
27
- // Bar is purely a renderer. Pages register items via useActionBarItems().
28
-
29
- const BAR_HEIGHT = 48;
30
- const BAR_WIDTH_FALLBACK = 440;
31
- const VIEWPORT_MARGIN = 8;
32
- const DEFAULT_BOTTOM_GUTTER = 24;
33
- const COLLAPSED_HANDLE_W = 22;
34
- const COLLAPSED_HANDLE_H = 56;
35
-
36
- type Pos = { type: "default" } | { type: "custom"; left: number; bottom: number };
37
-
38
- function clampCustom(
39
- p: { left: number; bottom: number },
40
- barWidth: number,
41
- ): { left: number; bottom: number } {
42
- if (typeof window === "undefined") return p;
43
- return {
44
- left: Math.max(
45
- VIEWPORT_MARGIN,
46
- Math.min(window.innerWidth - VIEWPORT_MARGIN - barWidth, p.left),
47
- ),
48
- bottom: Math.max(
49
- VIEWPORT_MARGIN,
50
- Math.min(window.innerHeight - VIEWPORT_MARGIN - BAR_HEIGHT, p.bottom),
51
- ),
52
- };
53
- }
54
-
55
- function loadPos(storageKey: string): Pos {
56
- if (typeof window === "undefined") return { type: "default" };
57
- try {
58
- const raw = window.localStorage.getItem(`${storageKey}:pos`);
59
- if (!raw) return { type: "default" };
60
- const parsed = JSON.parse(raw);
61
- if (parsed?.type === "default") return { type: "default" };
62
- if (
63
- parsed?.type === "custom" &&
64
- typeof parsed.left === "number" &&
65
- typeof parsed.bottom === "number"
66
- ) {
67
- return { type: "custom", left: parsed.left, bottom: parsed.bottom };
68
- }
69
- } catch {}
70
- return { type: "default" };
71
- }
72
-
73
- function loadCollapsed(storageKey: string): boolean {
74
- if (typeof window === "undefined") return false;
75
- try {
76
- return window.localStorage.getItem(`${storageKey}:collapsed`) === "1";
77
- } catch {
78
- return false;
79
- }
80
- }
81
-
82
- // Admin mode is session-only — deliberately NOT persisted. Two
83
- // reasons: (1) cross-page reload persistence would surface an
84
- // elevated-privileges state on a fresh session without explicit
85
- // user opt-in every time — a small surprise factor with real
86
- // consequences on admin surfaces. (2) The reset-on-context-change
87
- // effect below relies on the state living in React memory alone,
88
- // so we can watch for admin-item-set changes and clear cleanly.
89
-
90
- interface BuildEntry {
91
- kind: "flat" | "group";
92
- expansionKey: string;
93
- item?: ActionBarItem;
94
- category?: string;
95
- groupItems?: ActionBarItem[];
96
- }
97
-
98
- // Build render entries: a category with 2+ items collapses into one
99
- // group head at the position of its first item; later items in that
100
- // category are folded into the group, not re-emitted. A category
101
- // with one item just renders flat in place. No-category items always
102
- // render flat.
103
- function buildEntries(items: ActionBarItem[]): BuildEntry[] {
104
- const countByCat: Record<string, number> = {};
105
- for (const it of items) {
106
- if (it.category) countByCat[it.category] = (countByCat[it.category] ?? 0) + 1;
107
- }
108
- const result: BuildEntry[] = [];
109
- const consumed = new Set<string>();
110
- for (let i = 0; i < items.length; i++) {
111
- const it = items[i]!;
112
- const cat = it.category;
113
- if (cat && (countByCat[cat] ?? 0) >= 2) {
114
- if (consumed.has(cat)) continue;
115
- const groupItems = items.filter((x) => x.category === cat);
116
- result.push({
117
- kind: "group",
118
- expansionKey: `group:${cat}`,
119
- category: cat,
120
- groupItems,
121
- });
122
- consumed.add(cat);
123
- continue;
124
- }
125
- result.push({
126
- kind: "flat",
127
- expansionKey: it.key ?? `flat-${i}`,
128
- item: it,
129
- });
130
- }
131
- return result;
132
- }
133
-
134
- export interface ActionBarProps {
135
- // localStorage namespace for position + collapsed state.
136
- // Override per-app so multiple bars don't collide.
137
- storageKey?: string;
138
- // Enable desktop drag-to-reposition + double-click-to-reset.
139
- // Default true.
140
- draggable?: boolean;
141
- // Enable phone-only collapsed handle. Default true.
142
- collapsibleOnPhone?: boolean;
143
- className?: string;
144
- style?: CSSProperties;
145
- }
146
-
147
- export function ActionBar({
148
- storageKey = "ck-actionbar",
149
- draggable = true,
150
- collapsibleOnPhone = true,
151
- className,
152
- style,
153
- }: ActionBarProps) {
154
- const ctx = useContext(ActionBarContext);
155
- if (!ctx) {
156
- throw new Error("<ActionBar> must be inside <ActionBarProvider>");
157
- }
158
- const { items, categories, expandedKey, setExpandedKey, statusDot, panel, setPanelHost, toastActive, setToastHost } = ctx;
159
- const vp = useViewport();
160
- const isPhone = vp.isPhone;
161
-
162
- // ----- Position state -----
163
- const [pos, setPos] = useState<Pos>(() => loadPos(storageKey));
164
- const barRef = useRef<HTMLDivElement | null>(null);
165
- useEffect(() => {
166
- try {
167
- window.localStorage.setItem(`${storageKey}:pos`, JSON.stringify(pos));
168
- } catch {}
169
- }, [pos, storageKey]);
170
-
171
- // Re-clamp on width resize so the bar tracks the viewport as
172
- // the window resizes. Initial clamp also runs after first paint
173
- // to handle saved positions from a wider window.
174
- useEffect(() => {
175
- const reclamp = () => {
176
- setPos((p) => {
177
- if (p.type !== "custom") return p;
178
- const w = barRef.current?.offsetWidth ?? BAR_WIDTH_FALLBACK;
179
- return { type: "custom", ...clampCustom(p, w) };
180
- });
181
- };
182
- reclamp();
183
- window.addEventListener("resize", reclamp);
184
- return () => window.removeEventListener("resize", reclamp);
185
- }, []);
186
-
187
- // ----- Admin mode toggle (any registered item with adminOnly=true) -----
188
- // Admin items stay hidden by default; a small toggle button (rendered
189
- // between the grip and the first item) flips this to reveal them.
190
- // Session-only: never persisted (see comment on loadAdminMode's
191
- // removal above). Additionally auto-resets when the set of
192
- // adminOnly item keys changes so a user who turned admin on for
193
- // Doc A doesn't accidentally land in admin mode on Doc B.
194
- const [adminMode, setAdminMode] = useState<boolean>(false);
195
- const toggleAdminMode = useCallback(() => {
196
- setAdminMode((prev) => !prev);
197
- }, []);
198
- const adminItemKeys = useMemo(
199
- () =>
200
- items
201
- .filter((it) => it.adminOnly === true)
202
- .map((it) => it.key)
203
- .sort()
204
- .join(","),
205
- [items],
206
- );
207
- const prevAdminKeysRef = useRef(adminItemKeys);
208
- useEffect(() => {
209
- if (prevAdminKeysRef.current !== adminItemKeys) {
210
- prevAdminKeysRef.current = adminItemKeys;
211
- // Context changed (typically: user navigated to a different
212
- // page whose useActionBarItems registered a different set of
213
- // admin actions). Reset admin mode so the elevated state is
214
- // never carried across viewer sessions implicitly.
215
- setAdminMode(false);
216
- }
217
- }, [adminItemKeys]);
218
- const hasAdminItems = useMemo(() => items.some((it) => it.adminOnly === true), [items]);
219
- // Filter semantics:
220
- // admin OFF → hide items marked adminOnly (the toggle would reveal them)
221
- // admin ON → hide items marked hiddenInAdmin (page opted them out of admin view)
222
- // Together, adminOnly + hiddenInAdmin let pages choose:
223
- // - additive: normal items unmarked → visible in both modes;
224
- // admin items marked adminOnly → visible only in admin
225
- // - exclusive: normal items marked hiddenInAdmin → hidden in admin;
226
- // admin items marked adminOnly → visible only in admin
227
- const visibleItems = useMemo(
228
- () =>
229
- adminMode
230
- ? items.filter((it) => it.hiddenInAdmin !== true)
231
- : items.filter((it) => it.adminOnly !== true),
232
- [items, adminMode],
233
- );
234
-
235
- // ----- Collapsed state (phone only) -----
236
- const [collapsed, setCollapsed] = useState<boolean>(() => loadCollapsed(storageKey));
237
- useEffect(() => {
238
- try {
239
- window.localStorage.setItem(
240
- `${storageKey}:collapsed`,
241
- collapsed ? "1" : "0",
242
- );
243
- } catch {}
244
- }, [collapsed, storageKey]);
245
-
246
- // ----- Drag handlers (desktop) -----
247
- const dragRef = useRef<{ active: boolean; offsetX: number; offsetY: number }>({
248
- active: false,
249
- offsetX: 0,
250
- offsetY: 0,
251
- });
252
- const onGripDown = useCallback((e: React.MouseEvent) => {
253
- const rect = barRef.current?.getBoundingClientRect();
254
- if (!rect) return;
255
- const currentLeft = rect.left;
256
- const currentBottom = window.innerHeight - rect.bottom;
257
- dragRef.current = {
258
- active: true,
259
- offsetX: e.clientX - currentLeft,
260
- offsetY: e.clientY - (window.innerHeight - currentBottom - BAR_HEIGHT),
261
- };
262
- e.preventDefault();
263
- }, []);
264
- useEffect(() => {
265
- const onMove = (e: MouseEvent) => {
266
- if (!dragRef.current.active) return;
267
- const w = barRef.current?.offsetWidth ?? BAR_WIDTH_FALLBACK;
268
- const left = e.clientX - dragRef.current.offsetX;
269
- const topY = e.clientY - dragRef.current.offsetY;
270
- const bottom = window.innerHeight - topY - BAR_HEIGHT;
271
- setPos({ type: "custom", ...clampCustom({ left, bottom }, w) });
272
- };
273
- const onUp = () => {
274
- dragRef.current.active = false;
275
- };
276
- window.addEventListener("mousemove", onMove);
277
- window.addEventListener("mouseup", onUp);
278
- return () => {
279
- window.removeEventListener("mousemove", onMove);
280
- window.removeEventListener("mouseup", onUp);
281
- };
282
- }, []);
283
- const onGripDoubleClick = useCallback(() => {
284
- try {
285
- window.localStorage.removeItem(`${storageKey}:pos`);
286
- } catch {}
287
- setPos({ type: "default" });
288
- }, [storageKey]);
289
-
290
- // ----- Entries (flat vs group), partitioned by slot -----
291
- // Items default to the leading slot. Trailing items render after a
292
- // flex spacer so they pin to the right edge regardless of
293
- // registration order — system status indicators (sync, identity,
294
- // connection) belong here, where page items registering later
295
- // can't shuffle them. buildEntries runs per slot so category
296
- // grouping stays slot-local (a category split across slots is an
297
- // edge case we intentionally don't fold across the spacer).
298
- const { leadingEntries, trailingEntries } = useMemo(() => {
299
- const leading: ActionBarItem[] = [];
300
- const trailing: ActionBarItem[] = [];
301
- for (const it of visibleItems) {
302
- (it.slot === "trailing" ? trailing : leading).push(it);
303
- }
304
- return {
305
- leadingEntries: buildEntries(leading),
306
- trailingEntries: buildEntries(trailing),
307
- };
308
- }, [visibleItems]);
309
- const entries = useMemo(
310
- () => [...leadingEntries, ...trailingEntries],
311
- [leadingEntries, trailingEntries],
312
- );
313
-
314
- // Cleanup stale expansion if the corresponding group disappears.
315
- useEffect(() => {
316
- if (expandedKey === null) return;
317
- if (!entries.some((e) => e.kind === "group" && e.expansionKey === expandedKey)) {
318
- setExpandedKey(null);
319
- }
320
- }, [expandedKey, entries, setExpandedKey]);
321
-
322
- // ESC closes any open group.
323
- useEffect(() => {
324
- if (expandedKey === null) return;
325
- const onKey = (e: KeyboardEvent) => {
326
- if (e.key === "Escape") {
327
- e.preventDefault();
328
- setExpandedKey(null);
329
- }
330
- };
331
- window.addEventListener("keydown", onKey);
332
- return () => window.removeEventListener("keydown", onKey);
333
- }, [expandedKey, setExpandedKey]);
334
-
335
- // ----- Panel slot (design#13): outside-click + Escape close. -----
336
- // Hooks live ABOVE the early returns below — hook order must not
337
- // change when the bar switches between empty/collapsed/full.
338
- const panelOpen = Boolean(panel?.open);
339
- const panelClose = panel?.onOpenChange;
340
- useEffect(() => {
341
- if (!panelOpen || !panelClose) return;
342
- const onDown = (e: MouseEvent) => {
343
- const t = e.target as Node | null;
344
- if (t && barRef.current?.contains(t)) return;
345
- panelClose(false);
346
- };
347
- const onKey = (e: KeyboardEvent) => {
348
- if (e.key === "Escape") panelClose(false);
349
- };
350
- window.addEventListener("mousedown", onDown);
351
- window.addEventListener("keydown", onKey);
352
- return () => {
353
- window.removeEventListener("mousedown", onDown);
354
- window.removeEventListener("keydown", onKey);
355
- };
356
- }, [panelOpen, panelClose]);
357
-
358
- // Host refs — stable callbacks publishing the container DOM nodes
359
- // through context so the consumer hooks can portal content in.
360
- // useCallback keeps React from detach/re-attach churn per render.
361
- const panelRef = useRef<HTMLDivElement | null>(null);
362
- const panelHostRef = useCallback(
363
- (el: HTMLDivElement | null) => {
364
- panelRef.current = el;
365
- setPanelHost(el);
366
- },
367
- [setPanelHost],
368
- );
369
- const toastHostRef = useCallback(
370
- (el: HTMLDivElement | null) => {
371
- setToastHost(el);
372
- },
373
- [setToastHost],
374
- );
375
-
376
- // Horizontal clamp: centred on the bar unless that would push the
377
- // panel past a viewport edge — then shift just enough to fit.
378
- useLayoutEffect(() => {
379
- if (!panelOpen) return;
380
- const el = panelRef.current;
381
- if (!el) return;
382
- el.style.marginLeft = "0px";
383
- const rect = el.getBoundingClientRect();
384
- let shift = 0;
385
- if (rect.left < 8) shift = 8 - rect.left;
386
- else if (rect.right > window.innerWidth - 8) shift = window.innerWidth - 8 - rect.right;
387
- if (shift !== 0) el.style.marginLeft = `${Math.round(shift)}px`;
388
- }, [panelOpen, pos]);
389
-
390
- // ----- Empty state -----
391
- // Bar renders when EITHER any page item is registered OR the
392
- // status dot is set. Without the statusDot check, an app whose
393
- // only consumer is `useActionBarStatusDot` (e.g. a system-status-
394
- // only surface) would never see the bar at all.
395
- if (items.length === 0 && !statusDot) return null;
396
-
397
- // ----- Collapsed handle (phone only) -----
398
- if (isPhone && collapsibleOnPhone && collapsed) {
399
- return (
400
- <button
401
- type="button"
402
- onClick={() => setCollapsed(false)}
403
- aria-label="Expand action bar"
404
- title="Tap to expand"
405
- className="ck-action-handle-enter"
406
- style={{
407
- position: "fixed",
408
- left: 0,
409
- bottom: `calc(${DEFAULT_BOTTOM_GUTTER}px + var(--ck-tabbar-height, 0px) + env(safe-area-inset-bottom, 0px))`,
410
- width: COLLAPSED_HANDLE_W,
411
- height: COLLAPSED_HANDLE_H,
412
- padding: 0,
413
- paddingLeft: 4,
414
- display: "inline-flex",
415
- alignItems: "center",
416
- justifyContent: "center",
417
- zIndex: 80,
418
- background: "var(--ck-bg-surface)",
419
- border: "1px solid var(--ck-border-subtle)",
420
- borderLeft: 0,
421
- borderTopLeftRadius: 0,
422
- borderBottomLeftRadius: 0,
423
- borderTopRightRadius: 999,
424
- borderBottomRightRadius: 999,
425
- boxShadow: "3px 0 12px rgba(0,0,0,0.18)",
426
- cursor: "pointer",
427
- color: "var(--ck-text-secondary)",
428
- }}
429
- >
430
- <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
431
- <path d="M12 2 L13.6 8.4 L20 10 L13.6 11.6 L12 18 L10.4 11.6 L4 10 L10.4 8.4 Z" />
432
- <path
433
- d="M19 16 L19.6 18.4 L22 19 L19.6 19.6 L19 22 L18.4 19.6 L16 19 L18.4 18.4 Z"
434
- opacity="0.55"
435
- />
436
- </svg>
437
- </button>
438
- );
439
- }
440
-
441
- // ----- Full bar -----
442
- const isDefault = pos.type === "default";
443
- return (
444
- <div
445
- ref={barRef}
446
- data-ck-actionbar
447
- className={`ck-actionbar${isDefault ? " ck-actionbar-enter" : ""}${className ? ` ${className}` : ""}`}
448
- style={{
449
- position: "fixed",
450
- ...(isDefault
451
- ? {
452
- left: "50%",
453
- transform: "translateX(-50%)",
454
- bottom: `calc(${DEFAULT_BOTTOM_GUTTER}px + var(--ck-tabbar-height, 0px) + env(safe-area-inset-bottom, 0px))`,
455
- }
456
- : {
457
- left: pos.left,
458
- bottom: pos.bottom,
459
- }),
460
- height: BAR_HEIGHT,
461
- // Symmetric horizontal padding so the bar's interior content
462
- // area is centred. Previously only the right side had 6px
463
- // padding (legacy from when the right edge held normal items
464
- // and the left edge was an interactive grip), which made the
465
- // grip sit flush against the left curve while the rightmost
466
- // element (now the status dot) had visible breathing room.
467
- // The asymmetry pushed the leading items a few pixels left of
468
- // the bar's true centre.
469
- padding: "0 6px",
470
- display: "flex",
471
- alignItems: "center",
472
- gap: 4,
473
- zIndex: 80,
474
- // Bar background stays constant across modes — the earlier
475
- // whole-bar tint composited under the item hover overlay
476
- // (cool grey on tinted warm base) produced an awkward green
477
- // cast on hover, and competed with the shield toggle for
478
- // signal. Border colour DOES swap to accent when admin mode
479
- // is on so the elevated state reads at a glance without a
480
- // heavy surface change; the shield glyph adds a second cue
481
- // (outline off → filled on) at the toggle itself.
482
- background: "var(--ck-bg-surface)",
483
- border: `1px solid ${
484
- adminMode
485
- ? "var(--ck-accent, #4f46e5)"
486
- : "var(--ck-border-subtle)"
487
- }`,
488
- borderRadius: 999,
489
- boxShadow: "var(--ck-shadow-3)",
490
- fontFamily: "var(--ck-font-sans)",
491
- color: "var(--ck-text-primary)",
492
- transition: "border-color 200ms var(--ck-ease, ease)",
493
- ...style,
494
- }}
495
- >
496
- {/* Panel slot (design#13) — the bar positions + the chrome
497
- themes style it (.ck-actionbar-panel); consumers own only
498
- the content, PORTALLED in via useActionBarPanel (the div
499
- here is an empty host — content never flows through
500
- context state). Rendered inside the bar root so it follows
501
- drags natively. */}
502
- {panel?.open && (
503
- <div
504
- ref={panelHostRef}
505
- className="ck-actionbar-panel"
506
- role="region"
507
- aria-label={panel.ariaLabel ?? "Panel"}
508
- style={{
509
- position: "absolute",
510
- bottom: "calc(100% + 8px)",
511
- left: "50%",
512
- transform: "translateX(-50%)",
513
- width: panel.width ?? 320,
514
- maxWidth: "calc(100vw - 16px)",
515
- cursor: "default",
516
- }}
517
- />
518
- )}
519
- {toastActive && !panel?.open && (
520
- <div
521
- ref={toastHostRef}
522
- className="ck-actionbar-toast"
523
- role="status"
524
- style={{
525
- position: "absolute",
526
- bottom: "calc(100% + 8px)",
527
- left: "50%",
528
- transform: "translateX(-50%)",
529
- whiteSpace: "nowrap",
530
- }}
531
- />
532
- )}
533
- {/* Left-edge button. Phone = collapse; desktop = drag grip. */}
534
- {isPhone && collapsibleOnPhone ? (
535
- <button
536
- type="button"
537
- onClick={() => setCollapsed(true)}
538
- aria-label="Collapse action bar"
539
- title="Collapse"
540
- style={leftEdgeButtonStyle}
541
- >
542
- <svg
543
- width="14"
544
- height="14"
545
- viewBox="0 0 24 24"
546
- fill="none"
547
- stroke="currentColor"
548
- strokeWidth="1.8"
549
- strokeLinecap="round"
550
- strokeLinejoin="round"
551
- aria-hidden
552
- >
553
- <polyline points="15 18 9 12 15 6" />
554
- </svg>
555
- </button>
556
- ) : draggable ? (
557
- <button
558
- type="button"
559
- onMouseDown={onGripDown}
560
- onDoubleClick={onGripDoubleClick}
561
- aria-label="Drag action bar (double-click to reset)"
562
- title="Drag · double-click to reset"
563
- style={{ ...leftEdgeButtonStyle, cursor: "grab" }}
564
- >
565
- <svg width="10" height="14" viewBox="0 0 10 14" fill="currentColor" aria-hidden>
566
- <circle cx="2" cy="2" r="1" />
567
- <circle cx="2" cy="7" r="1" />
568
- <circle cx="2" cy="12" r="1" />
569
- <circle cx="8" cy="2" r="1" />
570
- <circle cx="8" cy="7" r="1" />
571
- <circle cx="8" cy="12" r="1" />
572
- </svg>
573
- </button>
574
- ) : null}
575
-
576
- {/* Admin-mode toggle. Only renders when at least one registered
577
- item has adminOnly=true. Borderless, transparent bg — same
578
- visual weight as the drag grip and the item icons around
579
- it. State polarity lives on the glyph itself: outline shield
580
- (off) → filled shield (on). No background pill, no whole-bar
581
- tint — the icon is the whole affordance. */}
582
- {hasAdminItems && (
583
- <button
584
- type="button"
585
- onClick={toggleAdminMode}
586
- aria-label={adminMode ? "Exit admin mode" : "Enter admin mode"}
587
- aria-pressed={adminMode}
588
- title={adminMode ? "Admin mode · click to exit" : "Enter admin mode"}
589
- data-ck-actionbar-admin-toggle
590
- data-active={adminMode ? "1" : "0"}
591
- style={{
592
- display: "inline-flex",
593
- alignItems: "center",
594
- justifyContent: "center",
595
- width: 28,
596
- height: 28,
597
- padding: 0,
598
- marginLeft: 2,
599
- marginRight: 2,
600
- borderRadius: 999,
601
- background: "transparent",
602
- color: adminMode
603
- ? "var(--ck-accent, #4f46e5)"
604
- : "var(--ck-text-secondary, #666)",
605
- border: "none",
606
- cursor: "pointer",
607
- transition: "color 200ms var(--ck-ease, ease)",
608
- }}
609
- >
610
- <svg
611
- width="16"
612
- height="16"
613
- viewBox="0 0 24 24"
614
- // Outline when off, filled when on. Stroke stays on in
615
- // both states so the silhouette is identical — only the
616
- // interior fills — which reads as "same shape, different
617
- // state" instead of two distinct glyphs.
618
- fill={adminMode ? "currentColor" : "none"}
619
- stroke="currentColor"
620
- strokeWidth="1.8"
621
- strokeLinecap="round"
622
- strokeLinejoin="round"
623
- aria-hidden
624
- >
625
- <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
626
- </svg>
627
- </button>
628
- )}
629
-
630
- {/* Balancing leading spacer — only present when the right side
631
- holds ONLY a status dot (no trailing items). With a left
632
- and right spacer of equal flex weight, the leading items
633
- centre between the grip and the status dot. Without this,
634
- a solo leading item (e.g. just "Theme · Light") looks
635
- left-anchored next to the grip with a visible gap before
636
- the dot, which reads as unbalanced. When trailing items ARE
637
- present, they take that role on the right and the leading
638
- group goes back to its natural left-anchored packing. */}
639
- {leadingEntries.length > 0 && trailingEntries.length === 0 && statusDot && (
640
- <span
641
- aria-hidden
642
- data-ck-actionbar-spacer="leading"
643
- style={{ flex: "1 1 auto", minWidth: 0 }}
644
- />
645
- )}
646
-
647
- {/* Leading entries — flat items + group heads with disclosure regions. */}
648
- {leadingEntries.map(renderEntry)}
649
-
650
- {/* Spacer pushes whatever is right-aligned (trailing entries +/or
651
- the bar-intrinsic status dot) to the right edge. Only rendered
652
- when there IS something on the right, so the bar still hugs
653
- its content tightly when neither is in use. */}
654
- {(trailingEntries.length > 0 || statusDot) && (
655
- <span
656
- aria-hidden
657
- data-ck-actionbar-spacer
658
- style={{ flex: "1 1 auto", minWidth: 0 }}
659
- />
660
- )}
661
-
662
- {/* Trailing entries — pin to the right edge regardless of
663
- registration order. Same rendering rules as leading. */}
664
- {trailingEntries.map(renderEntry)}
665
-
666
- {/* Status dot — bar-intrinsic chrome at the right edge,
667
- mirroring the left-edge drag grip. Consumer-controlled via
668
- `useActionBarStatusDot()`. Visual: a small coloured dot in
669
- a 28×BAR_HEIGHT slot. Renders as a button when an onClick
670
- is provided; otherwise a plain span (no interactive
671
- affordances). */}
672
- {statusDot &&
673
- (statusDot.onClick ? (
674
- <button
675
- type="button"
676
- onClick={statusDot.onClick}
677
- aria-label={statusDot.title ?? "Status"}
678
- title={statusDot.title ?? "Status"}
679
- data-ck-actionbar-status-dot
680
- style={rightEdgeButtonStyle}
681
- >
682
- <StatusDotGlyph color={statusDot.color} pulse={statusDot.pulse} />
683
- </button>
684
- ) : (
685
- <span
686
- aria-label={statusDot.title ?? "Status"}
687
- title={statusDot.title ?? "Status"}
688
- data-ck-actionbar-status-dot
689
- style={rightEdgeButtonStyle}
690
- >
691
- <StatusDotGlyph color={statusDot.color} pulse={statusDot.pulse} />
692
- </span>
693
- ))}
694
- </div>
695
- );
696
-
697
- function renderEntry(entry: BuildEntry): ReactNode {
698
- // Admin-only entries play a slide-in animation on mount so the
699
- // reveal doesn't feel like items just popped in. Groups that
700
- // contain any adminOnly items also get wrapped so the whole
701
- // disclosure slides in together.
702
- const isAdmin =
703
- entry.item?.adminOnly === true ||
704
- entry.groupItems?.some((it) => it.adminOnly === true) === true;
705
- let node: ReactNode;
706
- if (entry.kind === "flat" && entry.item) {
707
- node = (
708
- <ActionBarButton
709
- key={entry.expansionKey}
710
- icon={entry.item.icon}
711
- label={entry.item.label}
712
- title={entry.item.title}
713
- active={entry.item.active}
714
- disabled={entry.item.disabled}
715
- variant={entry.item.variant}
716
- hint={entry.item.hint}
717
- onClick={entry.item.onClick}
718
- />
719
- );
720
- } else {
721
- const cat = entry.category!;
722
- const catDef = categories[cat];
723
- const hasActiveChild = entry.groupItems!.some((it) => it.active);
724
- const isOpen = expandedKey === entry.expansionKey;
725
- node = (
726
- <ActionBarMenuGroup
727
- key={entry.expansionKey}
728
- label={catDef?.label ?? cat}
729
- icon={catDef?.icon}
730
- hasActiveChild={hasActiveChild}
731
- isOpen={isOpen}
732
- onToggle={() => setExpandedKey(isOpen ? null : entry.expansionKey)}
733
- items={entry.groupItems!}
734
- onItemClicked={(it) => {
735
- it.onClick();
736
- if (!it.keepGroupOpenOnClick) {
737
- setExpandedKey(null);
738
- }
739
- }}
740
- />
741
- );
742
- }
743
- if (!isAdmin) return node;
744
- // Wrapper carries the spring-in animation via keyframes injected
745
- // at module load (see bottom of file). Key includes adminMode so
746
- // React remounts the wrapper each time it flips, replaying the
747
- // animation cleanly.
748
- return (
749
- <span
750
- key={`${entry.expansionKey}-admin`}
751
- className="ck-actionbar-admin-item"
752
- style={{ display: "inline-flex" }}
753
- >
754
- {node}
755
- </span>
756
- );
757
- }
758
- }
759
-
760
- const leftEdgeButtonStyle: CSSProperties = {
761
- width: 28,
762
- height: BAR_HEIGHT,
763
- padding: 0,
764
- display: "inline-flex",
765
- alignItems: "center",
766
- justifyContent: "center",
767
- border: "none",
768
- background: "transparent",
769
- color: "var(--ck-text-tertiary)",
770
- borderRadius: 0,
771
- };
772
-
773
- // Right-edge button — mirrors leftEdgeButtonStyle in dimensions, used
774
- // for the status dot. Cursor stays default for the non-interactive
775
- // span variant; the interactive (onClick) path is a real <button>
776
- // that picks up :hover / :focus styles from base.css.
777
- const rightEdgeButtonStyle: CSSProperties = {
778
- ...leftEdgeButtonStyle,
779
- };
780
-
781
- // StatusDotGlyph — the actual coloured circle, sized to read at par
782
- // with the grip's six-dot icon. Pulse via a shared keyframe injected
783
- // at module scope (one stylesheet entry; idempotent across mounts).
784
- interface StatusDotGlyphProps {
785
- color: string;
786
- pulse?: boolean | undefined;
787
- }
788
- function StatusDotGlyph({ color, pulse }: StatusDotGlyphProps) {
789
- return (
790
- <span
791
- aria-hidden
792
- style={{
793
- display: "inline-block",
794
- width: 8,
795
- height: 8,
796
- borderRadius: "50%",
797
- background: color,
798
- animation: pulse ? "ck-actionbar-status-pulse 1.4s ease-in-out infinite" : undefined,
799
- }}
800
- />
801
- );
802
- }
803
-
804
- // One global stylesheet entry for the pulse keyframe + admin-item
805
- // entrance animation. Injected once on first import. Idempotent —
806
- // re-imports check by id.
807
- if (typeof document !== "undefined") {
808
- const STYLE_ID = "ck-actionbar-status-pulse";
809
- if (!document.getElementById(STYLE_ID)) {
810
- const el = document.createElement("style");
811
- el.id = STYLE_ID;
812
- el.textContent = `
813
- @keyframes ck-actionbar-status-pulse {
814
- 0%, 100% { opacity: 1; }
815
- 50% { opacity: 0.45; }
816
- }
817
- @keyframes ck-actionbar-admin-enter {
818
- 0% {
819
- opacity: 0;
820
- transform: translateX(-6px) scale(0.92);
821
- }
822
- 60% {
823
- opacity: 1;
824
- }
825
- 100% {
826
- opacity: 1;
827
- transform: translateX(0) scale(1);
828
- }
829
- }
830
- .ck-actionbar-admin-item {
831
- animation: ck-actionbar-admin-enter 260ms cubic-bezier(0.34, 1.56, 0.64, 1) both;
832
- transform-origin: left center;
833
- }
834
- `;
835
- document.head.appendChild(el);
836
- }
837
- }