@cosxai/ui 0.1.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 (109) hide show
  1. package/package.json +38 -0
  2. package/src/actionbar/ActionBar.tsx +436 -0
  3. package/src/actionbar/ActionBarButton.tsx +110 -0
  4. package/src/actionbar/ActionBarMenuGroup.tsx +106 -0
  5. package/src/actionbar/ActionBarProvider.tsx +76 -0
  6. package/src/actionbar/actionbar-context.ts +23 -0
  7. package/src/actionbar/index.ts +13 -0
  8. package/src/actionbar/types.ts +50 -0
  9. package/src/actionbar/useActionBarItems.ts +47 -0
  10. package/src/ambient/AmbientBackdrop.tsx +74 -0
  11. package/src/ambient/CommandInput.tsx +107 -0
  12. package/src/ambient/SuperbarStrip.tsx +36 -0
  13. package/src/ambient/index.ts +6 -0
  14. package/src/bento/BentoCell.tsx +66 -0
  15. package/src/bento/BentoGrid.tsx +42 -0
  16. package/src/bento/index.ts +2 -0
  17. package/src/command/CommandPalette.tsx +277 -0
  18. package/src/command/CommandProvider.tsx +57 -0
  19. package/src/command/command-context.ts +12 -0
  20. package/src/command/index.ts +6 -0
  21. package/src/command/rank.ts +45 -0
  22. package/src/command/types.ts +26 -0
  23. package/src/command/useCommandSource.ts +37 -0
  24. package/src/dialogs/DialogsProvider.tsx +216 -0
  25. package/src/dialogs/Modal.tsx +204 -0
  26. package/src/dialogs/Toast.tsx +85 -0
  27. package/src/dialogs/dialogs-context.ts +6 -0
  28. package/src/dialogs/index.ts +10 -0
  29. package/src/dialogs/types.ts +37 -0
  30. package/src/dialogs/useDialogs.ts +8 -0
  31. package/src/editorial/EditorialSpotlight.tsx +63 -0
  32. package/src/editorial/Folio.tsx +52 -0
  33. package/src/editorial/PlateMarker.tsx +33 -0
  34. package/src/editorial/RomanSection.tsx +65 -0
  35. package/src/editorial/RunningMarginalia.tsx +65 -0
  36. package/src/editorial/index.ts +10 -0
  37. package/src/frutiger/GlossyOrb.tsx +79 -0
  38. package/src/frutiger/SkyBackdrop.tsx +114 -0
  39. package/src/frutiger/index.ts +2 -0
  40. package/src/hooks/index.ts +5 -0
  41. package/src/hooks/useKeyboardHotkey.ts +80 -0
  42. package/src/hooks/useReducedMotion.ts +20 -0
  43. package/src/hooks/useViewport.ts +61 -0
  44. package/src/index.ts +26 -0
  45. package/src/layout/Breadcrumb.tsx +74 -0
  46. package/src/layout/LeftNavRail.tsx +126 -0
  47. package/src/layout/MobileTabBar.tsx +101 -0
  48. package/src/layout/NavItem.tsx +128 -0
  49. package/src/layout/NavSearchTrigger.tsx +88 -0
  50. package/src/layout/NavSection.tsx +40 -0
  51. package/src/layout/RightSidebarPanel.tsx +111 -0
  52. package/src/layout/Shell.tsx +91 -0
  53. package/src/layout/StickyBanner.tsx +83 -0
  54. package/src/layout/Topbar.tsx +68 -0
  55. package/src/layout/index.ts +22 -0
  56. package/src/layout/useNavRailState.ts +69 -0
  57. package/src/lib/cn.ts +7 -0
  58. package/src/lib/time-utils.ts +44 -0
  59. package/src/neobrutalism/Marquee.tsx +81 -0
  60. package/src/neobrutalism/Sticker.tsx +71 -0
  61. package/src/neobrutalism/index.ts +4 -0
  62. package/src/primitives/Avatar.tsx +53 -0
  63. package/src/primitives/Button.tsx +30 -0
  64. package/src/primitives/Card.tsx +41 -0
  65. package/src/primitives/Checkbox.tsx +78 -0
  66. package/src/primitives/CountBadge.tsx +50 -0
  67. package/src/primitives/Input.tsx +71 -0
  68. package/src/primitives/Kbd.tsx +45 -0
  69. package/src/primitives/PageHeader.tsx +77 -0
  70. package/src/primitives/Tag.tsx +56 -0
  71. package/src/primitives/Textarea.tsx +62 -0
  72. package/src/primitives/ToggleSwitch.tsx +79 -0
  73. package/src/primitives/Tooltip.tsx +171 -0
  74. package/src/primitives/index.ts +24 -0
  75. package/src/pwa/InstallPromptBanner.tsx +132 -0
  76. package/src/pwa/index.ts +4 -0
  77. package/src/pwa/manifest.template.json +20 -0
  78. package/src/pwa/registerSW.ts +55 -0
  79. package/src/riso/Halftone.tsx +85 -0
  80. package/src/riso/Misregister.tsx +63 -0
  81. package/src/riso/RisoStamp.tsx +76 -0
  82. package/src/riso/index.ts +3 -0
  83. package/src/sketch/HandUnderline.tsx +53 -0
  84. package/src/sketch/RoughArrow.tsx +91 -0
  85. package/src/sketch/RoughBox.tsx +73 -0
  86. package/src/sketch/StickyNote.tsx +56 -0
  87. package/src/sketch/index.ts +4 -0
  88. package/src/styles/base.css +80 -0
  89. package/src/styles/chrome-ambient.css +222 -0
  90. package/src/styles/chrome-bento.css +184 -0
  91. package/src/styles/chrome-editorial.css +145 -0
  92. package/src/styles/chrome-frutiger.css +364 -0
  93. package/src/styles/chrome-neobrutalism.css +315 -0
  94. package/src/styles/chrome-riso.css +328 -0
  95. package/src/styles/chrome-sketch.css +351 -0
  96. package/src/styles/chrome-swiss.css +232 -0
  97. package/src/styles/chrome-terminal.css +235 -0
  98. package/src/styles/fonts.css +22 -0
  99. package/src/styles/index.css +198 -0
  100. package/src/styles/tokens.css +976 -0
  101. package/src/terminal/AsciiBox.tsx +65 -0
  102. package/src/terminal/BrailleSpinner.tsx +46 -0
  103. package/src/terminal/index.ts +4 -0
  104. package/src/theme/ThemeProvider.tsx +93 -0
  105. package/src/theme/index.ts +5 -0
  106. package/src/theme/inline-script.ts +36 -0
  107. package/src/theme/theme-context.ts +7 -0
  108. package/src/theme/types.ts +22 -0
  109. package/src/theme/useTheme.ts +8 -0
@@ -0,0 +1,79 @@
1
+ import type { CSSProperties } from "react";
2
+
3
+ // Pure CSS 3D glass orb — the "Aero" / Vista / iOS 6 marble.
4
+ // Layered radial gradients: deep accent base, top white highlight,
5
+ // bottom internal reflection, soft outer glow. Drop it anywhere
6
+ // you want a hero / empty-state decoration.
7
+
8
+ export interface GlossyOrbProps {
9
+ size?: number;
10
+ // Tint of the orb. Defaults to aqua (Aero blue). Pass any color.
11
+ tone?: "aqua" | "lime" | "rose" | "amber";
12
+ // Disable the slow rotation animation.
13
+ static?: boolean;
14
+ className?: string;
15
+ style?: CSSProperties;
16
+ }
17
+
18
+ const TONES: Record<NonNullable<GlossyOrbProps["tone"]>, { base: string; deep: string; glow: string }> = {
19
+ aqua: { base: "#4FC3F7", deep: "#01579B", glow: "rgba(79, 195, 247, 0.55)" },
20
+ lime: { base: "#DCFF66", deep: "#7CB342", glow: "rgba(198, 255, 0, 0.55)" },
21
+ rose: { base: "#F48FB1", deep: "#AD1457", glow: "rgba(244, 143, 177, 0.55)" },
22
+ amber: { base: "#FFCA28", deep: "#E65100", glow: "rgba(255, 152, 0, 0.55)" },
23
+ };
24
+
25
+ export function GlossyOrb({
26
+ size = 96,
27
+ tone = "aqua",
28
+ static: noSpin,
29
+ className,
30
+ style,
31
+ }: GlossyOrbProps) {
32
+ const t = TONES[tone];
33
+ return (
34
+ <div
35
+ className={className}
36
+ aria-hidden
37
+ style={{
38
+ width: size,
39
+ height: size,
40
+ borderRadius: "50%",
41
+ position: "relative",
42
+ background: `
43
+ radial-gradient(circle at 50% 28%, rgba(255,255,255,0.95) 0%, rgba(255,255,255,0.0) 32%),
44
+ radial-gradient(circle at 50% 95%, ${t.base} 0%, transparent 40%),
45
+ radial-gradient(circle at 50% 50%, ${t.base} 0%, ${t.deep} 100%)
46
+ `,
47
+ boxShadow: `
48
+ 0 ${size * 0.12}px ${size * 0.28}px ${t.glow},
49
+ inset 0 ${size * 0.08}px ${size * 0.12}px rgba(255, 255, 255, 0.55),
50
+ inset 0 -${size * 0.08}px ${size * 0.16}px rgba(0, 0, 0, 0.22)
51
+ `,
52
+ animation: noSpin ? undefined : "ck-frutiger-orb-spin 18s linear infinite",
53
+ ...style,
54
+ }}
55
+ >
56
+ {/* Specular highlight — small bright cap top-left */}
57
+ <span
58
+ style={{
59
+ position: "absolute",
60
+ top: size * 0.1,
61
+ left: size * 0.22,
62
+ width: size * 0.36,
63
+ height: size * 0.22,
64
+ borderRadius: "50%",
65
+ background: "radial-gradient(ellipse, rgba(255,255,255,0.95) 0%, rgba(255,255,255,0) 70%)",
66
+ }}
67
+ />
68
+ <style>{`
69
+ @keyframes ck-frutiger-orb-spin {
70
+ 0% { transform: rotate(0deg); }
71
+ 100% { transform: rotate(360deg); }
72
+ }
73
+ @media (prefers-reduced-motion: reduce) {
74
+ [data-ck-glossy-orb] { animation: none !important; }
75
+ }
76
+ `}</style>
77
+ </div>
78
+ );
79
+ }
@@ -0,0 +1,114 @@
1
+ import type { CSSProperties, ReactNode } from "react";
2
+
3
+ // Drop-in section backdrop. The frutiger chrome already paints the
4
+ // page with a sky gradient — this component is for opt-in moments
5
+ // inside other chromes (a hero banner under chrome="classic", a
6
+ // preview area in the theming page) without flipping the whole app.
7
+
8
+ export interface SkyBackdropProps {
9
+ children?: ReactNode;
10
+ // Add the floating sparkle particles. Defaults to true.
11
+ sparkles?: boolean;
12
+ // Add soft white cumulus blobs near the bottom. Defaults to true.
13
+ clouds?: boolean;
14
+ // Variant: dusk swaps the gradient for indigo→lavender→pink.
15
+ variant?: "day" | "dusk";
16
+ className?: string;
17
+ style?: CSSProperties;
18
+ }
19
+
20
+ const DAY_BG = "linear-gradient(180deg, #87CEEB 0%, #B3E5FC 45%, #E1F5FE 100%)";
21
+ const DUSK_BG = "linear-gradient(180deg, #2E2280 0%, #6A4C93 40%, #F4A6CD 80%, #FFD8B1 100%)";
22
+
23
+ export function SkyBackdrop({
24
+ children,
25
+ sparkles = true,
26
+ clouds = true,
27
+ variant = "day",
28
+ className,
29
+ style,
30
+ }: SkyBackdropProps) {
31
+ return (
32
+ <div
33
+ className={className}
34
+ style={{
35
+ position: "relative",
36
+ overflow: "hidden",
37
+ background: variant === "dusk" ? DUSK_BG : DAY_BG,
38
+ ...style,
39
+ }}
40
+ >
41
+ {clouds && <Clouds variant={variant} />}
42
+ {sparkles && <Sparkles />}
43
+ <div style={{ position: "relative", zIndex: 2 }}>{children}</div>
44
+ </div>
45
+ );
46
+ }
47
+
48
+ function Clouds({ variant }: { variant: "day" | "dusk" }) {
49
+ const tint = variant === "dusk" ? "rgba(255, 220, 230, 0.5)" : "rgba(255, 255, 255, 0.6)";
50
+ return (
51
+ <div
52
+ aria-hidden
53
+ style={{
54
+ position: "absolute",
55
+ inset: 0,
56
+ background: `
57
+ radial-gradient(ellipse 280px 80px at 14% 78%, ${tint} 0%, transparent 70%),
58
+ radial-gradient(ellipse 360px 90px at 72% 86%, ${tint} 0%, transparent 70%),
59
+ radial-gradient(ellipse 200px 60px at 50% 92%, ${tint} 0%, transparent 70%)
60
+ `,
61
+ pointerEvents: "none",
62
+ zIndex: 1,
63
+ }}
64
+ />
65
+ );
66
+ }
67
+
68
+ function Sparkles() {
69
+ // 14 deterministic positions — random feels jittery on reload.
70
+ const dots = [
71
+ { x: 8, y: 22, s: 3, o: 0.85 },
72
+ { x: 18, y: 12, s: 2, o: 0.65 },
73
+ { x: 31, y: 32, s: 4, o: 0.9 },
74
+ { x: 44, y: 18, s: 2, o: 0.55 },
75
+ { x: 56, y: 42, s: 3, o: 0.7 },
76
+ { x: 68, y: 14, s: 2, o: 0.6 },
77
+ { x: 76, y: 30, s: 5, o: 0.8 },
78
+ { x: 88, y: 24, s: 2, o: 0.55 },
79
+ { x: 22, y: 56, s: 3, o: 0.55 },
80
+ { x: 38, y: 64, s: 2, o: 0.5 },
81
+ { x: 60, y: 60, s: 4, o: 0.7 },
82
+ { x: 82, y: 50, s: 2, o: 0.55 },
83
+ { x: 12, y: 42, s: 2, o: 0.5 },
84
+ { x: 50, y: 28, s: 2, o: 0.7 },
85
+ ];
86
+ return (
87
+ <div
88
+ aria-hidden
89
+ style={{
90
+ position: "absolute",
91
+ inset: 0,
92
+ pointerEvents: "none",
93
+ zIndex: 1,
94
+ }}
95
+ >
96
+ {dots.map((d, i) => (
97
+ <span
98
+ key={i}
99
+ style={{
100
+ position: "absolute",
101
+ left: `${d.x}%`,
102
+ top: `${d.y}%`,
103
+ width: d.s,
104
+ height: d.s,
105
+ borderRadius: "50%",
106
+ background: "#FFFFFF",
107
+ opacity: d.o,
108
+ boxShadow: `0 0 ${d.s * 4}px ${d.s * 1.5}px rgba(255, 255, 255, ${d.o * 0.55})`,
109
+ }}
110
+ />
111
+ ))}
112
+ </div>
113
+ );
114
+ }
@@ -0,0 +1,2 @@
1
+ export * from "./SkyBackdrop";
2
+ export * from "./GlossyOrb";
@@ -0,0 +1,5 @@
1
+ export { useViewport } from "./useViewport";
2
+ export type { Viewport, ViewportBreakpoints } from "./useViewport";
3
+ export { useReducedMotion } from "./useReducedMotion";
4
+ export { useKeyboardHotkey } from "./useKeyboardHotkey";
5
+ export type { UseKeyboardHotkeyOptions } from "./useKeyboardHotkey";
@@ -0,0 +1,80 @@
1
+ import { useEffect, useRef } from "react";
2
+
3
+ // Single-key hotkey listener with the standard conflict guards:
4
+ // - skipInputs (default true) — ignore when an input /
5
+ // textarea / select / contenteditable is focused
6
+ // - skipModifiers (default true) — let browser shortcuts
7
+ // (Cmd+S, Ctrl+W, etc.) through
8
+ // - skipWhenModalOpen (default true) — don't fire when a
9
+ // <Modal> (anything with role="dialog") is mounted
10
+ //
11
+ // `key` is a single character (case-insensitive) or a special key
12
+ // name ("Escape", "Enter", "ArrowUp", etc.). For modifier
13
+ // combinations use `mod: true` (Cmd on Mac, Ctrl elsewhere).
14
+ //
15
+ // Handler refs are kept fresh via a ref-mirror — call sites don't
16
+ // need to memoise the callback.
17
+
18
+ export interface UseKeyboardHotkeyOptions {
19
+ // Combine with Cmd (Mac) / Ctrl (other). Default false.
20
+ mod?: boolean;
21
+ skipInputs?: boolean;
22
+ skipModifiers?: boolean; // ignored when `mod: true`
23
+ skipWhenModalOpen?: boolean;
24
+ // Disable without unmounting (toggle off without remount).
25
+ enabled?: boolean;
26
+ }
27
+
28
+ const isMac = () =>
29
+ typeof navigator !== "undefined" &&
30
+ /Mac|iPhone|iPad|iPod/.test(navigator.platform);
31
+
32
+ export function useKeyboardHotkey(
33
+ key: string,
34
+ handler: (e: KeyboardEvent) => void,
35
+ options: UseKeyboardHotkeyOptions = {},
36
+ ) {
37
+ const {
38
+ mod = false,
39
+ skipInputs = true,
40
+ skipModifiers = true,
41
+ skipWhenModalOpen = true,
42
+ enabled = true,
43
+ } = options;
44
+
45
+ const handlerRef = useRef(handler);
46
+ handlerRef.current = handler;
47
+
48
+ useEffect(() => {
49
+ if (!enabled) return;
50
+ const target = key.toLowerCase();
51
+ const onKey = (e: KeyboardEvent) => {
52
+ if (e.key.toLowerCase() !== target) return;
53
+ if (mod) {
54
+ const wantedMod = isMac() ? e.metaKey : e.ctrlKey;
55
+ if (!wantedMod) return;
56
+ if (e.shiftKey || e.altKey) return;
57
+ } else if (skipModifiers && (e.metaKey || e.ctrlKey || e.altKey)) {
58
+ return;
59
+ }
60
+ if (skipInputs) {
61
+ const ae = document.activeElement as HTMLElement | null;
62
+ const tag = ae?.tagName;
63
+ if (
64
+ tag === "INPUT" ||
65
+ tag === "TEXTAREA" ||
66
+ tag === "SELECT" ||
67
+ ae?.isContentEditable
68
+ ) {
69
+ return;
70
+ }
71
+ }
72
+ if (skipWhenModalOpen && document.querySelector('[role="dialog"]')) {
73
+ return;
74
+ }
75
+ handlerRef.current(e);
76
+ };
77
+ window.addEventListener("keydown", onKey);
78
+ return () => window.removeEventListener("keydown", onKey);
79
+ }, [key, mod, skipInputs, skipModifiers, skipWhenModalOpen, enabled]);
80
+ }
@@ -0,0 +1,20 @@
1
+ import { useEffect, useState } from "react";
2
+
3
+ // Boolean subscription to the OS-level `prefers-reduced-motion`
4
+ // query. Use in component code that wants to short-circuit
5
+ // non-essential animations (the kit's CSS already respects the
6
+ // query via `@media (prefers-reduced-motion: reduce)` so most
7
+ // components don't need this — only call sites that wrap their
8
+ // own JS-driven motion (canvas animations, scroll-jacking, etc.)).
9
+
10
+ export function useReducedMotion(): boolean {
11
+ const [prefers, setPrefers] = useState(false);
12
+ useEffect(() => {
13
+ const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
14
+ setPrefers(mq.matches);
15
+ const onChange = (e: MediaQueryListEvent) => setPrefers(e.matches);
16
+ mq.addEventListener("change", onChange);
17
+ return () => mq.removeEventListener("change", onChange);
18
+ }, []);
19
+ return prefers;
20
+ }
@@ -0,0 +1,61 @@
1
+ import { useEffect, useState } from "react";
2
+
3
+ // Viewport size + named breakpoints. Layout components subscribe to
4
+ // this to switch desktop ↔ mobile shells, force-collapse the nav
5
+ // rail below a width threshold, etc.
6
+ //
7
+ // Single window listener for all consumers (mounted once per call
8
+ // site — keep usage minimal). For pages with many consumers, lift
9
+ // to a context if perf is ever an issue; not needed yet.
10
+
11
+ export interface Viewport {
12
+ width: number;
13
+ height: number;
14
+ // Below this, render the mobile shell (bottom tab bar + slide-out nav).
15
+ isPhone: boolean;
16
+ // Below this, force-collapse the left nav rail to icon-only.
17
+ isNarrow: boolean;
18
+ // Below this, hide the right rail entirely.
19
+ hidesRightRail: boolean;
20
+ }
21
+
22
+ export interface ViewportBreakpoints {
23
+ phone: number;
24
+ narrow: number;
25
+ rightRail: number;
26
+ }
27
+
28
+ const DEFAULTS: ViewportBreakpoints = {
29
+ phone: 768,
30
+ narrow: 1100,
31
+ rightRail: 1280,
32
+ };
33
+
34
+ function snapshot(b: ViewportBreakpoints): Viewport {
35
+ if (typeof window === "undefined") {
36
+ return { width: 1440, height: 900, isPhone: false, isNarrow: false, hidesRightRail: false };
37
+ }
38
+ const w = window.innerWidth;
39
+ const h = window.innerHeight;
40
+ return {
41
+ width: w,
42
+ height: h,
43
+ isPhone: w < b.phone,
44
+ isNarrow: w < b.narrow,
45
+ hidesRightRail: w < b.rightRail,
46
+ };
47
+ }
48
+
49
+ export function useViewport(breakpoints: Partial<ViewportBreakpoints> = {}): Viewport {
50
+ const b: ViewportBreakpoints = { ...DEFAULTS, ...breakpoints };
51
+ const [vp, setVp] = useState<Viewport>(() => snapshot(b));
52
+ useEffect(() => {
53
+ const onResize = () => setVp(snapshot(b));
54
+ onResize();
55
+ window.addEventListener("resize", onResize);
56
+ return () => window.removeEventListener("resize", onResize);
57
+ // Breakpoints are stable across renders for any sane caller; stringify
58
+ // to avoid unstable-object-identity re-runs without losing reactivity.
59
+ }, [b.phone, b.narrow, b.rightRail]);
60
+ return vp;
61
+ }
package/src/index.ts ADDED
@@ -0,0 +1,26 @@
1
+ // Entry point — re-exports everything consumers need.
2
+ // Components, hooks, theme, and helpers are organised under
3
+ // /primitives, /layout, /actionbar, /command, /dialogs, /hooks,
4
+ // /theme, /pwa, /lib. As the kit grows, each phase adds exports
5
+ // here. Consumers pull individual modules (`import { Button }
6
+ // from "@cosxai/ui"`) or copy source files directly per the
7
+ // shadcn-style distribution model.
8
+
9
+ export * from "./primitives";
10
+ export * from "./lib/cn";
11
+ export * from "./lib/time-utils";
12
+ export * from "./theme";
13
+ export * from "./hooks";
14
+ export * from "./layout";
15
+ export * from "./dialogs";
16
+ export * from "./actionbar";
17
+ export * from "./command";
18
+ export * from "./pwa";
19
+ export * from "./editorial";
20
+ export * from "./neobrutalism";
21
+ export * from "./ambient";
22
+ export * from "./terminal";
23
+ export * from "./bento";
24
+ export * from "./frutiger";
25
+ export * from "./riso";
26
+ export * from "./sketch";
@@ -0,0 +1,74 @@
1
+ import { Fragment, type ReactNode } from "react";
2
+
3
+ // Crumb item — either a label (terminal node, no link) or a label
4
+ // with a click handler. The kit doesn't ship router integration —
5
+ // consumers pass plain onClick or wrap children in their own router's
6
+ // link primitive (e.g. <NavLink to={...}>{label}</NavLink>).
7
+
8
+ export interface CrumbItem {
9
+ label: string;
10
+ onClick?: () => void;
11
+ // Custom render — escape hatch for "wrap this crumb in <Link>".
12
+ // When set, label/onClick are ignored.
13
+ render?: ReactNode;
14
+ }
15
+
16
+ export interface BreadcrumbProps {
17
+ items: ReadonlyArray<string | CrumbItem>;
18
+ // Slot to the right of the last crumb (e.g. doc-meta popover trigger).
19
+ accessory?: ReactNode;
20
+ className?: string;
21
+ }
22
+
23
+ function normalise(item: string | CrumbItem): CrumbItem {
24
+ return typeof item === "string" ? { label: item } : item;
25
+ }
26
+
27
+ // Phase-2 breadcrumb: simple chain with separator dots. The full
28
+ // middle-collapse + narrow-viewport popover fallback from deck-kit
29
+ // is deferred to a later iteration once the kit's surface stabilises.
30
+ // For now, long chains wrap; consumers should avoid >4 crumbs.
31
+ export function Breadcrumb({ items, accessory, className }: BreadcrumbProps) {
32
+ const list = items.map(normalise);
33
+ return (
34
+ <nav
35
+ aria-label="Breadcrumb"
36
+ className={className}
37
+ style={{
38
+ display: "flex",
39
+ alignItems: "center",
40
+ gap: 6,
41
+ font: "400 12px/1 var(--ck-font-mono)",
42
+ color: "var(--ck-text-tertiary)",
43
+ letterSpacing: "0.04em",
44
+ textTransform: "uppercase",
45
+ minWidth: 0,
46
+ flex: "1 1 auto",
47
+ }}
48
+ >
49
+ {list.map((crumb, i) => {
50
+ const isLast = i === list.length - 1;
51
+ return (
52
+ <Fragment key={i}>
53
+ {i > 0 && (
54
+ <span aria-hidden style={{ color: "var(--ck-text-disabled)" }}>·</span>
55
+ )}
56
+ <span
57
+ style={{
58
+ color: isLast ? "var(--ck-text-primary)" : "var(--ck-text-tertiary)",
59
+ whiteSpace: "nowrap",
60
+ overflow: "hidden",
61
+ textOverflow: "ellipsis",
62
+ cursor: crumb.onClick ? "pointer" : "default",
63
+ }}
64
+ onClick={crumb.onClick}
65
+ >
66
+ {crumb.render ?? crumb.label}
67
+ </span>
68
+ </Fragment>
69
+ );
70
+ })}
71
+ {accessory && <span style={{ marginLeft: 4 }}>{accessory}</span>}
72
+ </nav>
73
+ );
74
+ }
@@ -0,0 +1,126 @@
1
+ import { useEffect, type ReactNode, type CSSProperties } from "react";
2
+
3
+ // Fixed-left vertical nav rail. Three slots — brand (top), children
4
+ // (the nav body, scrollable), footer (avatar / collapse toggle).
5
+ //
6
+ // Owns the CSS var --ck-leftnav-width. Page content reads this var
7
+ // to inset itself, so layouts react to the rail width changing
8
+ // without prop-drilling.
9
+ //
10
+ // Stateless visually: collapsed is a boolean prop. State management
11
+ // lives in useNavRailState(), which the parent owns; this keeps the
12
+ // component pure and testable.
13
+
14
+ export interface LeftNavRailProps {
15
+ brand?: ReactNode;
16
+ children: ReactNode;
17
+ footer?: ReactNode;
18
+ collapsed?: boolean;
19
+ // Expanded / collapsed widths in px. Defaults 256 / 56.
20
+ widthExpanded?: number;
21
+ widthCollapsed?: number;
22
+ className?: string;
23
+ // Hide entirely (e.g. on phone). When true, sets --ck-leftnav-width: 0.
24
+ hidden?: boolean;
25
+ }
26
+
27
+ export function LeftNavRail({
28
+ brand,
29
+ children,
30
+ footer,
31
+ collapsed = false,
32
+ widthExpanded = 256,
33
+ widthCollapsed = 56,
34
+ className,
35
+ hidden = false,
36
+ }: LeftNavRailProps) {
37
+ const width = hidden ? 0 : collapsed ? widthCollapsed : widthExpanded;
38
+
39
+ // Stamp --ck-leftnav-width on documentElement so page-level
40
+ // layouts (Shell's marginLeft, fixed AgentRail, ActionBar's right
41
+ // inset) can read it without a context.
42
+ useEffect(() => {
43
+ document.documentElement.style.setProperty("--ck-leftnav-width", `${width}px`);
44
+ return () => {
45
+ // On unmount, clear to 0 so consumers without a rail don't inherit
46
+ // a stale inset.
47
+ document.documentElement.style.setProperty("--ck-leftnav-width", "0px");
48
+ };
49
+ }, [width]);
50
+
51
+ if (hidden) return null;
52
+
53
+ return (
54
+ <aside
55
+ data-ck-leftnav
56
+ className={className}
57
+ style={
58
+ {
59
+ position: "fixed",
60
+ left: 0,
61
+ top: 0,
62
+ bottom: 0,
63
+ width,
64
+ // Default to --ck-bg-surface so the rail blends seamlessly
65
+ // with the topbar + cards. Consumers wanting a tinted rail
66
+ // override via --ck-bg-sidebar in their own stylesheet or
67
+ // pass a className that sets background.
68
+ background: "var(--ck-bg-surface)",
69
+ borderRight: "1px solid var(--ck-border-subtle)",
70
+ display: "flex",
71
+ flexDirection: "column",
72
+ fontFamily: "var(--ck-font-sans)",
73
+ color: "var(--ck-text-primary)",
74
+ transition: "width var(--ck-dur-fast) var(--ck-ease), background-color 200ms var(--ck-ease)",
75
+ zIndex: 30,
76
+ } as CSSProperties
77
+ }
78
+ >
79
+ {brand && (
80
+ <div
81
+ style={{
82
+ // Match the topbar's height (Topbar stamps
83
+ // --ck-breadcrumb-height; default 64 px). Logo +
84
+ // crumbs sit on the same horizontal axis and the
85
+ // brand-area bottom rule aligns with the topbar's
86
+ // bottom rule — no per-app tuning.
87
+ height: "var(--ck-breadcrumb-height, 64px)",
88
+ padding: collapsed ? "0 4px" : "0 12px",
89
+ display: "flex",
90
+ alignItems: "center",
91
+ // Logo is brand mark, not nav item — centre it in both
92
+ // collapsed and expanded states so the visual anchor stays
93
+ // put across the width transition. Matches the centred
94
+ // favicon position when the rail is collapsed.
95
+ justifyContent: "center",
96
+ borderBottom: "1px solid var(--ck-border-subtle)",
97
+ transition: "padding var(--ck-dur-fast) var(--ck-ease)",
98
+ flexShrink: 0,
99
+ }}
100
+ >
101
+ {brand}
102
+ </div>
103
+ )}
104
+ <div
105
+ style={{
106
+ flex: 1,
107
+ minHeight: 0,
108
+ overflowY: "auto",
109
+ padding: collapsed ? "8px 4px" : "12px 8px",
110
+ }}
111
+ >
112
+ {children}
113
+ </div>
114
+ {footer && (
115
+ <div
116
+ style={{
117
+ padding: collapsed ? "8px 4px" : "12px 8px",
118
+ borderTop: "1px solid var(--ck-border-subtle)",
119
+ }}
120
+ >
121
+ {footer}
122
+ </div>
123
+ )}
124
+ </aside>
125
+ );
126
+ }