@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,316 +0,0 @@
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
- }
@@ -1,83 +0,0 @@
1
- import type { ReactNode, CSSProperties } from "react";
2
-
3
- // Eyebrow + title + optional right-slot, sticky at top of a page
4
- // region (NOT the topbar — this is for an in-page sticky like
5
- // "Reviewing as <name>" or "Document signed · view audit log").
6
- // Caller controls when it shows. Visual mass matches a topbar
7
- // (64 px) so it can sit directly underneath one cleanly.
8
-
9
- export interface StickyBannerProps {
10
- eyebrow?: string;
11
- title: ReactNode;
12
- // Right-aligned action cluster.
13
- actions?: ReactNode;
14
- // Sticky top offset in px. Default 64 (below topbar).
15
- topOffset?: number;
16
- height?: number;
17
- className?: string;
18
- // Visual tone — adjusts background. Default 'info'.
19
- tone?: "info" | "warning" | "success" | "critical";
20
- }
21
-
22
- const TONE_BG: Record<NonNullable<StickyBannerProps["tone"]>, string> = {
23
- info: "var(--ck-info-muted)",
24
- warning: "var(--ck-warning-muted)",
25
- success: "var(--ck-success-muted)",
26
- critical: "var(--ck-critical-muted)",
27
- };
28
- const TONE_BORDER: Record<NonNullable<StickyBannerProps["tone"]>, string> = {
29
- info: "var(--ck-accent-border)",
30
- warning: "var(--ck-warning)",
31
- success: "var(--ck-success)",
32
- critical: "var(--ck-critical)",
33
- };
34
-
35
- export function StickyBanner({
36
- eyebrow,
37
- title,
38
- actions,
39
- topOffset = 64,
40
- height = 64,
41
- className,
42
- tone = "info",
43
- }: StickyBannerProps) {
44
- return (
45
- <div
46
- className={className}
47
- style={
48
- {
49
- position: "sticky",
50
- top: topOffset,
51
- height,
52
- padding: "0 24px",
53
- display: "flex",
54
- alignItems: "center",
55
- gap: 16,
56
- background: TONE_BG[tone],
57
- borderBottom: `1px solid ${TONE_BORDER[tone]}`,
58
- zIndex: 10,
59
- fontFamily: "var(--ck-font-sans)",
60
- } as CSSProperties
61
- }
62
- >
63
- <div style={{ flex: 1, minWidth: 0 }}>
64
- {eyebrow && (
65
- <div className="ck-eyebrow" style={{ marginBottom: 2 }}>
66
- {eyebrow}
67
- </div>
68
- )}
69
- <div
70
- style={{
71
- font: "500 14px/1.3 var(--ck-font-sans)",
72
- color: "var(--ck-text-primary)",
73
- }}
74
- >
75
- {title}
76
- </div>
77
- </div>
78
- {actions && (
79
- <div style={{ display: "flex", gap: 8, flex: "none" }}>{actions}</div>
80
- )}
81
- </div>
82
- );
83
- }
@@ -1,68 +0,0 @@
1
- import type { ReactNode } from "react";
2
-
3
- // Top chrome strip. Three slots — left (crumbs / brand / back arrow),
4
- // center (page-specific affordance like a tab switcher), right
5
- // (actions). Height is exposed as --ck-breadcrumb-height (default
6
- // 64 px) so the right-side panel, command palette, and other fixed-
7
- // position UI can offset themselves correctly.
8
-
9
- export interface TopbarProps {
10
- // Usually <Breadcrumb /> on desktop, page title on mobile.
11
- left?: ReactNode;
12
- // Optional centred slot. Hidden if not supplied.
13
- center?: ReactNode;
14
- // Right cluster — theme toggle, profile chip, etc.
15
- right?: ReactNode;
16
- // Height in px. Default 64. Stamps --ck-breadcrumb-height so
17
- // sticky elements below can align.
18
- height?: number;
19
- className?: string;
20
- }
21
-
22
- export function Topbar({
23
- left,
24
- center,
25
- right,
26
- height = 64,
27
- className,
28
- }: TopbarProps) {
29
- return (
30
- <header
31
- data-ck-topbar
32
- className={className}
33
- style={
34
- {
35
- height,
36
- padding: "0 24px",
37
- display: "flex",
38
- alignItems: "center",
39
- gap: 16,
40
- background: "var(--ck-bg-surface)",
41
- borderBottom: "1px solid var(--ck-border-subtle)",
42
- ["--ck-breadcrumb-height" as string]: `${height}px`,
43
- } as React.CSSProperties
44
- }
45
- >
46
- <div style={{ flex: "1 1 auto", minWidth: 0 }}>{left}</div>
47
- {center && (
48
- <div
49
- style={{
50
- position: "absolute",
51
- left: "50%",
52
- transform: "translateX(-50%)",
53
- display: "flex",
54
- alignItems: "center",
55
- gap: 8,
56
- }}
57
- >
58
- {center}
59
- </div>
60
- )}
61
- {right && (
62
- <div style={{ display: "flex", alignItems: "center", gap: 8, flex: "none" }}>
63
- {right}
64
- </div>
65
- )}
66
- </header>
67
- );
68
- }
@@ -1,24 +0,0 @@
1
- export { Shell } from "./Shell";
2
- export type { ShellProps } from "./Shell";
3
- export { Topbar } from "./Topbar";
4
- export type { TopbarProps } from "./Topbar";
5
- export { Breadcrumb } from "./Breadcrumb";
6
- export type { BreadcrumbProps, CrumbItem } from "./Breadcrumb";
7
- export { LeftNavRail } from "./LeftNavRail";
8
- export type { LeftNavRailProps } from "./LeftNavRail";
9
- export { useNavRailState } from "./useNavRailState";
10
- export type { NavRailState, UseNavRailStateOpts } from "./useNavRailState";
11
- export { MobileTabBar } from "./MobileTabBar";
12
- export type { MobileTabBarProps, MobileTab } from "./MobileTabBar";
13
- export { RightSidebarPanel } from "./RightSidebarPanel";
14
- export type { RightSidebarPanelProps } from "./RightSidebarPanel";
15
- export { SidePanel } from "./SidePanel";
16
- export type { SidePanelProps } from "./SidePanel";
17
- export { StickyBanner } from "./StickyBanner";
18
- export type { StickyBannerProps } from "./StickyBanner";
19
- export { NavSection } from "./NavSection";
20
- export type { NavSectionProps } from "./NavSection";
21
- export { NavItem } from "./NavItem";
22
- export type { NavItemProps } from "./NavItem";
23
- export { NavSearchTrigger } from "./NavSearchTrigger";
24
- export type { NavSearchTriggerProps } from "./NavSearchTrigger";
@@ -1,69 +0,0 @@
1
- import { useCallback, useEffect, useState } from "react";
2
- import { useViewport } from "../hooks/useViewport";
3
-
4
- // Manages the collapse state of <LeftNavRail>. Two layers:
5
- //
6
- // 1. User preference — persisted in localStorage under
7
- // `storageKey`. The user toggles via a button in the rail.
8
- // 2. Viewport rule — below `forceCollapseBelow` the rail is
9
- // always collapsed regardless of user preference.
10
- //
11
- // The hook returns the EFFECTIVE collapsed state (combining the
12
- // two) plus a setter that only mutates the user preference (the
13
- // viewport rule continues to apply on top).
14
-
15
- export interface UseNavRailStateOpts {
16
- storageKey?: string;
17
- // Viewport width below which the rail force-collapses, ignoring
18
- // user preference. Default 1100 px.
19
- forceCollapseBelow?: number;
20
- // Initial preference if nothing is in localStorage. Default false
21
- // (expanded).
22
- defaultCollapsed?: boolean;
23
- }
24
-
25
- export interface NavRailState {
26
- collapsed: boolean;
27
- // The viewport is currently overriding the user preference.
28
- // Toggle button should be disabled when this is true.
29
- forcedByViewport: boolean;
30
- toggle: () => void;
31
- setCollapsed: (next: boolean) => void;
32
- }
33
-
34
- const DEFAULT_KEY = "ck-leftnav-collapsed";
35
-
36
- export function useNavRailState({
37
- storageKey = DEFAULT_KEY,
38
- forceCollapseBelow = 1100,
39
- defaultCollapsed = false,
40
- }: UseNavRailStateOpts = {}): NavRailState {
41
- const [userPref, setUserPref] = useState<boolean>(() => {
42
- if (typeof window === "undefined") return defaultCollapsed;
43
- const stored = window.localStorage.getItem(storageKey);
44
- if (stored === "1") return true;
45
- if (stored === "0") return false;
46
- return defaultCollapsed;
47
- });
48
- const vp = useViewport({ narrow: forceCollapseBelow });
49
- const forcedByViewport = vp.isNarrow;
50
- const collapsed = forcedByViewport || userPref;
51
-
52
- const setCollapsed = useCallback(
53
- (next: boolean) => {
54
- setUserPref(next);
55
- },
56
- [],
57
- );
58
-
59
- useEffect(() => {
60
- window.localStorage.setItem(storageKey, userPref ? "1" : "0");
61
- }, [userPref, storageKey]);
62
-
63
- const toggle = useCallback(() => {
64
- if (forcedByViewport) return; // no-op — viewport rules
65
- setUserPref((v) => !v);
66
- }, [forcedByViewport]);
67
-
68
- return { collapsed, forcedByViewport, toggle, setCollapsed };
69
- }
@@ -1,44 +0,0 @@
1
- // Time formatting helpers.
2
-
3
- // "3m ago" / "2h ago" / "5d ago", falls back to a locale date past
4
- // 30 days. Use for activity rows, comment timestamps, anywhere a
5
- // short relative cue beats a full timestamp.
6
- export function relativeTime(ts: number): string {
7
- const diff = Date.now() - ts;
8
- const sec = Math.round(diff / 1000);
9
- if (sec < 60) return `${sec}s ago`;
10
- const min = Math.round(sec / 60);
11
- if (min < 60) return `${min}m ago`;
12
- const hr = Math.round(min / 60);
13
- if (hr < 24) return `${hr}h ago`;
14
- const day = Math.round(hr / 24);
15
- if (day < 30) return `${day}d ago`;
16
- return new Date(ts).toLocaleDateString();
17
- }
18
-
19
- // Null-safe locale date formatter — accepts ms timestamps, ISO
20
- // strings, null, undefined, or non-finite values. Empty inputs
21
- // render as an em-dash placeholder ("—"), which most list rows
22
- // were doing inline with a ternary before.
23
- export function formatDate(ms: number | string | null | undefined): string {
24
- const n = coerceMs(ms);
25
- if (n === null) return "—";
26
- return new Date(n).toLocaleDateString();
27
- }
28
-
29
- // Full date + time formatter with the same null-safe semantics.
30
- export function formatTimestamp(
31
- ms: number | string | null | undefined,
32
- opts?: Intl.DateTimeFormatOptions,
33
- ): string {
34
- const n = coerceMs(ms);
35
- if (n === null) return "—";
36
- return opts ? new Date(n).toLocaleString(undefined, opts) : new Date(n).toLocaleString();
37
- }
38
-
39
- function coerceMs(ms: number | string | null | undefined): number | null {
40
- if (ms == null) return null;
41
- const n = typeof ms === "string" ? Number(ms) : ms;
42
- if (!Number.isFinite(n) || n <= 0) return null;
43
- return n;
44
- }
@@ -1,81 +0,0 @@
1
- import type { CSSProperties } from "react";
2
-
3
- // Looping marquee strip — the signature neobrutalism section
4
- // divider. "NEOBRUTALISM · NEOBRUTALISM · NEOBRUTALISM…" in
5
- // chunky 800-weight type, scrolling horizontally. Decoration,
6
- // not nav.
7
- //
8
- // Pure CSS animation — no rAF loops, no JS. The repeated text
9
- // inside is doubled so when the first set scrolls past the edge,
10
- // the second is already in position to seamlessly continue.
11
-
12
- export interface MarqueeProps {
13
- text: string;
14
- // Pixels per second. Default 60 — comfortable reading speed.
15
- speed?: number;
16
- // Visual variant. "default" = accent background; "inverted" =
17
- // pure black background with white text.
18
- variant?: "default" | "inverted";
19
- // px height. Default 56.
20
- height?: number;
21
- // Slight skew angle (deg). Default 0; try -2 for a punk feel.
22
- skew?: number;
23
- className?: string;
24
- style?: CSSProperties;
25
- }
26
-
27
- export function Marquee({
28
- text,
29
- speed = 60,
30
- variant = "default",
31
- height = 56,
32
- skew = 0,
33
- className,
34
- style,
35
- }: MarqueeProps) {
36
- // Each phrase is ~80 px wide for a typical 20-char fragment;
37
- // 10 copies gives ~800 px content — wide enough that the loop
38
- // is always seamless on any viewport. Real measurement happens
39
- // visually; for now the count + duration combo just need to be
40
- // multiples that produce a continuous loop.
41
- const phrases = new Array(10).fill(text).join(" • ");
42
- const duration = `${(phrases.length * 8) / speed}s`;
43
-
44
- return (
45
- <div
46
- className={className}
47
- style={{
48
- overflow: "hidden",
49
- height,
50
- background:
51
- variant === "inverted" ? "#000000" : "var(--ck-accent, #88AAEE)",
52
- color: variant === "inverted" ? "#FFFFFF" : "#000000",
53
- border: "2px solid var(--neobrut-ink, #000)",
54
- borderRadius: 4,
55
- transform: skew ? `skewY(${skew}deg)` : undefined,
56
- position: "relative",
57
- ...style,
58
- }}
59
- >
60
- <div
61
- style={{
62
- position: "absolute",
63
- inset: 0,
64
- display: "flex",
65
- alignItems: "center",
66
- gap: 40,
67
- whiteSpace: "nowrap",
68
- font: "800 22px/1 var(--ck-font-display, var(--ck-font-sans))",
69
- letterSpacing: "0.04em",
70
- animation: `ck-neobrut-marquee ${duration} linear infinite`,
71
- willChange: "transform",
72
- }}
73
- >
74
- {/* Doubled for seamless loop. CSS animation translates by
75
- -50% so the second copy moves into the first's position. */}
76
- <span>{phrases}</span>
77
- <span aria-hidden>{phrases}</span>
78
- </div>
79
- </div>
80
- );
81
- }