@cosxai/ui 0.8.4 → 0.10.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosxai/ui",
3
- "version": "0.8.4",
3
+ "version": "0.10.0",
4
4
  "description": "COSX design system — React 19 component primitives shared across product-meta and other consumers",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -78,6 +78,25 @@ function loadCollapsed(storageKey: string): boolean {
78
78
  }
79
79
  }
80
80
 
81
+ function loadAdminMode(storageKey: string): boolean {
82
+ if (typeof window === "undefined") return false;
83
+ try {
84
+ return window.localStorage.getItem(`${storageKey}:admin`) === "1";
85
+ } catch {
86
+ return false;
87
+ }
88
+ }
89
+
90
+ function persistAdminMode(storageKey: string, on: boolean) {
91
+ if (typeof window === "undefined") return;
92
+ try {
93
+ if (on) window.localStorage.setItem(`${storageKey}:admin`, "1");
94
+ else window.localStorage.removeItem(`${storageKey}:admin`);
95
+ } catch {
96
+ /* ignore */
97
+ }
98
+ }
99
+
81
100
  interface BuildEntry {
82
101
  kind: "flat" | "group";
83
102
  expansionKey: string;
@@ -175,6 +194,26 @@ export function ActionBar({
175
194
  return () => window.removeEventListener("resize", reclamp);
176
195
  }, []);
177
196
 
197
+ // ----- Admin mode toggle (any registered item with adminOnly=true) -----
198
+ // Admin items stay hidden by default; a small toggle button (rendered
199
+ // between the grip and the first item) flips this to reveal them.
200
+ // Persisted per storageKey so admin users stay in the mode they
201
+ // last chose across route navigations. When no consumer registers
202
+ // admin items, the toggle button doesn't render.
203
+ const [adminMode, setAdminMode] = useState<boolean>(() => loadAdminMode(storageKey));
204
+ const toggleAdminMode = useCallback(() => {
205
+ setAdminMode((prev) => {
206
+ const next = !prev;
207
+ persistAdminMode(storageKey, next);
208
+ return next;
209
+ });
210
+ }, [storageKey]);
211
+ const hasAdminItems = useMemo(() => items.some((it) => it.adminOnly === true), [items]);
212
+ const visibleItems = useMemo(
213
+ () => (adminMode ? items : items.filter((it) => it.adminOnly !== true)),
214
+ [items, adminMode],
215
+ );
216
+
178
217
  // ----- Collapsed state (phone only) -----
179
218
  const [collapsed, setCollapsed] = useState<boolean>(() => loadCollapsed(storageKey));
180
219
  useEffect(() => {
@@ -241,14 +280,14 @@ export function ActionBar({
241
280
  const { leadingEntries, trailingEntries } = useMemo(() => {
242
281
  const leading: ActionBarItem[] = [];
243
282
  const trailing: ActionBarItem[] = [];
244
- for (const it of items) {
283
+ for (const it of visibleItems) {
245
284
  (it.slot === "trailing" ? trailing : leading).push(it);
246
285
  }
247
286
  return {
248
287
  leadingEntries: buildEntries(leading),
249
288
  trailingEntries: buildEntries(trailing),
250
289
  };
251
- }, [items]);
290
+ }, [visibleItems]);
252
291
  const entries = useMemo(
253
292
  () => [...leadingEntries, ...trailingEntries],
254
293
  [leadingEntries, trailingEntries],
@@ -359,12 +398,21 @@ export function ActionBar({
359
398
  alignItems: "center",
360
399
  gap: 4,
361
400
  zIndex: 80,
362
- background: "var(--ck-bg-surface)",
363
- border: "1px solid var(--ck-border-subtle)",
401
+ // Subtle accent tint when admin mode is active so the bar reads
402
+ // as "elevated privileges on" without a full colour swap.
403
+ background: adminMode
404
+ ? "color-mix(in oklab, var(--ck-accent, #4f46e5) 8%, var(--ck-bg-surface))"
405
+ : "var(--ck-bg-surface)",
406
+ border: `1px solid ${
407
+ adminMode
408
+ ? "var(--ck-accent, #4f46e5)"
409
+ : "var(--ck-border-subtle)"
410
+ }`,
364
411
  borderRadius: 999,
365
412
  boxShadow: "var(--ck-shadow-3)",
366
413
  fontFamily: "var(--ck-font-sans)",
367
414
  color: "var(--ck-text-primary)",
415
+ transition: "background 180ms ease-out, border-color 180ms ease-out",
368
416
  ...style,
369
417
  }}
370
418
  >
@@ -411,6 +459,57 @@ export function ActionBar({
411
459
  </button>
412
460
  ) : null}
413
461
 
462
+ {/* Admin-mode toggle. Only renders when at least one registered
463
+ item has adminOnly=true. When active: filled accent circle;
464
+ when inactive: outlined muted circle. Uses a small shield
465
+ glyph so the affordance reads as "elevated privileges" at a
466
+ glance, distinct from a normal action button. */}
467
+ {hasAdminItems && (
468
+ <button
469
+ type="button"
470
+ onClick={toggleAdminMode}
471
+ aria-label={adminMode ? "Exit admin mode" : "Enter admin mode"}
472
+ aria-pressed={adminMode}
473
+ title={adminMode ? "Admin mode · click to exit" : "Enter admin mode"}
474
+ data-ck-actionbar-admin-toggle
475
+ data-active={adminMode ? "1" : "0"}
476
+ style={{
477
+ display: "inline-flex",
478
+ alignItems: "center",
479
+ justifyContent: "center",
480
+ width: 28,
481
+ height: 28,
482
+ padding: 0,
483
+ marginLeft: 2,
484
+ marginRight: 2,
485
+ borderRadius: 999,
486
+ background: adminMode ? "var(--ck-accent, #4f46e5)" : "transparent",
487
+ color: adminMode
488
+ ? "var(--ck-accent-fg, #fff)"
489
+ : "var(--ck-text-secondary, #666)",
490
+ border: adminMode
491
+ ? "1px solid var(--ck-accent, #4f46e5)"
492
+ : "1px solid var(--ck-border-subtle, #eee)",
493
+ cursor: "pointer",
494
+ transition: "background 160ms ease-out, color 160ms ease-out, border-color 160ms ease-out",
495
+ }}
496
+ >
497
+ <svg
498
+ width="14"
499
+ height="14"
500
+ viewBox="0 0 24 24"
501
+ fill="none"
502
+ stroke="currentColor"
503
+ strokeWidth="2"
504
+ strokeLinecap="round"
505
+ strokeLinejoin="round"
506
+ aria-hidden
507
+ >
508
+ <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
509
+ </svg>
510
+ </button>
511
+ )}
512
+
414
513
  {/* Balancing leading spacer — only present when the right side
415
514
  holds ONLY a status dot (no trailing items). With a left
416
515
  and right spacer of equal flex weight, the leading items
@@ -0,0 +1,171 @@
1
+ import { forwardRef, type CSSProperties } from "react";
2
+
3
+ // ActionBarModeHandle — the top-edge peek button that lets a user
4
+ // switch the ActionBar between two content sets ("modes"). Sits
5
+ // above the ActionBar's centred bottom position, showing only a
6
+ // slim 6px sliver at rest; grows on hover / focus to a full 28px
7
+ // tappable disc with a tooltip beneath the pointer.
8
+ //
9
+ // Compose with: `useActionBarMode` hook (or your own state) to swap
10
+ // the items registered via useActionBarItems when the handle fires
11
+ // onClick. Gate visibility on a permission bit (e.g.
12
+ // `document.capabilities.manage`) — passing `visible={false}` hides
13
+ // the handle entirely so non-privileged users never see the
14
+ // affordance.
15
+ //
16
+ // Visual: fixed positioning centred at the bottom of the viewport,
17
+ // stacked ABOVE the ActionBar (which lives at `bottom: 24px +
18
+ // tabbar-height`). The handle sits ~6px above the ActionBar's top
19
+ // edge at rest and animates upward on hover.
20
+ //
21
+ // Positioning contract: consumers may pass `bottom` to override the
22
+ // default 66px (24 gutter + 48 bar height - 6 overlap). Otherwise
23
+ // this matches the default ActionBar's centred bottom placement.
24
+ //
25
+ // Forwards ref to the underlying <button>.
26
+ export interface ActionBarModeHandleProps {
27
+ /**
28
+ * Called when the handle is clicked / activated. Consumer should
29
+ * flip the active mode state here (which drives
30
+ * useActionBarItems(sourceKey, items) with a different sourceKey).
31
+ */
32
+ onClick: () => void;
33
+ /**
34
+ * Human-readable label describing what the click will do
35
+ * ("Switch to manage mode"). Used as both aria-label and the
36
+ * hover tooltip.
37
+ */
38
+ label: string;
39
+ /**
40
+ * When false, the handle renders nothing. Consumers wire this to
41
+ * their capability check (e.g. `capabilities.manage === true`)
42
+ * so principals without the permission never see the affordance.
43
+ * Default: true.
44
+ */
45
+ visible?: boolean | undefined;
46
+ /**
47
+ * Optional bottom offset in px (default: matches the ActionBar's
48
+ * default centred position — 66px = 24 gutter + 48 bar height - 6
49
+ * overlap, plus the same tabbar + safe-area terms). Override when
50
+ * the ActionBar is docked non-default.
51
+ */
52
+ bottom?: number | string | undefined;
53
+ /**
54
+ * Extra className appended to the handle's root class. Handy for
55
+ * tests + one-off styling. Default className: `ck-actionbar-handle`.
56
+ */
57
+ className?: string | undefined;
58
+ /**
59
+ * Extra inline styles merged onto the root. Only use for one-off
60
+ * z-index / offset overrides; the visual language should stay
61
+ * consistent across surfaces.
62
+ */
63
+ style?: CSSProperties | undefined;
64
+ }
65
+
66
+ const HANDLE_REST_WIDTH = 44;
67
+ const HANDLE_REST_HEIGHT = 6;
68
+ const HANDLE_HOVER_WIDTH = 68;
69
+ const HANDLE_HOVER_HEIGHT = 24;
70
+ // Overlap the ActionBar's top edge by 4px so the handle looks
71
+ // tethered to the bar rather than floating above it.
72
+ const HANDLE_BOTTOM_DEFAULT = "calc(24px + 48px - 4px + var(--ck-tabbar-height, 0px) + env(safe-area-inset-bottom, 0px))";
73
+
74
+ export const ActionBarModeHandle = forwardRef<HTMLButtonElement, ActionBarModeHandleProps>(
75
+ function ActionBarModeHandle(
76
+ {
77
+ onClick,
78
+ label,
79
+ visible = true,
80
+ bottom,
81
+ className,
82
+ style,
83
+ },
84
+ ref,
85
+ ) {
86
+ if (!visible) return null;
87
+ const resolvedBottom =
88
+ typeof bottom === "number"
89
+ ? `${bottom}px`
90
+ : typeof bottom === "string"
91
+ ? bottom
92
+ : HANDLE_BOTTOM_DEFAULT;
93
+ return (
94
+ <button
95
+ ref={ref}
96
+ type="button"
97
+ onClick={onClick}
98
+ aria-label={label}
99
+ title={label}
100
+ className={`ck-actionbar-handle${className ? ` ${className}` : ""}`}
101
+ style={{
102
+ position: "fixed",
103
+ left: "50%",
104
+ bottom: resolvedBottom,
105
+ transform: "translateX(-50%)",
106
+ // Rest state: narrow slim strip at the top edge of the bar.
107
+ width: `var(--ck-actionbar-handle-w, ${HANDLE_REST_WIDTH}px)`,
108
+ height: `var(--ck-actionbar-handle-h, ${HANDLE_REST_HEIGHT}px)`,
109
+ padding: 0,
110
+ margin: 0,
111
+ border: 0,
112
+ borderRadius: 999,
113
+ background: "var(--ck-accent, #4f46e5)",
114
+ color: "var(--ck-accent-fg, #fff)",
115
+ fontSize: 11,
116
+ fontWeight: 500,
117
+ letterSpacing: 0.2,
118
+ lineHeight: 1,
119
+ overflow: "hidden",
120
+ whiteSpace: "nowrap",
121
+ cursor: "pointer",
122
+ // Sit above the ActionBar (z-index 80 in ActionBar.tsx).
123
+ zIndex: 85,
124
+ boxShadow: "0 2px 8px rgba(0,0,0,0.14)",
125
+ transition:
126
+ "width 220ms cubic-bezier(0.34, 1.56, 0.64, 1)," +
127
+ "height 220ms cubic-bezier(0.34, 1.56, 0.64, 1)," +
128
+ "box-shadow 220ms ease-out",
129
+ display: "inline-flex",
130
+ alignItems: "center",
131
+ justifyContent: "center",
132
+ ...style,
133
+ }}
134
+ // Inline hover/focus expansion — pure CSS on the same element
135
+ // via CSSProperties gives us the animation without a global
136
+ // stylesheet dependency in the kit.
137
+ onMouseEnter={(e) => {
138
+ const el = e.currentTarget;
139
+ el.style.setProperty("--ck-actionbar-handle-w", `${HANDLE_HOVER_WIDTH}px`);
140
+ el.style.setProperty("--ck-actionbar-handle-h", `${HANDLE_HOVER_HEIGHT}px`);
141
+ el.style.boxShadow = "0 4px 14px rgba(0,0,0,0.22)";
142
+ }}
143
+ onMouseLeave={(e) => {
144
+ const el = e.currentTarget;
145
+ el.style.setProperty("--ck-actionbar-handle-w", `${HANDLE_REST_WIDTH}px`);
146
+ el.style.setProperty("--ck-actionbar-handle-h", `${HANDLE_REST_HEIGHT}px`);
147
+ el.style.boxShadow = "0 2px 8px rgba(0,0,0,0.14)";
148
+ }}
149
+ onFocus={(e) => {
150
+ const el = e.currentTarget;
151
+ el.style.setProperty("--ck-actionbar-handle-w", `${HANDLE_HOVER_WIDTH}px`);
152
+ el.style.setProperty("--ck-actionbar-handle-h", `${HANDLE_HOVER_HEIGHT}px`);
153
+ el.style.boxShadow = "0 4px 14px rgba(0,0,0,0.22)";
154
+ }}
155
+ onBlur={(e) => {
156
+ const el = e.currentTarget;
157
+ el.style.setProperty("--ck-actionbar-handle-w", `${HANDLE_REST_WIDTH}px`);
158
+ el.style.setProperty("--ck-actionbar-handle-h", `${HANDLE_REST_HEIGHT}px`);
159
+ el.style.boxShadow = "0 2px 8px rgba(0,0,0,0.14)";
160
+ }}
161
+ >
162
+ {/* Only visible when expanded — CSS `overflow: hidden` on the
163
+ button clips it at rest. Kept as visible text (not aria-
164
+ hidden) so screen readers can still hear it via title. */}
165
+ <span aria-hidden style={{ opacity: 0.95 }}>
166
+ {label}
167
+ </span>
168
+ </button>
169
+ );
170
+ },
171
+ );
@@ -6,6 +6,14 @@ export { ActionBarButton } from "./ActionBarButton";
6
6
  export type { ActionBarButtonProps } from "./ActionBarButton";
7
7
  export { useActionBarItems, useActionBarItemsContext } from "./useActionBarItems";
8
8
  export { useActionBarStatusDot } from "./useActionBarStatusDot";
9
+ export { ActionBarModeHandle } from "./ActionBarModeHandle";
10
+ export type { ActionBarModeHandleProps } from "./ActionBarModeHandle";
11
+ export { useActionBarMode } from "./useActionBarMode";
12
+ export type {
13
+ ActionBarModeConfig,
14
+ UseActionBarModeOptions,
15
+ UseActionBarModeResult,
16
+ } from "./useActionBarMode";
9
17
  export type {
10
18
  ActionBarItem,
11
19
  ActionBarItemVariant,
@@ -47,6 +47,19 @@ export interface ActionBarItem {
47
47
  // a flex spacer so they pin to the right edge regardless of
48
48
  // registration order — system status indicators belong here.
49
49
  slot?: ActionBarItemSlot;
50
+ // Marks this item as admin-only. Hidden until the ActionBar's
51
+ // admin-mode toggle is switched on. The toggle button auto-appears
52
+ // on the bar whenever at least one registered item has adminOnly=
53
+ // true; when no consumer registers such items, the toggle stays
54
+ // hidden and the bar looks exactly as it always did.
55
+ //
56
+ // Use for elevated-privilege actions that would confuse regular
57
+ // users (version history, retry render, delete forever, etc.).
58
+ // Consumer decides eligibility upstream — typically by only
59
+ // registering the item when the caller has `capabilities.manage`
60
+ // (or an equivalent gate) === true. The kit does not check
61
+ // permissions; it only handles the toggle UX.
62
+ adminOnly?: boolean;
50
63
  }
51
64
 
52
65
  // Category definition — declared at the provider level. Drives
@@ -0,0 +1,144 @@
1
+ import { useCallback, useEffect, useMemo, useState } from "react";
2
+ import { useActionBarItems } from "./useActionBarItems";
3
+ import type { ActionBarItem } from "./types";
4
+
5
+ // useActionBarMode — one hook that manages a small set of named
6
+ // "modes" (each with its own item list) + registers the active mode's
7
+ // items into the ActionBar registry. Consumer combines this with
8
+ // <ActionBarModeHandle> to give privileged users a peek-out affordance
9
+ // that flips between the modes.
10
+ //
11
+ // Design intent (M4.5 block_doc surface):
12
+ // - Default mode: standard viewer actions (comment / download / share)
13
+ // - Manage mode: admin-only shortcuts (preview draft / manage share /
14
+ // view activity), gated behind `capabilities.manage === true`
15
+ // - Draft mode (optional third): appears once the user enters draft
16
+ // preview; distinct action set (publish / discard / raw JSON /
17
+ // return)
18
+ //
19
+ // The mode name is the ActionBar's source key, so switching modes
20
+ // unregisters the previous set and registers the new set atomically
21
+ // (useActionBarItems handles the cleanup).
22
+ //
23
+ // State persistence: pass `storageKey` for localStorage-backed
24
+ // stickiness across reloads. Nil-safe when window is unavailable
25
+ // (SSR).
26
+
27
+ export type ActionBarModeConfig<K extends string> = {
28
+ [key in K]: ActionBarItem[];
29
+ };
30
+
31
+ export interface UseActionBarModeOptions<K extends string> {
32
+ /**
33
+ * Map of mode keys → items. Each mode key becomes the source key
34
+ * passed to useActionBarItems, so it must be a stable string
35
+ * (e.g. "viewer", "manage") — mode registrations will not
36
+ * "cross-contaminate" the item set of another mode.
37
+ */
38
+ modes: ActionBarModeConfig<K>;
39
+ /**
40
+ * Which mode to start in. Overridden by localStorage when
41
+ * `storageKey` is set AND localStorage has a valid value.
42
+ */
43
+ defaultMode: K;
44
+ /**
45
+ * Optional localStorage key. When set, the current mode is
46
+ * persisted + rehydrated across reloads.
47
+ */
48
+ storageKey?: string | undefined;
49
+ }
50
+
51
+ export interface UseActionBarModeResult<K extends string> {
52
+ /** The currently active mode key. */
53
+ mode: K;
54
+ /** Set the mode explicitly (typically wired to the mode Handle). */
55
+ setMode: (next: K) => void;
56
+ /**
57
+ * Toggle between the FIRST two modes in the config's iteration
58
+ * order. Convenience for the common two-mode case; a
59
+ * three-mode consumer uses setMode directly.
60
+ */
61
+ toggle: () => void;
62
+ /**
63
+ * All configured mode keys in registration order — useful for
64
+ * building a cycle-through-modes button.
65
+ */
66
+ allModes: readonly K[];
67
+ }
68
+
69
+ function loadPersistedMode<K extends string>(
70
+ storageKey: string,
71
+ allModes: readonly K[],
72
+ ): K | null {
73
+ if (typeof window === "undefined") return null;
74
+ try {
75
+ const raw = window.localStorage.getItem(storageKey);
76
+ if (raw && (allModes as readonly string[]).includes(raw)) {
77
+ return raw as K;
78
+ }
79
+ } catch {
80
+ /* ignore */
81
+ }
82
+ return null;
83
+ }
84
+
85
+ function persistMode(storageKey: string, mode: string) {
86
+ if (typeof window === "undefined") return;
87
+ try {
88
+ window.localStorage.setItem(storageKey, mode);
89
+ } catch {
90
+ /* ignore */
91
+ }
92
+ }
93
+
94
+ export function useActionBarMode<K extends string>(
95
+ opts: UseActionBarModeOptions<K>,
96
+ ): UseActionBarModeResult<K> {
97
+ const { modes, defaultMode, storageKey } = opts;
98
+ const allModes = useMemo(() => Object.keys(modes) as K[], [modes]);
99
+ const [mode, setModeState] = useState<K>(() => {
100
+ if (storageKey) {
101
+ const persisted = loadPersistedMode<K>(storageKey, allModes);
102
+ if (persisted) return persisted;
103
+ }
104
+ return defaultMode;
105
+ });
106
+
107
+ // If the modes config changes (dev / hot reload) so the current
108
+ // mode key is no longer valid, drop back to the default.
109
+ useEffect(() => {
110
+ if (!(allModes as readonly string[]).includes(mode)) {
111
+ setModeState(defaultMode);
112
+ }
113
+ }, [mode, allModes, defaultMode]);
114
+
115
+ const setMode = useCallback(
116
+ (next: K) => {
117
+ setModeState(next);
118
+ if (storageKey) persistMode(storageKey, next);
119
+ },
120
+ [storageKey],
121
+ );
122
+
123
+ const toggle = useCallback(() => {
124
+ if (allModes.length < 2) return;
125
+ const idx = allModes.indexOf(mode);
126
+ // Cycle to the NEXT mode; wrap-around handles both the "toggle
127
+ // between 2" and "cycle through 3+" cases.
128
+ const nextIdx = (idx + 1) % allModes.length;
129
+ const next = allModes[nextIdx];
130
+ if (next !== undefined) {
131
+ setMode(next);
132
+ }
133
+ }, [allModes, mode, setMode]);
134
+
135
+ // Register the active mode's items with the ActionBar. Uses the
136
+ // mode key as the source key, so switching modes atomically
137
+ // unregisters the previous set. Items themselves must be stable
138
+ // (useMemo'd) — the ActionBar deduplicates by reference, so passing
139
+ // `modes[mode]` re-uses the caller's memoized array.
140
+ const activeItems = modes[mode];
141
+ useActionBarItems(mode, activeItems);
142
+
143
+ return { mode, setMode, toggle, allModes };
144
+ }