@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,36 +0,0 @@
1
- import type { CSSProperties } from "react";
2
-
3
- // Thin gradient strip — the Superbar pattern reusable as a
4
- // horizontal divider, a tab underline, or a progress bar fill.
5
- // 1-2 px tall by default; override via `thickness`.
6
-
7
- export interface SuperbarStripProps {
8
- // px height. Default 1 (hairline divider).
9
- thickness?: number;
10
- // CSS width — defaults to 100%.
11
- width?: string | number;
12
- className?: string;
13
- style?: CSSProperties;
14
- }
15
-
16
- export function SuperbarStrip({
17
- thickness = 1,
18
- width = "100%",
19
- className,
20
- style,
21
- }: SuperbarStripProps) {
22
- return (
23
- <div
24
- aria-hidden
25
- className={className}
26
- style={{
27
- width,
28
- height: thickness,
29
- background: "var(--ck-superbar)",
30
- borderRadius: thickness >= 2 ? thickness / 2 : 0,
31
- opacity: 0.85,
32
- ...style,
33
- }}
34
- />
35
- );
36
- }
@@ -1,6 +0,0 @@
1
- export { CommandInput } from "./CommandInput";
2
- export type { CommandInputProps } from "./CommandInput";
3
- export { SuperbarStrip } from "./SuperbarStrip";
4
- export type { SuperbarStripProps } from "./SuperbarStrip";
5
- export { AmbientBackdrop } from "./AmbientBackdrop";
6
- export type { AmbientBackdropProps } from "./AmbientBackdrop";
@@ -1,66 +0,0 @@
1
- import type { CSSProperties, MouseEventHandler, ReactNode } from "react";
2
-
3
- // One cell inside a <BentoGrid>. Spans configurable column + row
4
- // counts; surface styling matches the chrome (22px radius +
5
- // soft multi-layer shadow under bento). `interactive` enables the
6
- // hover-lift signature animation defined in chrome-bento.css.
7
-
8
- export interface BentoCellProps {
9
- children?: ReactNode;
10
- colSpan?: number;
11
- rowSpan?: number;
12
- // Override the cell's background. Useful for media + gradient
13
- // cells. Defaults to var(--ck-bg-surface).
14
- background?: string;
15
- // Override text color (for high-contrast gradient cells).
16
- color?: string;
17
- // Padding inside the cell. Defaults to 20px.
18
- padding?: number | string;
19
- // Apply the hover-lift effect. Defaults to false.
20
- interactive?: boolean;
21
- onClick?: MouseEventHandler<HTMLDivElement>;
22
- className?: string;
23
- style?: CSSProperties;
24
- }
25
-
26
- export function BentoCell({
27
- children,
28
- colSpan = 1,
29
- rowSpan = 1,
30
- background,
31
- color,
32
- padding = 20,
33
- interactive = false,
34
- onClick,
35
- className,
36
- style,
37
- }: BentoCellProps) {
38
- return (
39
- <div
40
- data-ck-bento-cell=""
41
- data-interactive={interactive ? "true" : undefined}
42
- onClick={onClick}
43
- className={className}
44
- role={onClick ? "button" : undefined}
45
- tabIndex={onClick ? 0 : undefined}
46
- style={{
47
- gridColumn: `span ${colSpan} / span ${colSpan}`,
48
- gridRow: `span ${rowSpan} / span ${rowSpan}`,
49
- background: background ?? "var(--ck-bg-surface)",
50
- color,
51
- border: "1px solid var(--ck-border-subtle)",
52
- borderRadius: "var(--ck-radius-lg, 22px)",
53
- boxShadow: "var(--ck-shadow-2)",
54
- padding,
55
- overflow: "hidden",
56
- position: "relative",
57
- cursor: onClick ? "pointer" : undefined,
58
- display: "flex",
59
- flexDirection: "column",
60
- ...style,
61
- }}
62
- >
63
- {children}
64
- </div>
65
- );
66
- }
@@ -1,42 +0,0 @@
1
- import type { CSSProperties, ReactNode } from "react";
2
-
3
- // 12-column CSS grid wrapper. Cells set their own colSpan/rowSpan
4
- // via <BentoCell>. The grid clamps span values to the column count
5
- // so a 4-wide cell inside an 8-col grid won't overflow.
6
-
7
- export interface BentoGridProps {
8
- children?: ReactNode;
9
- // Column count. Defaults to 12. Pass 6 / 8 for tighter sections.
10
- cols?: number;
11
- // Gap between cells. Defaults to 16px.
12
- gap?: number;
13
- // Minimum row height. Cells span discrete rows of this height.
14
- rowMinHeight?: number;
15
- className?: string;
16
- style?: CSSProperties;
17
- }
18
-
19
- export function BentoGrid({
20
- children,
21
- cols = 12,
22
- gap = 16,
23
- rowMinHeight = 120,
24
- className,
25
- style,
26
- }: BentoGridProps) {
27
- return (
28
- <div
29
- data-ck-bento-grid=""
30
- className={className}
31
- style={{
32
- display: "grid",
33
- gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`,
34
- gridAutoRows: `minmax(${rowMinHeight}px, auto)`,
35
- gap,
36
- ...style,
37
- }}
38
- >
39
- {children}
40
- </div>
41
- );
42
- }
@@ -1,2 +0,0 @@
1
- export * from "./BentoGrid";
2
- export * from "./BentoCell";
@@ -1,329 +0,0 @@
1
- import {
2
- useContext,
3
- useEffect,
4
- useMemo,
5
- useRef,
6
- useState,
7
- type CSSProperties,
8
- } from "react";
9
- import { createPortal } from "react-dom";
10
- import { CommandContext } from "./command-context";
11
- import { rankItems } from "./rank";
12
- import { Kbd } from "../primitives/Kbd";
13
- import type { CommandItem } from "./types";
14
-
15
- // The palette UI. Mounts inside <CommandProvider>; reads items from
16
- // the registry, filters, groups, navigates. Arrow keys + Enter +
17
- // Esc bound while open. Cmd+K toggle lives in the provider.
18
-
19
- export interface CommandPaletteProps {
20
- // Order in which groups appear. Items in groups not listed
21
- // here come last in registration order.
22
- groupOrder?: string[];
23
- placeholder?: string;
24
- // Fires whenever the query changes — on every keystroke and on the
25
- // reset-to-empty when the palette opens. Lets a consumer drive an
26
- // ASYNC command source (debounce → fetch → register the results as
27
- // items via useCommandSource) that the built-in client-side filter
28
- // can't provide on its own. Optional + backward compatible: omit it
29
- // and the palette behaves exactly as before (static items only).
30
- onQueryChange?: ((query: string) => void) | undefined;
31
- }
32
-
33
- export function CommandPalette({
34
- groupOrder = [],
35
- placeholder = "Type a command or search…",
36
- onQueryChange,
37
- }: CommandPaletteProps) {
38
- const ctx = useContext(CommandContext);
39
- if (!ctx) throw new Error("<CommandPalette> must be inside <CommandProvider>");
40
- const { open, setOpen, items } = ctx;
41
- const [q, setQ] = useState("");
42
- const [selectedIdx, setSelectedIdx] = useState(0);
43
- const inputRef = useRef<HTMLInputElement>(null);
44
- const listRef = useRef<HTMLDivElement>(null);
45
- // Latest onQueryChange held in a ref so the open-reset effect can
46
- // notify it WITHOUT taking it as a dep — an inline callback changes
47
- // identity every render, which would otherwise re-run the effect and
48
- // clear the query on every keystroke.
49
- const onQueryChangeRef = useRef(onQueryChange);
50
- onQueryChangeRef.current = onQueryChange;
51
-
52
- // Reset query + focus on every open.
53
- useEffect(() => {
54
- if (open) {
55
- setQ("");
56
- setSelectedIdx(0);
57
- // Tell the consumer the query cleared, so an async source drops
58
- // its stale results when the palette reopens.
59
- onQueryChangeRef.current?.("");
60
- // RAF so the modal element exists before focus.
61
- requestAnimationFrame(() => inputRef.current?.focus());
62
- }
63
- }, [open]);
64
-
65
- // Filter + flat-group ordering.
66
- const filtered = useMemo(() => rankItems(items, q), [items, q]);
67
- const grouped = useMemo(() => {
68
- const byGroup: Record<string, CommandItem[]> = {};
69
- for (const it of filtered) {
70
- (byGroup[it.group] ??= []).push(it);
71
- }
72
- const seen = new Set<string>();
73
- const ordered: { group: string; items: CommandItem[] }[] = [];
74
- for (const g of groupOrder) {
75
- if (byGroup[g]) {
76
- ordered.push({ group: g, items: byGroup[g] });
77
- seen.add(g);
78
- }
79
- }
80
- for (const g of Object.keys(byGroup)) {
81
- if (seen.has(g)) continue;
82
- ordered.push({ group: g, items: byGroup[g]! });
83
- }
84
- return ordered;
85
- }, [filtered, groupOrder]);
86
-
87
- // Flat array for keyboard navigation (sequential across groups).
88
- const flat = useMemo(() => grouped.flatMap((g) => g.items), [grouped]);
89
-
90
- // Effective selection: `selectedIdx` is the raw state the keyboard/mouse
91
- // set, but disabled rows and a shrunk list can leave it pointing at a
92
- // non-interactive or out-of-range item. Derive the index actually shown
93
- // as selected — the first enabled row when the raw one isn't usable — so
94
- // a disabled item (e.g. a "coming soon" teaser) is never highlighted and
95
- // the default lands on the first real result.
96
- const selIdx = useMemo(() => {
97
- if (flat.length === 0) return 0;
98
- if (selectedIdx < flat.length && !flat[selectedIdx]?.disabled) return selectedIdx;
99
- return firstEnabled(flat);
100
- }, [flat, selectedIdx]);
101
-
102
- const close = () => setOpen(false);
103
-
104
- useEffect(() => {
105
- if (!open) return;
106
- const onKey = (e: KeyboardEvent) => {
107
- if (e.key === "Escape") {
108
- e.preventDefault();
109
- close();
110
- return;
111
- }
112
- if (e.key === "ArrowDown") {
113
- e.preventDefault();
114
- setSelectedIdx(stepEnabled(flat, selIdx, 1));
115
- return;
116
- }
117
- if (e.key === "ArrowUp") {
118
- e.preventDefault();
119
- setSelectedIdx(stepEnabled(flat, selIdx, -1));
120
- return;
121
- }
122
- if (e.key === "Enter") {
123
- e.preventDefault();
124
- const pick = flat[selIdx];
125
- if (pick && !pick.disabled) pick.run({ close });
126
- return;
127
- }
128
- };
129
- window.addEventListener("keydown", onKey);
130
- return () => window.removeEventListener("keydown", onKey);
131
- // close + flat + selIdx are stable enough; intentionally omit close
132
- // from deps so we don't re-bind every render.
133
- // eslint-disable-next-line react-hooks/exhaustive-deps
134
- }, [open, flat, selIdx]);
135
-
136
- // Scroll selected row into view.
137
- useEffect(() => {
138
- if (!open) return;
139
- const node = listRef.current?.querySelector(`[data-row-idx="${selIdx}"]`);
140
- (node as HTMLElement | null)?.scrollIntoView({ block: "nearest" });
141
- }, [open, selIdx]);
142
-
143
- if (!open) return null;
144
-
145
- return createPortal(
146
- <div
147
- role="dialog"
148
- aria-modal="true"
149
- aria-label="Command palette"
150
- style={{
151
- position: "fixed",
152
- inset: 0,
153
- background: "rgba(10, 14, 26, 0.5)",
154
- backdropFilter: "blur(2px)",
155
- display: "flex",
156
- alignItems: "flex-start",
157
- justifyContent: "center",
158
- padding: "10vh 16px 16px",
159
- zIndex: 110,
160
- }}
161
- onMouseDown={(e) => {
162
- if (e.target === e.currentTarget) close();
163
- }}
164
- className="ck-anim-fade"
165
- >
166
- <div
167
- className="ck-anim-popover"
168
- style={
169
- {
170
- width: "100%",
171
- maxWidth: 560,
172
- background: "var(--ck-bg-surface)",
173
- border: "1px solid var(--ck-border-subtle)",
174
- borderRadius: "var(--ck-radius-md)",
175
- boxShadow: "var(--ck-shadow-3)",
176
- overflow: "hidden",
177
- display: "flex",
178
- flexDirection: "column",
179
- maxHeight: "70vh",
180
- fontFamily: "var(--ck-font-sans)",
181
- color: "var(--ck-text-primary)",
182
- } as CSSProperties
183
- }
184
- >
185
- <input
186
- ref={inputRef}
187
- value={q}
188
- onChange={(e) => {
189
- setQ(e.target.value);
190
- setSelectedIdx(0);
191
- onQueryChange?.(e.target.value);
192
- }}
193
- placeholder={placeholder}
194
- style={{
195
- height: 44,
196
- padding: "0 16px",
197
- border: "none",
198
- borderBottom: "1px solid var(--ck-border-subtle)",
199
- background: "transparent",
200
- color: "var(--ck-text-primary)",
201
- font: "400 14px/1 var(--ck-font-sans)",
202
- outline: "none",
203
- }}
204
- />
205
- <div ref={listRef} style={{ flex: 1, minHeight: 0, overflowY: "auto", padding: 4 }}>
206
- {flat.length === 0 ? (
207
- <div
208
- style={{
209
- padding: "24px 16px",
210
- color: "var(--ck-text-tertiary)",
211
- fontSize: 13,
212
- textAlign: "center",
213
- }}
214
- >
215
- No matches.
216
- </div>
217
- ) : (
218
- grouped.map((g) => (
219
- <div key={g.group} style={{ marginBottom: 6 }}>
220
- <div
221
- className="ck-eyebrow"
222
- style={{ padding: "8px 12px 4px", color: "var(--ck-text-tertiary)" }}
223
- >
224
- {g.group}
225
- </div>
226
- {g.items.map((it) => {
227
- const idx = flat.indexOf(it);
228
- const disabled = !!it.disabled;
229
- const selected = idx === selIdx && !disabled;
230
- return (
231
- <div
232
- key={it.key}
233
- data-row-idx={idx}
234
- aria-disabled={disabled || undefined}
235
- onMouseEnter={disabled ? undefined : () => setSelectedIdx(idx)}
236
- onClick={disabled ? undefined : () => it.run({ close })}
237
- style={{
238
- display: "flex",
239
- alignItems: "center",
240
- gap: 10,
241
- padding: "8px 12px",
242
- borderRadius: "var(--ck-radius-sm)",
243
- background: selected ? "var(--ck-accent-muted)" : "transparent",
244
- color: disabled
245
- ? "var(--ck-text-tertiary)"
246
- : selected
247
- ? "var(--ck-accent)"
248
- : "var(--ck-text-primary)",
249
- opacity: disabled ? 0.6 : 1,
250
- cursor: disabled ? "default" : "pointer",
251
- font: "400 13px/1.3 var(--ck-font-sans)",
252
- }}
253
- >
254
- {it.icon && (
255
- <span style={{ display: "inline-flex", color: "var(--ck-text-tertiary)" }}>
256
- {it.icon}
257
- </span>
258
- )}
259
- <span style={{ flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
260
- {it.label}
261
- {it.sublabel && (
262
- <span style={{ color: "var(--ck-text-tertiary)", marginLeft: 6, fontSize: 12 }}>
263
- {it.sublabel}
264
- </span>
265
- )}
266
- </span>
267
- {it.hint && (
268
- <span style={{ color: "var(--ck-text-tertiary)", fontSize: 12 }}>
269
- {it.hint}
270
- </span>
271
- )}
272
- </div>
273
- );
274
- })}
275
- </div>
276
- ))
277
- )}
278
- </div>
279
- <div
280
- style={{
281
- padding: "8px 12px",
282
- borderTop: "1px solid var(--ck-border-subtle)",
283
- display: "flex",
284
- alignItems: "center",
285
- gap: 12,
286
- color: "var(--ck-text-tertiary)",
287
- font: "400 11px/1 var(--ck-font-mono)",
288
- background: "var(--ck-bg-surface-2)",
289
- }}
290
- >
291
- <span style={{ display: "inline-flex", alignItems: "center", gap: 4 }}>
292
- <Kbd>↑</Kbd>
293
- <Kbd>↓</Kbd>
294
- navigate
295
- </span>
296
- <span style={{ display: "inline-flex", alignItems: "center", gap: 4 }}>
297
- <Kbd>Enter</Kbd>
298
- run
299
- </span>
300
- <span style={{ display: "inline-flex", alignItems: "center", gap: 4 }}>
301
- <Kbd>Esc</Kbd>
302
- close
303
- </span>
304
- </div>
305
- </div>
306
- </div>,
307
- document.body,
308
- );
309
- }
310
-
311
- // First index whose item isn't disabled, or 0 if every item is (or the
312
- // list is empty) — the palette has to point somewhere.
313
- function firstEnabled(list: CommandItem[]): number {
314
- const i = list.findIndex((it) => !it.disabled);
315
- return i === -1 ? 0 : i;
316
- }
317
-
318
- // Step from `from` in `dir` (+1 down / -1 up), wrapping, skipping disabled
319
- // rows. Returns `from` when there's no other enabled row to land on.
320
- function stepEnabled(list: CommandItem[], from: number, dir: 1 | -1): number {
321
- const n = list.length;
322
- if (n === 0) return 0;
323
- let i = from;
324
- for (let step = 0; step < n; step++) {
325
- i = (i + dir + n) % n;
326
- if (!list[i]?.disabled) return i;
327
- }
328
- return from;
329
- }
@@ -1,57 +0,0 @@
1
- import { useCallback, useMemo, useState, type ReactNode } from "react";
2
- import { CommandContext } from "./command-context";
3
- import { useKeyboardHotkey } from "../hooks/useKeyboardHotkey";
4
- import type { CommandItem } from "./types";
5
-
6
- // Hosts the command-source registry + the palette's open state.
7
- // Binds Cmd+K globally so any consumer page can trigger the palette
8
- // without wiring its own listener.
9
-
10
- export function CommandProvider({
11
- children,
12
- hotkey = "k",
13
- }: {
14
- children: ReactNode;
15
- // Letter for the Mod+_ shortcut. Default "k".
16
- hotkey?: string;
17
- }) {
18
- const [sources, setSources] = useState<Record<string, CommandItem[]>>({});
19
- const [open, setOpen] = useState(false);
20
-
21
- const register = useCallback((sourceKey: string, items: CommandItem[]) => {
22
- setSources((prev) => {
23
- const cur = prev[sourceKey];
24
- if (cur && cur.length === items.length && cur.every((it, i) => it === items[i])) {
25
- return prev;
26
- }
27
- return { ...prev, [sourceKey]: items };
28
- });
29
- }, []);
30
-
31
- const unregister = useCallback((sourceKey: string) => {
32
- setSources((prev) => {
33
- if (!(sourceKey in prev)) return prev;
34
- const next = { ...prev };
35
- delete next[sourceKey];
36
- return next;
37
- });
38
- }, []);
39
-
40
- const items = useMemo(() => Object.values(sources).flat(), [sources]);
41
-
42
- useKeyboardHotkey(
43
- hotkey,
44
- (e) => {
45
- e.preventDefault();
46
- setOpen((v) => !v);
47
- },
48
- { mod: true, skipWhenModalOpen: false },
49
- );
50
-
51
- const value = useMemo(
52
- () => ({ register, unregister, items, open, setOpen }),
53
- [register, unregister, items, open],
54
- );
55
-
56
- return <CommandContext.Provider value={value}>{children}</CommandContext.Provider>;
57
- }
@@ -1,12 +0,0 @@
1
- import { createContext } from "react";
2
- import type { CommandItem } from "./types";
3
-
4
- export interface CommandContextValue {
5
- register: (sourceKey: string, items: CommandItem[]) => void;
6
- unregister: (sourceKey: string) => void;
7
- items: CommandItem[];
8
- open: boolean;
9
- setOpen: (next: boolean) => void;
10
- }
11
-
12
- export const CommandContext = createContext<CommandContextValue | null>(null);
@@ -1,6 +0,0 @@
1
- export { CommandProvider } from "./CommandProvider";
2
- export { CommandPalette } from "./CommandPalette";
3
- export type { CommandPaletteProps } from "./CommandPalette";
4
- export { useCommandSource, useCommandPalette } from "./useCommandSource";
5
- export { rankItems } from "./rank";
6
- export type { CommandItem } from "./types";
@@ -1,50 +0,0 @@
1
- import type { CommandItem } from "./types";
2
-
3
- // Tiny fuzzy ranker. Three signals:
4
- // - label substring match → score 100
5
- // - label prefix match → score +20
6
- // - keyword substring match → score 60 (lower than label)
7
- // Anything that doesn't match returns null.
8
- //
9
- // Not as fancy as fzf — good enough for command palettes with
10
- // dozens to low hundreds of items. Swap in a fancier library if
11
- // you outgrow it; the call site (`useFilteredCommands`) is small.
12
-
13
- interface Ranked {
14
- item: CommandItem;
15
- score: number;
16
- }
17
-
18
- function scoreOne(item: CommandItem, q: string): number | null {
19
- // Secret items exist only for their exact phrase — never in empty
20
- // listings, never via partial/label matches.
21
- if (item.secret) {
22
- return q.trim().toLowerCase() === item.secret.toLowerCase() ? 200 : null;
23
- }
24
- if (!q) return 1; // empty query — everyone passes, sorted by registration order
25
- const ql = q.toLowerCase();
26
- const lab = item.label.toLowerCase();
27
- let s = 0;
28
- if (lab.includes(ql)) {
29
- s = 100;
30
- if (lab.startsWith(ql)) s += 20;
31
- } else if (item.keywords?.some((k) => k.toLowerCase().includes(ql))) {
32
- s = 60;
33
- } else if (item.sublabel?.toLowerCase().includes(ql)) {
34
- s = 40;
35
- } else {
36
- return null;
37
- }
38
- return s;
39
- }
40
-
41
- export function rankItems(items: CommandItem[], query: string): CommandItem[] {
42
- const ranked: Ranked[] = [];
43
- for (const item of items) {
44
- const sc = scoreOne(item, query);
45
- if (sc !== null) ranked.push({ item, score: sc });
46
- }
47
- // Stable sort by score descending.
48
- ranked.sort((a, b) => b.score - a.score);
49
- return ranked.map((r) => r.item);
50
- }
@@ -1,37 +0,0 @@
1
- import type { ReactNode } from "react";
2
-
3
- // Single executable command. Sources register sets of these; the
4
- // palette flattens, filters by the user's query, groups by `group`,
5
- // and runs `run()` when the user picks.
6
-
7
- export interface CommandItem {
8
- key: string;
9
- // Visual category — items sharing a group render under a single
10
- // labelled header in the palette. Use anything; the kit pins
11
- // ordering via the palette's `groupOrder` prop.
12
- group: string;
13
- label: string;
14
- // Dimmed secondary text — usually a path or short context.
15
- sublabel?: string;
16
- // Right-aligned hint (kbd shortcut, "→", etc.).
17
- hint?: ReactNode;
18
- icon?: ReactNode;
19
- // Extra strings to score against (filenames, aliases). Lower
20
- // weight than label so they don't drown the primary match.
21
- keywords?: string[];
22
- // Called when the user picks this command. `close()` shuts the
23
- // palette — useful when the command opens a sub-flow that wants
24
- // to keep the palette open.
25
- run: (api: { close: () => void }) => void;
26
- // Cheat-code gating: when set, the item is INVISIBLE to every
27
- // query except an exact (case-insensitive, trimmed) match of this
28
- // phrase — it never appears in empty-query listings or partial
29
- // matches. Use for hidden debug/test menus; pair with a "Debug"
30
- // group so the reveal is self-describing.
31
- secret?: string;
32
- // Non-interactive placeholder: the row still renders (dimmed) but is
33
- // skipped by keyboard nav, never auto-selected, and `run` never fires
34
- // on it. Use for "coming soon" teasers. Distinct from `secret`, which
35
- // HIDES the row; `disabled` SHOWS it as unavailable.
36
- disabled?: boolean | undefined;
37
- }