@cosxai/ui 0.3.0 → 0.3.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.3.0",
3
+ "version": "0.3.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",
@@ -146,7 +146,7 @@ export function ActionBar({
146
146
  if (!ctx) {
147
147
  throw new Error("<ActionBar> must be inside <ActionBarProvider>");
148
148
  }
149
- const { items, categories, expandedKey, setExpandedKey } = ctx;
149
+ const { items, categories, expandedKey, setExpandedKey, statusDot } = ctx;
150
150
  const vp = useViewport();
151
151
  const isPhone = vp.isPhone;
152
152
 
@@ -402,11 +402,11 @@ export function ActionBar({
402
402
  {/* Leading entries — flat items + group heads with disclosure regions. */}
403
403
  {leadingEntries.map(renderEntry)}
404
404
 
405
- {/* Spacer pushes trailing entries to the right edge of the bar.
406
- Only rendered when there's at least one trailing entry, so the
407
- bar still hugs its content tightly when no trailing slot is in
408
- use. */}
409
- {trailingEntries.length > 0 && (
405
+ {/* Spacer pushes whatever is right-aligned (trailing entries +/or
406
+ the bar-intrinsic status dot) to the right edge. Only rendered
407
+ when there IS something on the right, so the bar still hugs
408
+ its content tightly when neither is in use. */}
409
+ {(trailingEntries.length > 0 || statusDot) && (
410
410
  <span
411
411
  aria-hidden
412
412
  data-ck-actionbar-spacer
@@ -417,6 +417,35 @@ export function ActionBar({
417
417
  {/* Trailing entries — pin to the right edge regardless of
418
418
  registration order. Same rendering rules as leading. */}
419
419
  {trailingEntries.map(renderEntry)}
420
+
421
+ {/* Status dot — bar-intrinsic chrome at the right edge,
422
+ mirroring the left-edge drag grip. Consumer-controlled via
423
+ `useActionBarStatusDot()`. Visual: a small coloured dot in
424
+ a 28×BAR_HEIGHT slot. Renders as a button when an onClick
425
+ is provided; otherwise a plain span (no interactive
426
+ affordances). */}
427
+ {statusDot &&
428
+ (statusDot.onClick ? (
429
+ <button
430
+ type="button"
431
+ onClick={statusDot.onClick}
432
+ aria-label={statusDot.title ?? "Status"}
433
+ title={statusDot.title ?? "Status"}
434
+ data-ck-actionbar-status-dot
435
+ style={rightEdgeButtonStyle}
436
+ >
437
+ <StatusDotGlyph color={statusDot.color} pulse={statusDot.pulse} />
438
+ </button>
439
+ ) : (
440
+ <span
441
+ aria-label={statusDot.title ?? "Status"}
442
+ title={statusDot.title ?? "Status"}
443
+ data-ck-actionbar-status-dot
444
+ style={rightEdgeButtonStyle}
445
+ >
446
+ <StatusDotGlyph color={statusDot.color} pulse={statusDot.pulse} />
447
+ </span>
448
+ ))}
420
449
  </div>
421
450
  );
422
451
 
@@ -472,3 +501,49 @@ const leftEdgeButtonStyle: CSSProperties = {
472
501
  color: "var(--ck-text-tertiary)",
473
502
  borderRadius: 0,
474
503
  };
504
+
505
+ // Right-edge button — mirrors leftEdgeButtonStyle in dimensions, used
506
+ // for the status dot. Cursor stays default for the non-interactive
507
+ // span variant; the interactive (onClick) path is a real <button>
508
+ // that picks up :hover / :focus styles from base.css.
509
+ const rightEdgeButtonStyle: CSSProperties = {
510
+ ...leftEdgeButtonStyle,
511
+ };
512
+
513
+ // StatusDotGlyph — the actual coloured circle, sized to read at par
514
+ // with the grip's six-dot icon. Pulse via a shared keyframe injected
515
+ // at module scope (one stylesheet entry; idempotent across mounts).
516
+ interface StatusDotGlyphProps {
517
+ color: string;
518
+ pulse?: boolean | undefined;
519
+ }
520
+ function StatusDotGlyph({ color, pulse }: StatusDotGlyphProps) {
521
+ return (
522
+ <span
523
+ aria-hidden
524
+ style={{
525
+ display: "inline-block",
526
+ width: 8,
527
+ height: 8,
528
+ borderRadius: "50%",
529
+ background: color,
530
+ animation: pulse ? "ck-actionbar-status-pulse 1.4s ease-in-out infinite" : undefined,
531
+ }}
532
+ />
533
+ );
534
+ }
535
+
536
+ // One global stylesheet entry for the pulse keyframe. Injected once
537
+ // on first import. Idempotent — re-imports check by id.
538
+ if (typeof document !== "undefined") {
539
+ const STYLE_ID = "ck-actionbar-status-pulse";
540
+ if (!document.getElementById(STYLE_ID)) {
541
+ const el = document.createElement("style");
542
+ el.id = STYLE_ID;
543
+ el.textContent = `@keyframes ck-actionbar-status-pulse {
544
+ 0%, 100% { opacity: 1; }
545
+ 50% { opacity: 0.45; }
546
+ }`;
547
+ document.head.appendChild(el);
548
+ }
549
+ }
@@ -1,6 +1,6 @@
1
1
  import { useCallback, useMemo, useState, type ReactNode } from "react";
2
2
  import { ActionBarContext } from "./actionbar-context";
3
- import type { ActionBarItem, ActionBarCategories } from "./types";
3
+ import type { ActionBarItem, ActionBarCategories, ActionBarStatusDot } from "./types";
4
4
 
5
5
  // Provider for the action-bar registry + expansion state +
6
6
  // category catalog. Mount once near the app root, inside the
@@ -23,6 +23,7 @@ export function ActionBarProvider({
23
23
  }: ActionBarProviderProps) {
24
24
  const [sources, setSources] = useState<Record<string, ActionBarItem[]>>({});
25
25
  const [expandedKey, setExpandedKey] = useState<string | null>(null);
26
+ const [statusDot, setStatusDot] = useState<ActionBarStatusDot | null>(null);
26
27
 
27
28
  const register = useCallback((sourceKey: string, items: ActionBarItem[]) => {
28
29
  setSources((prev) => {
@@ -64,8 +65,10 @@ export function ActionBarProvider({
64
65
  categories,
65
66
  expandedKey,
66
67
  setExpandedKey,
68
+ statusDot,
69
+ setStatusDot,
67
70
  }),
68
- [register, unregister, items, categories, expandedKey],
71
+ [register, unregister, items, categories, expandedKey, statusDot],
69
72
  );
70
73
 
71
74
  return (
@@ -1,10 +1,11 @@
1
1
  import { createContext } from "react";
2
- import type { ActionBarItem, ActionBarCategories } from "./types";
2
+ import type { ActionBarItem, ActionBarCategories, ActionBarStatusDot } from "./types";
3
3
 
4
- // Registry + expansion + category catalog. Items are pushed by
5
- // pages through useActionBarItems(); categories are declared at
6
- // the provider level (passed as a prop). expandedKey tracks which
7
- // group is currently open (one at a time).
4
+ // Registry + expansion + category catalog + status-dot slot. Items are
5
+ // pushed by pages through useActionBarItems(); the status dot is
6
+ // pushed by a separate hook (useActionBarStatusDot) because it's
7
+ // bar-intrinsic chrome, not page-level content. expandedKey tracks
8
+ // which group is currently open (one at a time).
8
9
 
9
10
  export interface ActionBarContextValue {
10
11
  register: (sourceKey: string, items: ActionBarItem[]) => void;
@@ -18,6 +19,10 @@ export interface ActionBarContextValue {
18
19
  // Which group head is currently expanded. null = none.
19
20
  expandedKey: string | null;
20
21
  setExpandedKey: (key: string | null) => void;
22
+ // Bar-intrinsic right-edge status indicator. null when no consumer
23
+ // has registered one. Last call to setStatusDot wins.
24
+ statusDot: ActionBarStatusDot | null;
25
+ setStatusDot: (dot: ActionBarStatusDot | null) => void;
21
26
  }
22
27
 
23
28
  export const ActionBarContext = createContext<ActionBarContextValue | null>(null);
@@ -5,10 +5,12 @@ export type { ActionBarProps } from "./ActionBar";
5
5
  export { ActionBarButton } from "./ActionBarButton";
6
6
  export type { ActionBarButtonProps } from "./ActionBarButton";
7
7
  export { useActionBarItems, useActionBarItemsContext } from "./useActionBarItems";
8
+ export { useActionBarStatusDot } from "./useActionBarStatusDot";
8
9
  export type {
9
10
  ActionBarItem,
10
11
  ActionBarItemVariant,
11
12
  ActionBarItemSlot,
13
+ ActionBarStatusDot,
12
14
  ActionBarCategory,
13
15
  ActionBarCategories,
14
16
  } from "./types";
@@ -62,3 +62,33 @@ export interface ActionBarSource {
62
62
  sourceKey: string;
63
63
  items: ActionBarItem[];
64
64
  }
65
+
66
+ // Status dot — bar-intrinsic chrome rendered at the right edge,
67
+ // mirroring the left-edge drag grip. Distinct from `ActionBarItem`
68
+ // (which is page-level content registered through the items
69
+ // registry): a status dot is a fixed system affordance —
70
+ // connection / sync / identity health. The grip on the left says
71
+ // "you can move this thing"; the status dot on the right says
72
+ // "here's how the system is doing."
73
+ //
74
+ // Visual: a small coloured dot, optionally pulsing. Hover shows the
75
+ // title as a native tooltip. Click fires `onClick` — the bar owns
76
+ // no popover behaviour; consumers render their own popover anchored
77
+ // to body / wherever fits their UX.
78
+ //
79
+ // Registered via `useActionBarStatusDot()`. One source of truth at
80
+ // any time (last call wins). Pass `null` to clear.
81
+ export interface ActionBarStatusDot {
82
+ // Colour of the dot. Any CSS colour string — typically a token
83
+ // like `var(--ck-status-success)`. The bar applies no defaulting,
84
+ // so omit the entry (pass null to the hook) to render no dot.
85
+ color: string;
86
+ // Hover tooltip + aria-label. Falls back to "Status".
87
+ title?: string;
88
+ // Click handler. When omitted the dot renders as a non-interactive
89
+ // span (no focus ring, no cursor change, no role=button).
90
+ onClick?: () => void;
91
+ // When true, the dot pulses (CSS animation). Use sparingly —
92
+ // pulsing reads as attention-grabbing.
93
+ pulse?: boolean;
94
+ }
@@ -0,0 +1,49 @@
1
+ import { useContext, useEffect } from "react";
2
+ import { ActionBarContext } from "./actionbar-context";
3
+ import type { ActionBarStatusDot } from "./types";
4
+
5
+ // Push a status-dot config into the action bar's right-edge slot.
6
+ // The bar renders the dot as bar-intrinsic chrome (mirroring the
7
+ // left-edge drag grip), separate from the items registry.
8
+ //
9
+ // Pass `null` to clear. Last call wins — there's no source-key
10
+ // fan-out as with items, because the status dot is a single
11
+ // system-level affordance (sync / connection / identity) and
12
+ // stacking multiple makes no UX sense.
13
+ //
14
+ // On unmount, the dot is cleared automatically.
15
+ //
16
+ // **Important**: pass a stable config object (typically via
17
+ // `useMemo`), or omit fields you don't change between renders.
18
+ // The effect re-runs when individual fields change — colour
19
+ // transitions or pulse toggles re-render the dot but don't thrash
20
+ // the bar.
21
+ export function useActionBarStatusDot(config: ActionBarStatusDot | null) {
22
+ const ctx = useContext(ActionBarContext);
23
+ if (!ctx) {
24
+ throw new Error("useActionBarStatusDot must be used within <ActionBarProvider>");
25
+ }
26
+ const { setStatusDot } = ctx;
27
+ // Project config to primitive deps so a fresh-but-equivalent
28
+ // config object doesn't trigger thrash. onClick identity changes
29
+ // are tolerated through a manual ref capture in the bar render.
30
+ const color = config?.color;
31
+ const title = config?.title;
32
+ const onClick = config?.onClick;
33
+ const pulse = config?.pulse;
34
+ useEffect(() => {
35
+ if (color === undefined) {
36
+ setStatusDot(null);
37
+ return;
38
+ }
39
+ setStatusDot({
40
+ color,
41
+ ...(title !== undefined ? { title } : {}),
42
+ ...(onClick !== undefined ? { onClick } : {}),
43
+ ...(pulse !== undefined ? { pulse } : {}),
44
+ });
45
+ return () => {
46
+ setStatusDot(null);
47
+ };
48
+ }, [setStatusDot, color, title, onClick, pulse]);
49
+ }