@cosxai/ui 0.10.4 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosxai/ui",
3
- "version": "0.10.4",
3
+ "version": "0.11.1",
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",
@@ -415,23 +415,18 @@ export function ActionBar({
415
415
  alignItems: "center",
416
416
  gap: 4,
417
417
  zIndex: 80,
418
- // Subtle accent tint when admin mode is active so the bar reads
419
- // as "elevated privileges on" without a full colour swap.
420
- background: adminMode
421
- ? "color-mix(in oklab, var(--ck-accent, #4f46e5) 8%, var(--ck-bg-surface))"
422
- : "var(--ck-bg-surface)",
423
- border: `1px solid ${
424
- adminMode
425
- ? "var(--ck-accent, #4f46e5)"
426
- : "var(--ck-border-subtle)"
427
- }`,
418
+ // Bar chrome stays constant across admin/viewer modes the
419
+ // whole-bar tint + accent border in earlier versions competed
420
+ // with the shield toggle for signal and produced awkward hue
421
+ // shifts under the item hover overlay (cool grey on tinted
422
+ // warm base read as green). Mode state lives entirely on the
423
+ // shield now: outline glyph = off, filled glyph = on.
424
+ background: "var(--ck-bg-surface)",
425
+ border: "1px solid var(--ck-border-subtle)",
428
426
  borderRadius: 999,
429
427
  boxShadow: "var(--ck-shadow-3)",
430
428
  fontFamily: "var(--ck-font-sans)",
431
429
  color: "var(--ck-text-primary)",
432
- transition:
433
- "background 260ms cubic-bezier(0.34, 1.56, 0.64, 1)," +
434
- "border-color 260ms cubic-bezier(0.34, 1.56, 0.64, 1)",
435
430
  ...style,
436
431
  }}
437
432
  >
@@ -481,8 +476,9 @@ export function ActionBar({
481
476
  {/* Admin-mode toggle. Only renders when at least one registered
482
477
  item has adminOnly=true. Borderless, transparent bg — same
483
478
  visual weight as the drag grip and the item icons around
484
- it. Muted stroke by default, accent stroke + subtle scale
485
- when active. Icon: shield (elevated privileges). */}
479
+ it. State polarity lives on the glyph itself: outline shield
480
+ (off) filled shield (on). No background pill, no whole-bar
481
+ tint — the icon is the whole affordance. */}
486
482
  {hasAdminItems && (
487
483
  <button
488
484
  type="button"
@@ -508,20 +504,18 @@ export function ActionBar({
508
504
  : "var(--ck-text-secondary, #666)",
509
505
  border: "none",
510
506
  cursor: "pointer",
511
- transform: adminMode ? "scale(1.06)" : "scale(1)",
512
- // Spring-ish easing so the tint + scale feel connected
513
- // rather than mechanical — matches ck-actionbar-enter's
514
- // curve for family consistency.
515
- transition:
516
- "color 260ms cubic-bezier(0.34, 1.56, 0.64, 1)," +
517
- "transform 260ms cubic-bezier(0.34, 1.56, 0.64, 1)",
507
+ transition: "color 200ms var(--ck-ease, ease)",
518
508
  }}
519
509
  >
520
510
  <svg
521
511
  width="16"
522
512
  height="16"
523
513
  viewBox="0 0 24 24"
524
- fill="none"
514
+ // Outline when off, filled when on. Stroke stays on in
515
+ // both states so the silhouette is identical — only the
516
+ // interior fills — which reads as "same shape, different
517
+ // state" instead of two distinct glyphs.
518
+ fill={adminMode ? "currentColor" : "none"}
525
519
  stroke="currentColor"
526
520
  strokeWidth="1.8"
527
521
  strokeLinecap="round"
@@ -0,0 +1,316 @@
1
+ import { useEffect, useRef, type CSSProperties, type ReactNode } from 'react';
2
+ import { createPortal } from 'react-dom';
3
+
4
+ // SidePanel — right-docked overlay-less panel for admin surfaces
5
+ // (doc editors, activity feeds, share managers, …). Slides in from
6
+ // the right and pushes the main content left via
7
+ // `--ck-sidepanel-width`, which Shell / topbar / scroll containers
8
+ // can read to inset themselves. No backdrop — the reader keeps
9
+ // interacting with the doc while the panel is open, mirroring
10
+ // Notion / Linear / Slack right-panel UX.
11
+ //
12
+ // Distinct from `<RightSidebarPanel>` (this bucket's other right-
13
+ // side surface):
14
+ // - RightSidebarPanel = scoped floating card (bordered, offset
15
+ // from viewport edges); good for lightweight ephemeral lists.
16
+ // - SidePanel = full-height slide-in that reshapes layout;
17
+ // good for editors + long panels the user will linger in.
18
+ //
19
+ // Companion CSS variable:
20
+ // `--ck-sidepanel-width` — stamped on `:root` while `open` is true.
21
+ // Layout consumers (Shell, main scroll containers, watermarks)
22
+ // should sum this into their right inset:
23
+ // right: calc(var(--ck-rightrail-width, 0px)
24
+ // + var(--ck-sidepanel-width, 0px));
25
+ // The variable is DELIBERATELY separate from `--ck-rightrail-width`
26
+ // so the SidePanel can push content-region chrome without
27
+ // narrowing the topbar (workspace switcher, avatar, notification
28
+ // pills belong to the topbar and shouldn't slide around when a
29
+ // doc-scoped panel opens).
30
+ //
31
+ // Anatomy:
32
+ //
33
+ // ┌─ header (sticky) ─────────────┐
34
+ // │ Title × │
35
+ // ├───────────────────────────────┤
36
+ // │ │
37
+ // │ body (scrollable + flex col) │
38
+ // │ │
39
+ // ├─ footer (sticky, optional) ───┤
40
+ // │ Cancel Save │
41
+ // └───────────────────────────────┘
42
+ //
43
+ // Non-goals:
44
+ // - Multiple concurrent panels. Only one at a time — a second would
45
+ // double the marginRight the shell reads and the second panel would
46
+ // render off-viewport. Callers coordinate open state.
47
+ // - Focus trap. The main content stays interactive; trapping focus
48
+ // here would break the "keep working while the panel is open"
49
+ // promise. ESC still closes.
50
+ // - Resize handle. Fixed width per open; iterate if callers need it.
51
+ //
52
+ // Phone: renders the same panel but at `width: min(100vw, width)`
53
+ // so it simply covers the viewport. The push-left effect degrades
54
+ // to a full-screen overlay — acceptable since these surfaces are
55
+ // desktop-first anyway.
56
+ //
57
+ // Body is a flex column so children can opt into `flex: 1` +
58
+ // `minHeight: 0` and grow to fill the panel (e.g. a textarea that
59
+ // should reach the footer). `overflowY: auto` stays as a safety
60
+ // net for content that genuinely exceeds the panel height.
61
+
62
+ export interface SidePanelProps {
63
+ open: boolean;
64
+ onClose: () => void;
65
+ /** Text shown in the header. Icons / node also accepted. */
66
+ title: ReactNode;
67
+ /**
68
+ * Panel width in CSS px. Default 480 — the sweet spot for an
69
+ * editor with a couple of fields. Callers override for wider
70
+ * content (e.g. Activity timeline).
71
+ */
72
+ width?: number | undefined;
73
+ /**
74
+ * Optional sticky footer — usually action buttons (Cancel / Save).
75
+ * Omit for read-only surfaces (Activity feed, share reader).
76
+ */
77
+ footer?: ReactNode | undefined;
78
+ /**
79
+ * Top inset. Defaults to
80
+ * `calc(var(--ck-topbar-height, 64px) + env(safe-area-inset-top, 0px))`
81
+ * so the panel docks under the app's topbar. Consumers with a
82
+ * different topbar height stamp `--ck-topbar-height` on `:root`
83
+ * OR override this prop directly.
84
+ */
85
+ topOffset?: string | undefined;
86
+ /**
87
+ * Bottom inset. Defaults to `var(--ck-tabbar-height, 0px)` so
88
+ * the panel doesn't cover the mobile tab bar. Override for
89
+ * consumers with different bottom chrome.
90
+ */
91
+ bottomOffset?: string | undefined;
92
+ /**
93
+ * z-index. Default 25 — sits above main content, below the
94
+ * standard Modal (100) and typical fixed topbar (30).
95
+ */
96
+ zIndex?: number | undefined;
97
+ children: ReactNode;
98
+ }
99
+
100
+ const DEFAULT_WIDTH = 480;
101
+ const CSS_VAR_SIDEPANEL_WIDTH = '--ck-sidepanel-width';
102
+ const DEFAULT_TOP_OFFSET =
103
+ 'calc(var(--ck-topbar-height, 64px) + env(safe-area-inset-top, 0px))';
104
+ const DEFAULT_BOTTOM_OFFSET = 'var(--ck-tabbar-height, 0px)';
105
+ const DEFAULT_Z_INDEX = 25;
106
+
107
+ export function SidePanel({
108
+ open,
109
+ onClose,
110
+ title,
111
+ width,
112
+ footer,
113
+ topOffset,
114
+ bottomOffset,
115
+ zIndex,
116
+ children,
117
+ }: SidePanelProps) {
118
+ const panelWidth = width ?? DEFAULT_WIDTH;
119
+ const resolvedTop = topOffset ?? DEFAULT_TOP_OFFSET;
120
+ const resolvedBottom = bottomOffset ?? DEFAULT_BOTTOM_OFFSET;
121
+ const resolvedZ = zIndex ?? DEFAULT_Z_INDEX;
122
+
123
+ // Stamp / clear `--ck-sidepanel-width` on document root while open.
124
+ // Consumers that want their layout to shift left when a panel opens
125
+ // read this var and inset their right edge accordingly. The kit
126
+ // itself doesn't force any layout to read it — it's an opt-in
127
+ // contract between the panel and layout consumers.
128
+ useEffect(() => {
129
+ if (!open) return;
130
+ const root = document.documentElement;
131
+ const prev = root.style.getPropertyValue(CSS_VAR_SIDEPANEL_WIDTH);
132
+ root.style.setProperty(CSS_VAR_SIDEPANEL_WIDTH, `${panelWidth}px`);
133
+ return () => {
134
+ // Restore whatever value was there before us — could be empty
135
+ // (fall through to token default) or a previous panel's width
136
+ // if two panels overlap mid-transition.
137
+ if (prev) {
138
+ root.style.setProperty(CSS_VAR_SIDEPANEL_WIDTH, prev);
139
+ } else {
140
+ root.style.removeProperty(CSS_VAR_SIDEPANEL_WIDTH);
141
+ }
142
+ };
143
+ }, [open, panelWidth]);
144
+
145
+ // ESC to close. Global listener so users don't have to focus into
146
+ // the panel first. Only mounted while open so it doesn't eat ESC
147
+ // on the main surface.
148
+ useEffect(() => {
149
+ if (!open) return;
150
+ const onKey = (e: KeyboardEvent) => {
151
+ if (e.key === 'Escape') {
152
+ e.stopPropagation();
153
+ onClose();
154
+ }
155
+ };
156
+ window.addEventListener('keydown', onKey);
157
+ return () => window.removeEventListener('keydown', onKey);
158
+ }, [open, onClose]);
159
+
160
+ // Focus the close button on open so keyboard users have a
161
+ // predictable landing target + can Tab into the body. Not a focus
162
+ // trap — Tab from the last body input walks back out into the
163
+ // main surface, which is the whole point of the pattern.
164
+ const closeButtonRef = useRef<HTMLButtonElement | null>(null);
165
+ useEffect(() => {
166
+ if (!open) return;
167
+ // Run after transition frame so the button is actually
168
+ // interactive by the time focus lands.
169
+ const raf = requestAnimationFrame(() => {
170
+ closeButtonRef.current?.focus();
171
+ });
172
+ return () => cancelAnimationFrame(raf);
173
+ }, [open]);
174
+
175
+ const containerStyle: CSSProperties = {
176
+ position: 'fixed',
177
+ top: resolvedTop,
178
+ right: 0,
179
+ bottom: resolvedBottom,
180
+ width: `min(100vw, ${panelWidth}px)`,
181
+ background: 'var(--ck-bg-surface, #fff)',
182
+ borderLeft: '1px solid var(--ck-border-subtle, #eee)',
183
+ boxShadow: open ? '-4px 0 20px rgba(0, 0, 0, 0.06)' : 'none',
184
+ transform: open ? 'translateX(0)' : 'translateX(100%)',
185
+ transition:
186
+ 'transform var(--ck-dur-fast, 180ms) var(--ck-ease, cubic-bezier(0.22, 1, 0.36, 1))',
187
+ display: 'flex',
188
+ flexDirection: 'column',
189
+ zIndex: resolvedZ,
190
+ // Prevent the closed panel from stealing pointer events on the
191
+ // main surface (translated off-screen but still in the DOM).
192
+ pointerEvents: open ? 'auto' : 'none',
193
+ };
194
+
195
+ // Portal to <body> so the panel escapes any `transform` /
196
+ // `will-change: transform` / `filter` / `contain: paint` ancestor
197
+ // — those establish a new containing block for `position: fixed`,
198
+ // meaning the panel would anchor to that ancestor instead of the
199
+ // viewport (e.g. inside a ZoomLayer it would land mid-page rather
200
+ // than on the right edge). The body has no such ancestor, so
201
+ // `right: 0` + `top: <offset>` genuinely means "the viewport
202
+ // corner".
203
+ //
204
+ // `inert` (React 19+) instead of `aria-hidden` for the closed
205
+ // state: aria-hidden triggers a runtime warning when a focused
206
+ // descendant exists (the close button focuses on open + keeps
207
+ // focus briefly after close), and `inert` is the modern correct
208
+ // API — removes the subtree from the tab sequence + AT tree +
209
+ // pointer targeting in one attribute.
210
+ return createPortal(
211
+ <aside
212
+ role="complementary"
213
+ inert={!open}
214
+ aria-label={typeof title === 'string' ? title : undefined}
215
+ style={containerStyle}
216
+ >
217
+ <header
218
+ style={{
219
+ display: 'flex',
220
+ alignItems: 'center',
221
+ justifyContent: 'space-between',
222
+ padding: '12px 16px',
223
+ borderBottom: '1px solid var(--ck-border-subtle, #eee)',
224
+ gap: 12,
225
+ flex: '0 0 auto',
226
+ }}
227
+ >
228
+ <div
229
+ style={{
230
+ font: '600 14px/1.4 var(--ck-font-sans)',
231
+ color: 'var(--ck-text-primary, #111)',
232
+ overflow: 'hidden',
233
+ textOverflow: 'ellipsis',
234
+ whiteSpace: 'nowrap',
235
+ }}
236
+ >
237
+ {title}
238
+ </div>
239
+ <button
240
+ ref={closeButtonRef}
241
+ type="button"
242
+ onClick={onClose}
243
+ aria-label="Close panel"
244
+ style={{
245
+ display: 'inline-flex',
246
+ alignItems: 'center',
247
+ justifyContent: 'center',
248
+ width: 28,
249
+ height: 28,
250
+ borderRadius: 6,
251
+ border: 'none',
252
+ background: 'transparent',
253
+ color: 'var(--ck-text-secondary, #666)',
254
+ cursor: 'pointer',
255
+ transition: 'background 120ms ease',
256
+ }}
257
+ onMouseEnter={(e) => {
258
+ e.currentTarget.style.background = 'var(--ck-bg-hover, #f4f4f5)';
259
+ }}
260
+ onMouseLeave={(e) => {
261
+ e.currentTarget.style.background = 'transparent';
262
+ }}
263
+ >
264
+ <CloseIcon />
265
+ </button>
266
+ </header>
267
+ <div
268
+ style={{
269
+ flex: 1,
270
+ minHeight: 0,
271
+ overflowY: 'auto',
272
+ padding: 16,
273
+ display: 'flex',
274
+ flexDirection: 'column',
275
+ }}
276
+ >
277
+ {children}
278
+ </div>
279
+ {footer ? (
280
+ <footer
281
+ style={{
282
+ display: 'flex',
283
+ justifyContent: 'flex-end',
284
+ alignItems: 'center',
285
+ gap: 8,
286
+ padding: '12px 16px',
287
+ borderTop: '1px solid var(--ck-border-subtle, #eee)',
288
+ flex: '0 0 auto',
289
+ }}
290
+ >
291
+ {footer}
292
+ </footer>
293
+ ) : null}
294
+ </aside>,
295
+ document.body,
296
+ );
297
+ }
298
+
299
+ function CloseIcon(): ReactNode {
300
+ return (
301
+ <svg
302
+ width="16"
303
+ height="16"
304
+ viewBox="0 0 24 24"
305
+ fill="none"
306
+ stroke="currentColor"
307
+ strokeWidth="2"
308
+ strokeLinecap="round"
309
+ strokeLinejoin="round"
310
+ aria-hidden="true"
311
+ >
312
+ <line x1="18" y1="6" x2="6" y2="18" />
313
+ <line x1="6" y1="6" x2="18" y2="18" />
314
+ </svg>
315
+ );
316
+ }
@@ -12,6 +12,8 @@ export { MobileTabBar } from "./MobileTabBar";
12
12
  export type { MobileTabBarProps, MobileTab } from "./MobileTabBar";
13
13
  export { RightSidebarPanel } from "./RightSidebarPanel";
14
14
  export type { RightSidebarPanelProps } from "./RightSidebarPanel";
15
+ export { SidePanel } from "./SidePanel";
16
+ export type { SidePanelProps } from "./SidePanel";
15
17
  export { StickyBanner } from "./StickyBanner";
16
18
  export type { StickyBannerProps } from "./StickyBanner";
17
19
  export { NavSection } from "./NavSection";