@vvedantb/ui 0.4.0 → 0.4.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": "@vvedantb/ui",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "license": "MIT",
5
5
  "description": "Shared React 19 UI for web apps: one JavaScript entry (primitives, shell pieces, app helpers, native tokens), design-token CSS, shared Tailwind v4 CSS and a Tailwind preset.",
6
6
  "type": "module",
@@ -52,15 +52,6 @@
52
52
  "sideEffects": [
53
53
  "./src/ui/sonner.css"
54
54
  ],
55
- "scripts": {
56
- "check:tokens": "node tokens/scripts/check-component-tokens.mjs",
57
- "build": "node tokens/scripts/check-exports.mjs && node tokens/scripts/check-component-tokens.mjs && tsc -p tsconfig.json",
58
- "typecheck": "tsc -p tsconfig.json && tsc -p tokens/tsconfig.json",
59
- "test": "node tokens/scripts/check-component-tokens.mjs && vitest run && node --test tokens/test/*.test.mjs",
60
- "verify": "node tokens/scripts/check-component-tokens.mjs && node tokens/verify/run.mjs",
61
- "analyze": "node tokens/verify/analyze.mjs",
62
- "prepack": "node tokens/scripts/check-exports.mjs"
63
- },
64
55
  "peerDependencies": {
65
56
  "@clerk/clerk-react": ">=5",
66
57
  "@radix-ui/react-accordion": "^1.2.12",
@@ -171,5 +162,13 @@
171
162
  "zod": {
172
163
  "optional": true
173
164
  }
165
+ },
166
+ "scripts": {
167
+ "check:tokens": "node tokens/scripts/check-component-tokens.mjs",
168
+ "build": "node tokens/scripts/check-exports.mjs && node tokens/scripts/check-component-tokens.mjs && tsc -p tsconfig.json",
169
+ "typecheck": "tsc -p tsconfig.json && tsc -p tokens/tsconfig.json",
170
+ "test": "node tokens/scripts/check-component-tokens.mjs && vitest run && node --test tokens/test/*.test.mjs",
171
+ "verify": "node tokens/scripts/check-component-tokens.mjs && node tokens/verify/run.mjs",
172
+ "analyze": "node tokens/verify/analyze.mjs"
174
173
  }
175
- }
174
+ }
@@ -0,0 +1,256 @@
1
+ // @vitest-environment happy-dom
2
+ import { act, createRef } from "react";
3
+ import { createRoot, type Root } from "react-dom/client";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import { SessionsHoverCard } from "./SessionsHoverCard";
6
+
7
+ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
8
+
9
+ let root: Root | null = null;
10
+ let host: HTMLElement | null = null;
11
+
12
+ async function mount(node: React.ReactNode) {
13
+ host = document.createElement("div");
14
+ document.body.appendChild(host);
15
+ root = createRoot(host);
16
+ await act(async () => {
17
+ root!.render(node);
18
+ });
19
+ }
20
+
21
+ afterEach(async () => {
22
+ await act(async () => root?.unmount());
23
+ host?.remove();
24
+ document.body.innerHTML = "";
25
+ root = null;
26
+ host = null;
27
+ });
28
+
29
+ describe("SessionsHoverCard", () => {
30
+ it("renders header, body and footer slots when open", async () => {
31
+ await mount(
32
+ <SessionsHoverCard open header="Head" body="Body text" footer="Foot">
33
+ <button type="button">Row</button>
34
+ </SessionsHoverCard>,
35
+ );
36
+ const text = document.body.textContent ?? "";
37
+ expect(text).toContain("Row");
38
+ expect(text).toContain("Head");
39
+ expect(text).toContain("Body text");
40
+ expect(text).toContain("Foot");
41
+ expect(document.body.innerHTML).toContain("w-64");
42
+ });
43
+
44
+ it("does not call the body render prop or loadContent while closed", async () => {
45
+ const body = vi.fn(() => "x");
46
+ const loadContent = vi.fn(() => "y");
47
+ await mount(
48
+ <>
49
+ <SessionsHoverCard open={false} body={body}>
50
+ <button type="button">A</button>
51
+ </SessionsHoverCard>
52
+ <SessionsHoverCard open={false} loadContent={loadContent}>
53
+ <button type="button">B</button>
54
+ </SessionsHoverCard>
55
+ </>,
56
+ );
57
+ expect(body).not.toHaveBeenCalled();
58
+ expect(loadContent).not.toHaveBeenCalled();
59
+ });
60
+
61
+ it("calls the render prop with open true and loads content once when open", async () => {
62
+ const body = vi.fn((ctx: { open: boolean }) => `open=${ctx.open}`);
63
+ const loadContent = vi.fn(async () => "Loaded preview");
64
+ await mount(
65
+ <>
66
+ <SessionsHoverCard open body={body}>
67
+ <button type="button">A</button>
68
+ </SessionsHoverCard>
69
+ <SessionsHoverCard open loadContent={loadContent} loadingFallback="Loading">
70
+ <button type="button">B</button>
71
+ </SessionsHoverCard>
72
+ </>,
73
+ );
74
+ expect(body).toHaveBeenCalledWith({ open: true });
75
+ expect(document.body.textContent).toContain("open=true");
76
+ expect(loadContent).toHaveBeenCalledTimes(1);
77
+ expect(document.body.textContent).toContain("Loaded preview");
78
+ });
79
+
80
+ it("portals the content to document.body by default", async () => {
81
+ await mount(
82
+ <div id="clip" style={{ overflow: "hidden" }}>
83
+ <SessionsHoverCard open body="Body text">
84
+ <button type="button">Row</button>
85
+ </SessionsHoverCard>
86
+ </div>,
87
+ );
88
+ const content = document.querySelector("[data-radix-popper-content-wrapper]");
89
+ expect(content).not.toBeNull();
90
+ expect(document.getElementById("clip")!.contains(content)).toBe(false);
91
+ expect(document.body.textContent).toContain("Body text");
92
+ });
93
+
94
+ it("keeps the content in place with portal={false}", async () => {
95
+ await mount(
96
+ <div id="clip" style={{ overflow: "hidden" }}>
97
+ <SessionsHoverCard open portal={false} body="Body text">
98
+ <button type="button">Row</button>
99
+ </SessionsHoverCard>
100
+ </div>,
101
+ );
102
+ const content = document.querySelector("[data-radix-popper-content-wrapper]");
103
+ expect(document.getElementById("clip")!.contains(content)).toBe(true);
104
+ });
105
+
106
+ it("swaps the surface without keeping the default one", async () => {
107
+ await mount(
108
+ <>
109
+ <SessionsHoverCard open surface="glass" body="G" className="p-5">
110
+ <button type="button">A</button>
111
+ </SessionsHoverCard>
112
+ </>,
113
+ );
114
+ const card = document.querySelector("[data-state=open][data-side]") as HTMLElement;
115
+ expect(card.className).toContain("glass-panel-strong");
116
+ expect(card.className).toContain("rounded-surface");
117
+ expect(card.className).not.toContain("bg-overlay");
118
+ expect(card.className).not.toContain("rounded-lg");
119
+ expect(card.className).toContain("p-5");
120
+ expect(card.className).not.toContain("p-3");
121
+ });
122
+
123
+ it("uses the floating overlay surface by default", async () => {
124
+ await mount(
125
+ <SessionsHoverCard open body="B">
126
+ <button type="button">A</button>
127
+ </SessionsHoverCard>,
128
+ );
129
+ const card = document.querySelector("[data-state=open][data-side]") as HTMLElement;
130
+ expect(card.className).toContain("bg-overlay");
131
+ expect(card.className).toContain("rounded-lg");
132
+ });
133
+
134
+ it("forwards the ref and props to the child trigger and keeps its handlers", async () => {
135
+ const ref = createRef<HTMLButtonElement>();
136
+ const onClick = vi.fn();
137
+ await mount(
138
+ <SessionsHoverCard ref={ref as never} data-testid="row" body="B">
139
+ <button type="button" onClick={onClick}>
140
+ Row
141
+ </button>
142
+ </SessionsHoverCard>,
143
+ );
144
+ const button = document.querySelector("button") as HTMLButtonElement;
145
+ expect(ref.current).toBe(button);
146
+ expect(button.getAttribute("data-testid")).toBe("row");
147
+ await act(async () => button.click());
148
+ expect(onClick).toHaveBeenCalledTimes(1);
149
+ });
150
+
151
+ it("renders its own trigger element with asChild={false}", async () => {
152
+ await mount(
153
+ <SessionsHoverCard asChild={false} body="B">
154
+ <span>Row</span>
155
+ </SessionsHoverCard>,
156
+ );
157
+ const trigger = document.querySelector("[data-state]") as HTMLElement;
158
+ expect(trigger.tagName).toBe("A");
159
+ expect(trigger.textContent).toBe("Row");
160
+ });
161
+
162
+ describe("focus and pointer", () => {
163
+ function setFocusVisible(el: HTMLElement, value: boolean) {
164
+ const orig = el.matches.bind(el);
165
+ el.matches = (sel: string) => (sel === ":focus-visible" ? value : orig(sel));
166
+ }
167
+
168
+ it("does not open on pointer-driven focus after a context menu", async () => {
169
+ const onOpenChange = vi.fn();
170
+ await mount(
171
+ <SessionsHoverCard openDelay={0} header="Head" onOpenChange={onOpenChange}>
172
+ <button type="button">Row</button>
173
+ </SessionsHoverCard>,
174
+ );
175
+ const row = document.querySelector("button")!;
176
+ setFocusVisible(row, false);
177
+ await act(async () => {
178
+ row.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true }));
179
+ row.dispatchEvent(new FocusEvent("focus", { bubbles: false }));
180
+ row.dispatchEvent(new FocusEvent("focusin", { bubbles: true }));
181
+ });
182
+ await act(async () => new Promise((r) => setTimeout(r, 20)));
183
+ expect(onOpenChange).not.toHaveBeenCalledWith(true);
184
+ expect(document.body.textContent).not.toContain("Head");
185
+ });
186
+
187
+ it("opens on keyboard focus-visible", async () => {
188
+ const onOpenChange = vi.fn();
189
+ await mount(
190
+ <SessionsHoverCard openDelay={0} header="Head" onOpenChange={onOpenChange}>
191
+ <button type="button">Row</button>
192
+ </SessionsHoverCard>,
193
+ );
194
+ const row = document.querySelector("button")!;
195
+ setFocusVisible(row, true);
196
+ await act(async () => {
197
+ row.dispatchEvent(new FocusEvent("focusin", { bubbles: true }));
198
+ });
199
+ await act(async () => new Promise((r) => setTimeout(r, 20)));
200
+ expect(onOpenChange).toHaveBeenCalledWith(true);
201
+ });
202
+
203
+ // React derives onPointerLeave from native pointerout (with a null or outside
204
+ // relatedTarget). A native pointerleave event does not reach the handler.
205
+ function leave(el: HTMLElement) {
206
+ return act(async () => {
207
+ el.dispatchEvent(new PointerEvent("pointerout", { pointerType: "mouse", bubbles: true, relatedTarget: null }));
208
+ });
209
+ }
210
+
211
+ it("closes on pointer leave when stuck open by non-focus-visible focus", async () => {
212
+ const onOpenChange = vi.fn();
213
+ await mount(
214
+ <SessionsHoverCard defaultOpen header="Head" onOpenChange={onOpenChange}>
215
+ <button type="button">Row</button>
216
+ </SessionsHoverCard>,
217
+ );
218
+ const row = document.querySelector("button")!;
219
+ setFocusVisible(row, false);
220
+ row.focus();
221
+ await leave(row);
222
+ expect(onOpenChange).toHaveBeenCalledWith(false);
223
+ await act(async () => new Promise((r) => setTimeout(r, 20)));
224
+ expect(document.body.textContent).not.toContain("Head");
225
+ });
226
+
227
+ it("stays open on pointer leave with keyboard focus-visible", async () => {
228
+ const onOpenChange = vi.fn();
229
+ await mount(
230
+ <SessionsHoverCard defaultOpen header="Head" onOpenChange={onOpenChange}>
231
+ <button type="button">Row</button>
232
+ </SessionsHoverCard>,
233
+ );
234
+ const row = document.querySelector("button")!;
235
+ setFocusVisible(row, true);
236
+ row.focus();
237
+ await leave(row);
238
+ await act(async () => new Promise((r) => setTimeout(r, 20)));
239
+ expect(onOpenChange).not.toHaveBeenCalledWith(false);
240
+ expect(document.body.textContent).toContain("Head");
241
+ });
242
+
243
+ it("does not force-close on pointer leave without focus (Radix owns hover close)", async () => {
244
+ const onOpenChange = vi.fn();
245
+ await mount(
246
+ <SessionsHoverCard defaultOpen closeDelay={1000} header="Head" onOpenChange={onOpenChange}>
247
+ <button type="button">Row</button>
248
+ </SessionsHoverCard>,
249
+ );
250
+ const row = document.querySelector("button")!;
251
+ await leave(row);
252
+ expect(onOpenChange).not.toHaveBeenCalledWith(false);
253
+ expect(document.body.textContent).toContain("Head");
254
+ });
255
+ });
256
+ });
@@ -1,4 +1,4 @@
1
- import type { ChangeEvent, ReactNode } from "react";
1
+ import type { ChangeEvent, ReactNode, RefObject } from "react";
2
2
  import { useEffect, useRef, useState } from "react";
3
3
  import { createPortal } from "react-dom";
4
4
  import { AnimatePresence, domAnimation, LazyMotion, m } from "motion/react";
@@ -22,6 +22,7 @@ import {
22
22
  import { SpeechInput } from "../../ai-elements/speech-input";
23
23
  import { chatInputContainerClass } from "../../ui/surface-classes";
24
24
  import { ComposerSlashMenu } from "./ComposerSlashMenu";
25
+ import { InputAreaEditor } from "./InputAreaEditor";
25
26
  import { FadePresence } from "./FadePresence";
26
27
  import { InputActions, type InputActionsProps } from "./InputActions";
27
28
  import { motionFast } from "./motion";
@@ -30,6 +31,8 @@ import { useComposerCompact } from "./useComposerCompact";
30
31
  import { useComposerSlashMenu } from "./useComposerSlashMenu";
31
32
  import { useInputAreaPrediction } from "./useInputAreaPrediction";
32
33
 
34
+ const EXPANDED_EDITOR =
35
+ "min-h-16 max-h-50 w-full self-stretch overflow-y-auto rounded-none px-4 pt-3.5 pb-1 text-left";
33
36
  const COMPACT_EDITOR =
34
37
  "flex min-h-9 max-h-9 min-w-0 w-auto flex-1 items-center self-center overflow-hidden whitespace-pre! rounded-none px-1 py-2 text-left leading-5";
35
38
  const COMPACT_START_ADDON =
@@ -67,8 +70,9 @@ export type InputAreaProps = {
67
70
  /** When omitted: the draft has text, or `fileCount` or `chipCount` is above 0. */
68
71
  canSubmit?: boolean;
69
72
 
70
- /** Inputs to the compact pill. */
73
+ /** Files force the stacked card. */
71
74
  fileCount?: number;
75
+ /** Chips never force the stacked card. They only enable send and Backspace removal. */
72
76
  chipCount?: number;
73
77
 
74
78
  showDictate?: boolean;
@@ -86,8 +90,10 @@ export type InputAreaProps = {
86
90
  queue?: ReactNode;
87
91
  /** Attachment strip. Shown inside the input, above the text. */
88
92
  attachments?: ReactNode;
89
- /** Chip row. Shown inside the input, above the text. */
93
+ /** Chip row. Shown inside the input, before the text (see `chipsPlacement`). */
90
94
  chips?: ReactNode;
95
+ /** `inline` (default): chips are non-editable atoms at the start of the same text flow, so wrapped lines start under them. `top`: chips sit in a strip above the text. */
96
+ chipsPlacement?: "inline" | "top";
91
97
  /** Extra rows in the plus menu after the attach row. Include your own separators. */
92
98
  plusMenuExtra?: ReactNode;
93
99
  /** Props merged onto the plus-menu dropdown content, such as `align`, `side` or `sideOffset`. Defaults: `align="start"`, `side="top"`. */
@@ -102,7 +108,7 @@ export type InputAreaProps = {
102
108
  /** Returns a continuation for the draft. Omit to turn ghost text off. */
103
109
  getPrediction?: (text: string) => Promise<string>;
104
110
  predictionEnabled?: boolean;
105
- /** Classes on the ghost suggestion span. Default matches the original app composer. */
111
+ /** Classes for the ghost suggestion. With `chipsPlacement="top"` they sit on the overlay span. Inline mode copies their colour and opacity to the `::after` ghost text. */
106
112
  predictionTextClassName?: string;
107
113
 
108
114
  /** Wrapper around banners, queue and the input. */
@@ -142,6 +148,7 @@ export function InputArea({
142
148
  queue,
143
149
  attachments,
144
150
  chips,
151
+ chipsPlacement = "inline",
145
152
  plusMenuExtra,
146
153
  plusMenuContentProps,
147
154
  slashItems,
@@ -164,7 +171,10 @@ export function InputArea({
164
171
  if (!isControlled) setInternalValue(next);
165
172
  onChange?.(next);
166
173
  };
167
- const inputRef = useRef<HTMLTextAreaElement>(null);
174
+ const inputRef = useRef<HTMLElement>(null);
175
+ // Measure once after mount so a long `defaultValue` expands on first paint.
176
+ const [, setMeasured] = useState(false);
177
+ useEffect(() => setMeasured(true), []);
168
178
  const fileInputRef = useRef<HTMLInputElement>(null);
169
179
  const hasFiles = fileCount > 0;
170
180
 
@@ -176,7 +186,6 @@ export function InputArea({
176
186
  const { chromeRef, compact } = useComposerCompact({
177
187
  value: input,
178
188
  fileCount,
179
- chipCount,
180
189
  });
181
190
 
182
191
  const items = slashItems ?? [];
@@ -195,13 +204,17 @@ export function InputArea({
195
204
  getPrediction,
196
205
  });
197
206
 
207
+ const inlineMode = chipsPlacement === "inline";
208
+
198
209
  const textareaClassName = cn(
199
210
  "bg-transparent! shadow-none border-none",
200
211
  "text-sm md:text-[15px] leading-5 placeholder:text-muted-foreground",
201
- "smooth-typing-input field-sizing-content focus-visible:outline-hidden",
212
+ "smooth-typing-input focus-visible:outline-hidden",
202
213
  compact
203
214
  ? COMPACT_EDITOR
204
- : "min-h-16 max-h-50 w-full self-stretch overflow-y-auto rounded-none px-4 pt-3.5 pb-1 text-left",
215
+ : // `py-0` clears InputGroupTextarea's default `py-3`.
216
+ cn(EXPANDED_EDITOR, "py-0 pt-3.5 pb-1"),
217
+ !inlineMode && "field-sizing-content",
205
218
  );
206
219
 
207
220
  const submitDisabled =
@@ -229,12 +242,25 @@ export function InputArea({
229
242
  const activeElement = document.activeElement;
230
243
  const isInputFocused =
231
244
  activeElement instanceof HTMLInputElement ||
232
- activeElement instanceof HTMLTextAreaElement;
233
- if (!isInputFocused && inputRef.current) {
234
- const textarea = inputRef.current;
235
- textarea.focus();
236
- const length = textarea.value.length;
237
- textarea.setSelectionRange(length, length);
245
+ activeElement instanceof HTMLTextAreaElement ||
246
+ (activeElement instanceof HTMLElement &&
247
+ activeElement.isContentEditable);
248
+ const editor = inputRef.current;
249
+ if (!isInputFocused && editor) {
250
+ editor.focus();
251
+ if (editor instanceof HTMLTextAreaElement) {
252
+ const length = editor.value.length;
253
+ editor.setSelectionRange(length, length);
254
+ } else {
255
+ const sel = window.getSelection();
256
+ if (sel) {
257
+ const range = document.createRange();
258
+ range.selectNodeContents(editor);
259
+ range.collapse(false);
260
+ sel.removeAllRanges();
261
+ sel.addRange(range);
262
+ }
263
+ }
238
264
  }
239
265
  };
240
266
  window.addEventListener("keydown", handleKeyDown);
@@ -332,6 +358,112 @@ export function InputArea({
332
358
  </div>
333
359
  );
334
360
 
361
+ const comboboxProps = {
362
+ role: slashMenu.comboboxProps.role,
363
+ "aria-multiline": slashMenu.comboboxProps["aria-multiline"],
364
+ "aria-haspopup": slashMenu.comboboxProps["aria-haspopup"],
365
+ "aria-autocomplete": slashMenu.comboboxProps["aria-autocomplete"],
366
+ "aria-expanded": slashMenu.comboboxProps["aria-expanded"],
367
+ "aria-controls": slashMenu.comboboxProps["aria-controls"],
368
+ "aria-activedescendant": slashMenu.comboboxProps["aria-activedescendant"],
369
+ };
370
+
371
+ /** Slash menu and ghost text keys. Returns true when handled. */
372
+ const handleCommonKeyDown = (
373
+ event: Parameters<typeof slashMenu.onKeyDown>[0],
374
+ ): boolean => {
375
+ if (slashMenu.onKeyDown(event)) return true;
376
+ if (prediction.suggestion) {
377
+ if (event.key === "Tab" && !event.shiftKey) {
378
+ event.preventDefault();
379
+ setInput(input + prediction.suggestion);
380
+ return true;
381
+ }
382
+ if (event.key === "Escape") {
383
+ event.preventDefault();
384
+ prediction.dismiss();
385
+ return true;
386
+ }
387
+ }
388
+ return false;
389
+ };
390
+
391
+ const body = (
392
+ <PromptInputBody>
393
+ <div
394
+ className={cn(
395
+ "relative min-w-0",
396
+ compact ? "flex-1" : "w-full self-stretch",
397
+ )}
398
+ >
399
+ {inlineMode ? (
400
+ <InputAreaEditor
401
+ editorRef={inputRef as RefObject<HTMLDivElement | null>}
402
+ value={input}
403
+ onValueChange={setInput}
404
+ placeholder={placeholder}
405
+ suggestion={prediction.suggestion || undefined}
406
+ suggestionClassName={predictionTextClassName}
407
+ chips={chips}
408
+ chipCount={chipCount}
409
+ onRemoveLastChip={onRemoveLastChip}
410
+ className={cn(textareaClassName, "relative z-10")}
411
+ ariaProps={comboboxProps}
412
+ onKeyDown={(event) => {
413
+ handleCommonKeyDown(event);
414
+ }}
415
+ onBlur={slashMenu.onBlur}
416
+ />
417
+ ) : (
418
+ <>
419
+ {prediction.suggestion ? (
420
+ <div
421
+ aria-hidden
422
+ className={cn(
423
+ textareaClassName,
424
+ "pointer-events-none absolute inset-0 z-0 overflow-hidden whitespace-pre-wrap wrap-break-word text-foreground",
425
+ )}
426
+ >
427
+ {input}
428
+ <span className={predictionTextClassName}>
429
+ {prediction.suggestion}
430
+ </span>
431
+ </div>
432
+ ) : null}
433
+ <PromptInputTextarea
434
+ ref={inputRef as RefObject<HTMLTextAreaElement | null>}
435
+ value={input}
436
+ placeholder={placeholder}
437
+ className={cn(
438
+ textareaClassName,
439
+ "relative z-10",
440
+ prediction.suggestion && "caret-current text-transparent",
441
+ )}
442
+ rows={1}
443
+ {...comboboxProps}
444
+ onKeyDown={(event) => {
445
+ if (handleCommonKeyDown(event)) return;
446
+ if (
447
+ event.key === "Backspace" &&
448
+ event.currentTarget.selectionStart === 0 &&
449
+ event.currentTarget.selectionEnd === 0 &&
450
+ chipCount > 0
451
+ ) {
452
+ event.preventDefault();
453
+ onRemoveLastChip?.();
454
+ }
455
+ }}
456
+ onBlur={slashMenu.onBlur}
457
+ onChange={(e) => {
458
+ setInput(e.currentTarget.value);
459
+ }}
460
+ />
461
+ </>
462
+ )}
463
+ </div>
464
+ </PromptInputBody>
465
+ );
466
+
335
467
  return (
336
468
  <LazyMotion features={domAnimation}>
337
469
  <TooltipProvider>
@@ -365,7 +497,7 @@ export function InputArea({
365
497
  "flex w-full min-w-0 [interpolate-size:allow-keywords] transition-[color,box-shadow,border-color,border-radius,height] duration-200",
366
498
  chatInputContainerClass,
367
499
  compact
368
- ? "h-12 flex-row flex-nowrap items-center rounded-full py-1 has-[>textarea]:h-12"
500
+ ? "h-12 flex-row flex-nowrap items-center rounded-full py-1 has-[[data-slot=input-group-control]]:h-12"
369
501
  : "h-auto flex-col items-start rounded-surface",
370
502
  borderClassName,
371
503
  containerClassName,
@@ -390,7 +522,7 @@ export function InputArea({
390
522
  }
391
523
  >
392
524
  {showFileUpload ? attachments : null}
393
- {chips}
525
+ {inlineMode ? null : chips}
394
526
  {compact ? (
395
527
  <InputGroupAddon
396
528
  align="inline-start"
@@ -399,80 +531,7 @@ export function InputArea({
399
531
  {plusButton}
400
532
  </InputGroupAddon>
401
533
  ) : null}
402
- <PromptInputBody>
403
- <div
404
- className={cn(
405
- "relative min-w-0",
406
- compact ? "flex-1" : "w-full self-stretch",
407
- )}
408
- >
409
- {prediction.suggestion ? (
410
- <div
411
- aria-hidden
412
- className={cn(
413
- textareaClassName,
414
- "pointer-events-none absolute inset-0 z-0 overflow-hidden whitespace-pre-wrap wrap-break-word text-foreground",
415
- )}
416
- >
417
- {input}
418
- <span className={predictionTextClassName}>
419
- {prediction.suggestion}
420
- </span>
421
- </div>
422
- ) : null}
423
- <PromptInputTextarea
424
- ref={inputRef}
425
- value={input}
426
- placeholder={placeholder}
427
- className={cn(
428
- textareaClassName,
429
- "relative z-10",
430
- prediction.suggestion &&
431
- "caret-current text-transparent",
432
- )}
433
- rows={1}
434
- role={slashMenu.comboboxProps.role}
435
- aria-multiline={slashMenu.comboboxProps["aria-multiline"]}
436
- aria-haspopup={slashMenu.comboboxProps["aria-haspopup"]}
437
- aria-autocomplete={
438
- slashMenu.comboboxProps["aria-autocomplete"]
439
- }
440
- aria-expanded={slashMenu.comboboxProps["aria-expanded"]}
441
- aria-controls={slashMenu.comboboxProps["aria-controls"]}
442
- aria-activedescendant={
443
- slashMenu.comboboxProps["aria-activedescendant"]
444
- }
445
- onKeyDown={(event) => {
446
- if (slashMenu.onKeyDown(event)) return;
447
- if (prediction.suggestion) {
448
- if (event.key === "Tab" && !event.shiftKey) {
449
- event.preventDefault();
450
- setInput(input + prediction.suggestion);
451
- return;
452
- }
453
- if (event.key === "Escape") {
454
- event.preventDefault();
455
- prediction.dismiss();
456
- return;
457
- }
458
- }
459
- if (
460
- event.key === "Backspace" &&
461
- event.currentTarget.selectionStart === 0 &&
462
- event.currentTarget.selectionEnd === 0 &&
463
- chipCount > 0
464
- ) {
465
- event.preventDefault();
466
- onRemoveLastChip?.();
467
- }
468
- }}
469
- onBlur={slashMenu.onBlur}
470
- onChange={(e) => {
471
- setInput(e.currentTarget.value);
472
- }}
473
- />
474
- </div>
475
- </PromptInputBody>
534
+ {body}
476
535
  {compact ? (
477
536
  <InputGroupAddon
478
537
  align="inline-end"
@@ -0,0 +1,268 @@
1
+ import {
2
+ useLayoutEffect,
3
+ useRef,
4
+ type ClipboardEvent,
5
+ type FocusEventHandler,
6
+ type KeyboardEvent,
7
+ type ReactNode,
8
+ type Ref,
9
+ } from "react";
10
+ import { cn } from "../../utils/cn";
11
+
12
+ export const CHIPS_HOST_ATTR = "data-composer-chips-inline";
13
+
14
+ /** Custom property the ghost `::after` reads its colour from. */
15
+ export const GHOST_COLOR_VAR = "--composer-ghost-color";
16
+
17
+ /**
18
+ * Editor base class. Placeholder and ghost text use `::after`, so both follow
19
+ * the chips on the same line. A class cannot reach a pseudo-element, so the
20
+ * ghost colour comes from `--composer-ghost-color`, read off a probe span that
21
+ * carries `suggestionClassName`. Muted foreground is the fallback.
22
+ */
23
+ export const INLINE_EDITOR_CLASS =
24
+ "relative block w-full cursor-text whitespace-pre-wrap wrap-break-word bg-transparent outline-hidden data-empty:after:pointer-events-none data-empty:after:select-none data-empty:after:text-muted-foreground data-empty:after:content-[attr(data-placeholder)] data-suggestion:after:pointer-events-none data-suggestion:after:select-none data-suggestion:after:text-[color:var(--composer-ghost-color,var(--color-muted-foreground))] data-suggestion:after:content-[attr(data-suggestion)]";
25
+
26
+ // `contents` so each chip is an inline atom in the editor's own flow (same as
27
+ // session-composer chip spans). A wrapping flex host becomes one box and puts
28
+ // the text in a column beside it.
29
+ const CHIPS_HOST_CLASS = "contents empty:hidden [&>*]:mr-1.5";
30
+
31
+ /** Reads the plain text of the editor. Skips the chips host. */
32
+ export function extractEditorText(root: HTMLElement): string {
33
+ let out = "";
34
+ const walk = (node: Node, isRoot: boolean) => {
35
+ node.childNodes.forEach((child, index) => {
36
+ if (child instanceof HTMLElement) {
37
+ if (child.hasAttribute(CHIPS_HOST_ATTR)) return;
38
+ if (child.tagName === "BR") {
39
+ // A trailing <br> only holds the line open.
40
+ if (isRoot && index === node.childNodes.length - 1) return;
41
+ out += "\n";
42
+ return;
43
+ }
44
+ if (child.tagName === "DIV" || child.tagName === "P") {
45
+ if (out !== "" && !out.endsWith("\n")) out += "\n";
46
+ walk(child, false);
47
+ return;
48
+ }
49
+ walk(child, false);
50
+ return;
51
+ }
52
+ if (child.nodeType === Node.TEXT_NODE) {
53
+ out += (child.textContent ?? "").replace(/\u200b/g, "");
54
+ }
55
+ });
56
+ };
57
+ walk(root, true);
58
+ return out;
59
+ }
60
+
61
+ function placeCaretAtEnd(el: HTMLElement) {
62
+ const sel = window.getSelection();
63
+ if (!sel) return;
64
+ const range = document.createRange();
65
+ range.selectNodeContents(el);
66
+ range.collapse(false);
67
+ sel.removeAllRanges();
68
+ sel.addRange(range);
69
+ }
70
+
71
+ /** True when nothing but the chips host sits before the caret. */
72
+ function caretAtTextStart(root: HTMLElement): boolean {
73
+ const sel = window.getSelection();
74
+ if (!sel || sel.rangeCount === 0 || !sel.isCollapsed) return false;
75
+ const anchor = sel.anchorNode;
76
+ if (!anchor || !root.contains(anchor)) return false;
77
+ const host = root.querySelector(`[${CHIPS_HOST_ATTR}]`);
78
+ if (host?.contains(anchor)) return false;
79
+ const probe = document.createRange();
80
+ probe.selectNodeContents(root);
81
+ if (host) probe.setStartAfter(host);
82
+ probe.setEnd(anchor, sel.anchorOffset);
83
+ return probe.toString().replace(/\u200b/g, "") === "";
84
+ }
85
+
86
+ function insertPlainText(root: HTMLElement, text: string) {
87
+ if (document.execCommand("insertText", false, text)) return;
88
+ const sel = window.getSelection();
89
+ if (!sel || sel.rangeCount === 0) return;
90
+ const range = sel.getRangeAt(0);
91
+ range.deleteContents();
92
+ const node = document.createTextNode(text);
93
+ range.insertNode(node);
94
+ range.setStartAfter(node);
95
+ range.collapse(true);
96
+ sel.removeAllRanges();
97
+ sel.addRange(range);
98
+ root.dispatchEvent(new Event("input", { bubbles: true }));
99
+ }
100
+
101
+ export type InputAreaEditorProps = {
102
+ editorRef?: Ref<HTMLDivElement>;
103
+ value: string;
104
+ onValueChange: (value: string) => void;
105
+ placeholder?: string;
106
+ suggestion?: string;
107
+ /** Classes for the ghost text. Its colour and opacity are copied to the `::after`. */
108
+ suggestionClassName?: string;
109
+ chips?: ReactNode;
110
+ chipCount?: number;
111
+ onRemoveLastChip?: () => void;
112
+ className?: string;
113
+ /** Runs first. Call `preventDefault` to stop the built-in Enter and Backspace handling. */
114
+ onKeyDown?: (event: KeyboardEvent<HTMLDivElement>) => void;
115
+ onBlur?: FocusEventHandler<HTMLDivElement>;
116
+ /** ARIA props, such as the slash menu combobox props. */
117
+ ariaProps?: Record<string, unknown>;
118
+ };
119
+
120
+ /**
121
+ * Contenteditable editor. The chips host is a non-editable inline atom at the
122
+ * start of the same text flow, so wrapped lines start under the chips. React
123
+ * owns the host only. The text nodes after it belong to the DOM and are
124
+ * reconciled against `value`.
125
+ */
126
+ export function InputAreaEditor({
127
+ editorRef,
128
+ value,
129
+ onValueChange,
130
+ placeholder,
131
+ suggestion,
132
+ suggestionClassName,
133
+ chips,
134
+ chipCount = 0,
135
+ onRemoveLastChip,
136
+ className,
137
+ onKeyDown,
138
+ onBlur,
139
+ ariaProps,
140
+ }: InputAreaEditorProps) {
141
+ const rootRef = useRef<HTMLDivElement | null>(null);
142
+ const composingRef = useRef(false);
143
+ const ghostProbeRef = useRef<HTMLSpanElement | null>(null);
144
+
145
+ const setRefs = (node: HTMLDivElement | null) => {
146
+ rootRef.current = node;
147
+ if (typeof editorRef === "function") editorRef(node);
148
+ else if (editorRef) editorRef.current = node;
149
+ };
150
+
151
+ useLayoutEffect(() => {
152
+ const root = rootRef.current;
153
+ if (!root || composingRef.current) return;
154
+ if (extractEditorText(root) === value) return;
155
+ Array.from(root.childNodes).forEach((child) => {
156
+ if (!(child instanceof HTMLElement && child.hasAttribute(CHIPS_HOST_ATTR))) {
157
+ root.removeChild(child);
158
+ }
159
+ });
160
+ if (value !== "") root.appendChild(document.createTextNode(value));
161
+ if (document.activeElement === root) placeCaretAtEnd(root);
162
+ });
163
+
164
+ // Copy the probe colour to the editor. Opacity below 1 is folded into the
165
+ // colour, so the `::after` needs one variable.
166
+ useLayoutEffect(() => {
167
+ const root = rootRef.current;
168
+ if (!root) return;
169
+ const probe = ghostProbeRef.current;
170
+ const style = probe ? getComputedStyle(probe) : null;
171
+ if (!style?.color) {
172
+ root.style.removeProperty(GHOST_COLOR_VAR);
173
+ return;
174
+ }
175
+ const opacity = Number.parseFloat(style.opacity);
176
+ root.style.setProperty(
177
+ GHOST_COLOR_VAR,
178
+ opacity >= 0 && opacity < 1
179
+ ? `color-mix(in srgb, ${style.color} ${opacity * 100}%, transparent)`
180
+ : style.color,
181
+ );
182
+ }, [suggestion, suggestionClassName]);
183
+
184
+ const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
185
+ onKeyDown?.(event);
186
+ if (event.defaultPrevented) return;
187
+ const root = event.currentTarget;
188
+ if (event.key === "Enter") {
189
+ if (composingRef.current || event.nativeEvent.isComposing) return;
190
+ if (event.shiftKey) return;
191
+ event.preventDefault();
192
+ const form = root.closest("form");
193
+ const submitButton = form?.querySelector<HTMLButtonElement>(
194
+ 'button[type="submit"]',
195
+ );
196
+ if (submitButton?.disabled) return;
197
+ form?.requestSubmit();
198
+ return;
199
+ }
200
+ if (
201
+ event.key === "Backspace" &&
202
+ chipCount > 0 &&
203
+ caretAtTextStart(root)
204
+ ) {
205
+ event.preventDefault();
206
+ onRemoveLastChip?.();
207
+ }
208
+ };
209
+
210
+ const handlePaste = (event: ClipboardEvent<HTMLDivElement>) => {
211
+ event.preventDefault();
212
+ const text = event.clipboardData.getData("text/plain");
213
+ if (text) insertPlainText(event.currentTarget, text);
214
+ };
215
+
216
+ return (
217
+ <>
218
+ <div
219
+ ref={setRefs}
220
+ data-slot="input-group-control"
221
+ contentEditable
222
+ suppressContentEditableWarning
223
+ spellCheck
224
+ aria-label={placeholder}
225
+ aria-multiline
226
+ {...ariaProps}
227
+ role="combobox"
228
+ data-empty={value === "" ? "" : undefined}
229
+ data-placeholder={placeholder}
230
+ data-suggestion={suggestion || undefined}
231
+ className={cn(INLINE_EDITOR_CLASS, className)}
232
+ onInput={(event) => {
233
+ if (composingRef.current) return;
234
+ onValueChange(extractEditorText(event.currentTarget));
235
+ }}
236
+ onCompositionStart={() => {
237
+ composingRef.current = true;
238
+ }}
239
+ onCompositionEnd={(event) => {
240
+ composingRef.current = false;
241
+ onValueChange(extractEditorText(event.currentTarget));
242
+ }}
243
+ onKeyDown={handleKeyDown}
244
+ onPaste={handlePaste}
245
+ onBlur={onBlur}
246
+ >
247
+ <span
248
+ contentEditable={false}
249
+ {...{ [CHIPS_HOST_ATTR]: "" }}
250
+ className={CHIPS_HOST_CLASS}
251
+ >
252
+ {chips}
253
+ </span>
254
+ </div>
255
+ {suggestion && suggestionClassName ? (
256
+ // Outside the editor, so reconciliation and the text value never see it.
257
+ // Zero-size, not `display: none`, so the computed colour is real.
258
+ <span
259
+ aria-hidden
260
+ data-composer-ghost-probe=""
261
+ className="pointer-events-none absolute size-0 select-none overflow-hidden text-foreground"
262
+ >
263
+ <span ref={ghostProbeRef} className={suggestionClassName} />
264
+ </span>
265
+ ) : null}
266
+ </>
267
+ );
268
+ }
@@ -9,20 +9,18 @@ function getMeasureContext(): CanvasRenderingContext2D | null {
9
9
  return context;
10
10
  }
11
11
 
12
+ /** Chips never force the stacked card: only files, newlines and a wrapped line do. */
12
13
  export function isComposerCompact({
13
14
  value,
14
15
  fileCount,
15
- chipCount,
16
16
  exceedsPill,
17
17
  }: {
18
18
  value: string;
19
19
  fileCount: number;
20
- chipCount: number;
21
20
  exceedsPill: boolean;
22
21
  }): boolean {
23
22
  if (fileCount > 0) return false;
24
- if (chipCount > 0) return false;
25
- if (value.trim().length === 0) return true;
23
+ if (value === "" || value === "\n") return true;
26
24
  if (value.includes("\n")) return false;
27
25
  return !exceedsPill;
28
26
  }
@@ -9,11 +9,9 @@ import {
9
9
  export function useComposerCompact({
10
10
  value,
11
11
  fileCount,
12
- chipCount,
13
12
  }: {
14
13
  value: string;
15
14
  fileCount: number;
16
- chipCount: number;
17
15
  }): { chromeRef: RefObject<HTMLDivElement | null>; compact: boolean } {
18
16
  "use no memo";
19
17
  const chromeRef = useRef<HTMLDivElement>(null);
@@ -26,7 +24,10 @@ export function useComposerCompact({
26
24
  const editor = editorNode instanceof HTMLElement ? editorNode : null;
27
25
  const font = editor ? getComputedStyle(editor).font : "14px sans-serif";
28
26
  if (editor && wasCompactRef.current) {
29
- const inner = readEditorInnerWidth(editor);
27
+ const chipsWidth =
28
+ editor.querySelector<HTMLElement>("[data-composer-chips-inline]")
29
+ ?.offsetWidth ?? 0;
30
+ const inner = readEditorInnerWidth(editor) - chipsWidth;
30
31
  if (inner > 0) pillWidthRef.current = inner;
31
32
  }
32
33
  const exceedsPill =
@@ -37,7 +38,6 @@ export function useComposerCompact({
37
38
  const compact = isComposerCompact({
38
39
  value,
39
40
  fileCount,
40
- chipCount,
41
41
  exceedsPill,
42
42
  });
43
43
  wasCompactRef.current = compact;
@@ -30,7 +30,7 @@ export function useComposerSlashMenu({
30
30
  value: string;
31
31
  onValueChange: (value: string) => void;
32
32
  items: ComposerSlashItem[];
33
- textareaRef: RefObject<HTMLTextAreaElement | null>;
33
+ textareaRef: RefObject<HTMLElement | null>;
34
34
  onSelect: (item: ComposerSlashItem) => void;
35
35
  /** When false the menu never opens. */
36
36
  enabled?: boolean;
@@ -122,7 +122,7 @@ export function useComposerSlashMenu({
122
122
  if (item) applyItem(item, trigger);
123
123
  };
124
124
 
125
- const onKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>): boolean => {
125
+ const onKeyDown = (event: KeyboardEvent<HTMLElement>): boolean => {
126
126
  if (!showPopup) return false;
127
127
  if (visibleItems.length > 0) {
128
128
  if (event.key === "ArrowDown") {
package/app/README.md DELETED
@@ -1,100 +0,0 @@
1
- # App helpers (`@vvedantb/ui`)
2
-
3
- Shared web React helpers that were duplicated across apps. Source-exported as the `./app` subpath of `@vvedantb/ui`.
4
-
5
- ## Install
6
-
7
- ```sh
8
- npm install @vvedantb/ui
9
- ```
10
-
11
- ## Peers
12
-
13
- Required: `react` and `react-dom` (>=19). Optional peers of `@vvedantb/ui`, needed only for the app helpers: `@clerk/clerk-react` (`useStableAuth`), `@tanstack/react-hotkeys` and `zod`.
14
-
15
- `sonner` is not a dependency. The update watcher takes the toast API as an option. The app source uses a local class join, which the root does not export (the root `cn` is the clsx and tailwind-merge one). Tailwind must scan `node_modules/@vvedantb/ui/app/src/**/*.{ts,tsx}` so the class names are generated, and the app needs the `@vvedantb/ui/tokens` names (`bg-surface-card`, `divide-separator`, `text-muted-foreground` and so on).
16
-
17
- ```css
18
- @source "../node_modules/@vvedantb/ui/app/src/**/*.{ts,tsx}";
19
- ```
20
-
21
- ## Confirm skip (Alt-click)
22
-
23
- ```tsx
24
- import { ConfirmSkipHint, skipConfirmTitle, useRequestConfirm } from "@vvedantb/ui";
25
-
26
- const { request } = useRequestConfirm();
27
-
28
- <DropdownMenuItem
29
- title={skipConfirmTitle("Delete")}
30
- onSelect={(event) => request(() => setOpen(true), () => remove(), event)}
31
- >
32
- Delete <ConfirmSkipHint />
33
- </DropdownMenuItem>
34
- ```
35
-
36
- - `useRequestConfirm` does not subscribe to Alt. It reads `getAltHeld()` when the request happens, so the menu owner does not re-render when Alt goes down. It returns `{ request }` only.
37
- - `ConfirmSkipHint` stays mounted and gains a `hidden` class when Alt is not held, so an open Radix menu keeps a stable child tree.
38
- - The Alt store resets when the last subscriber leaves, clears on blur and tab hide, and cancels a bare Alt keydown only inside `[role='menu']` or `[data-radix-menu-content]`.
39
- - Also exported: `useAltHeld`, `getAltHeld`, `createAltHeldStore`, `bindToWindow`, `shouldSkipConfirm`, `requestConfirm`.
40
-
41
- ## Update available watcher
42
-
43
- ```ts
44
- import { toast } from "sonner";
45
- import { startUpdateAvailableWatcher } from "@vvedantb/ui";
46
-
47
- useEffect(
48
- () =>
49
- startUpdateAvailableWatcher({
50
- origin: window.location.origin,
51
- toast,
52
- closeConvex: () => void convex.close(),
53
- enabled: !import.meta.env.DEV,
54
- }),
55
- [],
56
- );
57
- ```
58
-
59
- Options: `origin`, `toast`, `closeConvex?`, `pollMs?` (default 60000), `toastId?` (default `app-update-available`), `enabled?` (default true), `reload?` (default `window.location.reload`). On a changed asset fingerprint it shows a sticky "Update available" toast. Refresh calls `closeConvex` (errors ignored), then reloads. `fingerprintFromHtml` and `fetchDeployFingerprint` are exported too. The fingerprint looks for Vite `/assets/*.js` URLs in the index HTML.
60
-
61
- ## Settings chrome
62
-
63
- ```tsx
64
- import { SettingsPage, SettingsSection, SettingsToggleRow } from "@vvedantb/ui";
65
-
66
- <SettingsPage
67
- title="Notifications"
68
- layout={({ title, headerRight, toolbar, tabs, fillHeight, children }) => (
69
- <PageContainer title={title} rightSection={headerRight} toolbar={toolbar} tabs={tabs} noScroll={fillHeight}>
70
- {children}
71
- </PageContainer>
72
- )}
73
- >
74
- <SettingsSection title="Email" bodyVariant="list">
75
- <SettingsToggleRow title="Weekly digest" action={<Switch />} />
76
- </SettingsSection>
77
- </SettingsPage>
78
- ```
79
-
80
- Exports: `SettingsSection`, `SettingsField`, `SettingsStack`, `SettingsToggleRow`, `SettingsEmptyState` (icon is `ComponentType<{ size?: number; className?: string }>`), `SettingsPage`. Without `layout`, `SettingsPage` renders a plain `div` with a heading and the optional slots.
81
-
82
- ## useStableAuth
83
-
84
- ```ts
85
- import { useStableAuth } from "@vvedantb/ui"; // 2000 ms debounce, wraps Clerk useAuth
86
- import { createUseStableAuth } from "@vvedantb/ui";
87
-
88
- const useAuth = createUseStableAuth(useClerkAuth, { debounceMs: 3000 });
89
- ```
90
-
91
- After a signed-in session, a drop to signed-out reports `isLoaded: false` for the debounce window so Convex does not cascade. State is module-level. `resetStableAuthSession()` clears it and is for tests only.
92
-
93
- ## Develop
94
-
95
- ```sh
96
- pnpm --filter @vvedantb/ui typecheck
97
- pnpm --filter @vvedantb/ui test
98
- ```
99
-
100
- Source is in `app/src` and tests in `app/tests`. Publish steps are in the root `README.md`.
package/tokens/README.md DELETED
@@ -1,98 +0,0 @@
1
- # `@vvedantb/ui/tokens`
2
-
3
- Design tokens and shared Tailwind v4 CSS for the vvedantb web apps. The CSS and Tailwind preset are subpaths of `@vvedantb/ui`; the native JS tokens are named exports of the root (`tokens`, `Tokens`). Source-exported: no build step. Paths in the `File` column are relative to `packages/ui/tokens/`.
4
-
5
- ## Exports
6
-
7
- | Export | File | What it is |
8
- | --- | --- | --- |
9
- | `@vvedantb/ui/tokens`, `@vvedantb/ui/tokens/tokens.css` | `src/tokens.css` | Colour, radius, shadow, ease and font tokens (`@theme`), light/dark variables, `dark` variant, `*` border and `body` defaults. In 0.2.1 it also defines shadcn-style aliases (`primary`, `muted-foreground`, `secondary`, `destructive`, `card`, `popover`, `input`, `ring`) that point at the same tokens. |
10
- | `tokens` and `Tokens` from `@vvedantb/ui` | `src/native.ts` | JS colours, radii and spacing for React Native. `@vvedantb/native-ui` keeps its own copy in `src/tokens.ts`; a test keeps the two equal. |
11
- | `@vvedantb/ui/tokens/css/base.css` | `src/css/base.css` | `html` touch/accent, `::selection`, control transitions, pointer cursor, range accent (`@layer base`). |
12
- | `@vvedantb/ui/tokens/css/dialog.css` | `src/css/dialog.css` | Unlayered `max-width: none` for `@vvedantb/ui` compact (bottom-sheet) dialogs. |
13
- | `@vvedantb/ui/tokens/css/modal.css` | `src/css/modal.css` | `.t-modal`, `.is-open`, `.is-closing` and `t-modal-presence-wait` (`@layer components`). |
14
- | `@vvedantb/ui/tokens/css/tabs.css` | `src/css/tabs.css` | `.t-tab`, active colour, `.t-tabs-pill`, small-screen `.t-tabs` scrolling. |
15
- | `@vvedantb/ui/tokens/css/tabs-pills.css` | `src/css/tabs-pills.css` | `--tabs-*` colour variables, `.t-tabs--segmented`, `.t-tabs--pills` and pill hover / active fills. |
16
- | `@vvedantb/ui/tokens/css/utilities.css` | `src/css/utilities.css` | `@utility hit-target`, `@utility reveal-on-hover` and `@utility rounded-surface` (`clamp(0.75rem, var(--radius), 1rem)`). |
17
- | `@vvedantb/ui/tokens/css/scrollbar-thin.css` | `src/css/scrollbar-thin.css` | `.scrollbar-thin` (`@layer utilities`, always emitted). |
18
- | `@vvedantb/ui/tokens/css/animations.css` | `src/css/animations.css` | `animate-aurora`, `animate-fade-in-up`, `animate-indeterminate` as `@theme` (CSS-first apps). |
19
- | `@vvedantb/ui/tokens/css/compat-shadcn-rgb.css`, `@vvedantb/ui/tokens/compat/shadcn-rgb.css` | `src/css/compat-shadcn-rgb.css` | Replaces `tokens.css` for apps with shadcn-style `R G B` channel variables. Defines only the UI-only `--color-*` names as `rgb(var(--x) / alpha)` references, `--radius-field`, `--scrollbar`, `--field-radius` and a `glass-panel-strong` fallback. Import it **or** `tokens.css`, never both. |
20
- | `@vvedantb/ui/tokens/css/index.css` | `src/css/index.css` | base + dialog + utilities + scrollbar-thin + animations, for new CSS-first apps. |
21
- | `@vvedantb/ui/tokens/tailwind-preset` | `src/tailwind-preset.mjs` (+ `.d.mts`) | Tailwind preset for apps that load a JS config with `@config`. |
22
-
23
- Every file was extracted because the same rule (same at-rule context, selector and body) sits in 3 or more of the five apps. The measured duplication is in `verify/ANALYSIS.md`, and the proof that each app compiles to the same CSS after migrating is in `verify/RESULTS.md`. Blocks that only 2 apps share, or that drift in a way that would change an app's CSS, stay in the apps; `verify/ANALYSIS.md` lists them.
24
-
25
- ## Use
26
-
27
- CSS-first app (no `tailwind.config`):
28
-
29
- ```css
30
- @import "tailwindcss";
31
- @import "@vvedantb/ui/tokens";
32
- @import "@vvedantb/ui/tokens/css/animations.css";
33
- @import "@vvedantb/ui/tokens/css/base.css";
34
- /* ...app @layer base rules... */
35
- @import "@vvedantb/ui/tokens/css/modal.css";
36
- @import "@vvedantb/ui/tokens/css/tabs.css";
37
- /* ...app @layer components rules... */
38
- ```
39
-
40
- App with a JS config (`@config "../tailwind.config.ts"`):
41
-
42
- ```ts
43
- import { tokensPreset } from "@vvedantb/ui/tokens/tailwind-preset";
44
-
45
- export default {
46
- presets: [tokensPreset()],
47
- // or tokensPreset({ color: (name) => `color-mix(in oklch, var(--${name}) calc(<alpha-value> * 100%), transparent)` })
48
- content: ["./src/**/*.{ts,tsx}"],
49
- theme: { extend: { /* app-only colours, fonts, animations */ } },
50
- plugins: [tailwindcssAnimate],
51
- };
52
- ```
53
-
54
- The preset holds `darkMode: "class"`, the shared colour map (`border`, `surface.*`, `danger.*`, `field.*`, shadcn aliases `primary`/`secondary`/`destructive`/`card`/`popover`/`input` ...), `borderRadius` lg/md/sm/field, `boxShadow` soft/panel/insetSoft, `ease-smooth`, `screens` xs to 2xl in px, and the aurora / fade-in-up / indeterminate / accordion keyframes. It does not hold plugins, `content`, fonts, `accent`/`muted`/`ring`/`sidebar` (they differ per app) or accordion timings.
55
-
56
- Do not import `css/animations.css` in an app that uses the preset: the preset already defines the same keyframes and animations.
57
-
58
- ### Where to put the imports
59
-
60
- Tailwind inlines an `@import` where it sits, even in the middle of a file (it must be at top level, not inside an `@layer` block). Put each import where the app's own copy of the rules was. Within one layer, later rules win at equal specificity, so moving shared rules above or below app rules can flip the result for elements matched by both. `examples/migration.md` gives the exact position for each app, and `pnpm --filter @vvedantb/ui verify` checks it.
61
-
62
- ### Variables each file needs
63
-
64
- | File | Needs | Provided by |
65
- | --- | --- | --- |
66
- | `base.css` | `--accent`, `--foreground` | tokens.css or the app theme |
67
- | `dialog.css` | none | |
68
- | `modal.css` | `--modal-open-dur`, `--modal-close-dur`, `--modal-scale`, `--modal-scale-close`, `--modal-ease` | app `:root` |
69
- | `tabs.css` | `--tabs-dur`, `--tabs-ease` (app `:root`); `--tabs-text-muted`, `--tabs-text-active`, `--tabs-pill-bg` | colours: tabs-pills.css or the app theme |
70
- | `tabs-pills.css` | `--muted`, `--segment`, `--segment-foreground`, `--default` | tokens.css or the app theme |
71
- | `utilities.css` | `--breakpoint-sm` (Tailwind default or app theme) | Tailwind |
72
- | `scrollbar-thin.css` | `--scrollbar` | tokens.css or the app theme |
73
- | `animations.css` | none | |
74
- | `tailwind-preset` | the CSS variables named in its colour map, `--radius`, `--field-radius` | tokens.css or the app theme |
75
-
76
- ### Using an existing shadcn RGB theme
77
-
78
- See the package `README.md`. With the compat file, `base.css`, `scrollbar-thin.css`, `utilities.css`, `dialog.css`, `modal.css` and `tabs.css` need no `tokens.css`. `base.css` reads `--accent` and `--foreground` and `tabs-pills.css` reads `--muted`, `--segment` and `--default`. Those are not channel-compatible, so skip `tabs-pills.css` and `index.css` (which includes `base.css`) unless the app defines those variables as full colours.
79
-
80
- ### Parameters
81
-
82
- Where apps share a rule but drifted in one value, the value is a custom property whose fallback is the most common value. Set it on `:root` to keep a different value.
83
-
84
- | File | Property | Default | Set by |
85
- | --- | --- | --- | --- |
86
- | `base.css` | `--tokens-control-transition-property` | `background-color, border-color, color, box-shadow, transform, opacity` | app-c: `background-color, color, transform, opacity` |
87
- | `base.css` | `--tokens-control-transition-duration` | `240ms` | app-c: `100ms` |
88
-
89
- ## Develop
90
-
91
- ```sh
92
- pnpm --filter @vvedantb/ui build # export targets exist + tsc (primitives, shell, app)
93
- pnpm --filter @vvedantb/ui test # vitest (app helpers) + offline node --test: exports, CSS structure, Tailwind compile, preset
94
- pnpm --filter @vvedantb/ui analyze # rule-level duplication across the app clones -> verify/ANALYSIS.tables.md
95
- pnpm --filter @vvedantb/ui verify # before/after compile of every app -> verify/RESULTS.md, examples/migration.md
96
- ```
97
-
98
- `analyze` and `verify` need the app clones (`APPS_DIR`, default `/workspace/cvx`; name or symlink the clones `app-a` to `app-e` inside it) and network access the first time: they install Tailwind 4.3.3, the plugins, postcss and jiti into a scratch directory (`VERIFY_DIR`, default `/workspace/pk-tokens-verify`, outside the repo). The migration for each app is data in `verify/migrations/<app>.json`. Each removed block must equal the shared file's block with the same context and selector, or the run fails.