@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 +10 -11
- package/shell/src/SessionsHoverCard.test.tsx +256 -0
- package/src/composer/input-area/InputArea.tsx +149 -90
- package/src/composer/input-area/InputAreaEditor.tsx +268 -0
- package/src/composer/input-area/composerCompact.ts +2 -4
- package/src/composer/input-area/useComposerCompact.ts +4 -4
- package/src/composer/input-area/useComposerSlashMenu.ts +2 -2
- package/app/README.md +0 -100
- package/tokens/README.md +0 -98
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vvedantb/ui",
|
|
3
|
-
"version": "0.4.
|
|
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
|
-
/**
|
|
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,
|
|
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
|
|
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<
|
|
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
|
|
212
|
+
"smooth-typing-input focus-visible:outline-hidden",
|
|
202
213
|
compact
|
|
203
214
|
? COMPACT_EDITOR
|
|
204
|
-
:
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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-[
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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<
|
|
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<
|
|
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.
|