@vvedantb/ui 0.5.3 → 0.5.4

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/README.md CHANGED
@@ -69,7 +69,7 @@ import { InputArea, InputBanners, buildComposerSlashItems } from "@vvedantb/ui";
69
69
 
70
70
  | Prop | Default | Notes |
71
71
  | --- | --- | --- |
72
- | `value`, `onChange`, `defaultValue` | uncontrolled, `""` | `onSubmit` clears the draft through `onChange("")` first. Restore it yourself if sending fails. |
72
+ | `value`, `onChange`, `defaultValue` | uncontrolled, `""` | `onSubmit` clears the draft through `onChange("")` first. Restore it yourself if sending fails. A value of `"\n"` shows as an empty editor with the placeholder, and the editor never reports a trailing line holder newline (0.5.4). |
73
73
  | `onSubmit(text)` | none | Trimmed text. Not called while `isSending`, or when send is disabled. |
74
74
  | `onStop`, `isStreaming`, `isSending`, `isUploading` | off | While streaming, stop shows next to send, and send queues. Sending or uploading shows a spinner and disables send. |
75
75
  | `canSubmit` | text, or `fileCount` or `chipCount` above 0 | Overrides when send is enabled. |
@@ -250,7 +250,7 @@ import {
250
250
  - `SelectField`: a labelled single select built from `Select`. Children are `SelectItem`s. An empty `value` shows the placeholder. `id` goes to the trigger and the label points at it. `className` merges onto the trigger.
251
251
  - Motion: `springTransition`, `springFastTransition` and `fadeUpSpring` presets. `FadePresence` takes optional `variants` (`hidden`, `show`, `exit`); omitted, it keeps its opacity fade. `PanelTransition` wraps children in React `ViewTransition` with `default="none"` and `enter`/`exit` classes (`vt-panel-in`, `vt-panel-out` by default). It needs a React build that exports `ViewTransition`, and the app supplies the `::view-transition-*` CSS.
252
252
 
253
- ## App shell (0.5.0 to 0.5.3)
253
+ ## App shell (0.5.0 to 0.5.4)
254
254
 
255
255
  A sidebar and main page layout, extracted from existing app markup. It is router-free: pass your own links (with `asChild`), state and media query result. Tooltips need a `TooltipProvider` above the shell.
256
256
 
@@ -291,7 +291,7 @@ import {
291
291
 
292
292
  - Widths: `AppShell` sets `--app-shell-rail-width` (default `4rem`), `--app-shell-sidebar-width` (rail plus panel, default `20rem`) and `--app-shell-mobile-header-height` (default `calc(3.5rem + env(safe-area-inset-top, 0px))`) as inline style. Change them with `railWidth`, `sidebarWidth` and `mobileHeaderHeight`. The other pieces read these variables. Outside `AppShell`, set the variables yourself or pass a width class with `className`.
293
293
  - `AppShell`: `sidebar`, `children`, `panelHidden` (main offsets by the rail width instead of the sidebar width), `skipLinkLabel` (`"Skip to main content"`), `mainId` (`"main-content"`), `className`, `mainClassName`, `contentClassName`.
294
- - `AppSidebar`: `rail`, `panel` (not rendered while `panelHidden`), `collapsed`, `panelHidden`, `isDesktop`, `mobileOpen`, `onMobileOpenChange`, `id` (default from `useId`), `asideProps` (`data-*` attributes for the `<aside>`), `className`, `mobileTitle`, `mobileHeaderActions` (0.5.2; trailing actions such as icon buttons, in place of the end spacer), `mobileMenuIcon` (`(open) => node`, default a menu icon), `mobileMenuLabel` (`"Open navigation menu"`), `closeLabel` (`"Close navigation"`). Swipe left or Escape closes the drawer. The drawer is `inert` while closed on mobile, and closes when `isDesktop` becomes `true`. Close it on route change in the app.
294
+ - `AppSidebar`: `rail`, `panel` (not rendered while `panelHidden`), `collapsed`, `panelHidden`, `isDesktop`, `mobileOpen`, `onMobileOpenChange`, `id` (default from `useId`), `asideProps` (`data-*` attributes for the `<aside>`), `className`, `mobileTitle`, `mobileHeaderActions` (0.5.2; trailing actions such as icon buttons, in place of the end spacer), `mobileMenuIcon` (`(open) => node`, default a menu icon), `mobileMenuLabel` (`"Open navigation menu"`), `closeLabel` (`"Close navigation"`). Swipe left or Escape closes the drawer. The drawer is `inert` while closed on mobile, and closes when `isDesktop` becomes `true`. Close it on route change in the app. The mobile header is a three-column grid, so the title and trailing actions stay on one row and a long title truncates (0.5.4).
295
295
  - `SidebarRail`: `top`, `children` (scroll body), `footer`, `showDivider`, `bodyAsChild` (the single child, for example a tabs list, becomes the body), `className`, `topClassName`, `bodyClassName`, `footerClassName`. Other HTML attributes (`id`, `data-*`, `aria-*`) go to the root `<div>`. The top and footer wrappers render only when their slot is set.
296
296
  - `RailTile`: `label` (`aria-label` and tooltip), `active`, `tooltip`, `showTooltip` (default `true`), `badge` (inside the tile, after the children; style it with `railBadgeClass`), `asChild`, `className`. Without `asChild` it is a native `<button type="button">` (0.5.3), so icons keep their authored size; other props go to it. With `asChild` the child (for example a router link) gets the tile classes and `aria-current="page"` when active.
297
297
  - `RailDivider`, `SidebarIconTooltip` (`label`, `enabled`, `side` `"right"`, `sideOffset` 8).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vvedantb/ui",
3
- "version": "0.5.3",
3
+ "version": "0.5.4",
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
+ }
@@ -96,9 +96,10 @@ export function AppSidebar({
96
96
 
97
97
  return (
98
98
  <>
99
+ {/* Three columns keep the title centred and stop it overlapping the actions: it truncates instead. */}
99
100
  <header
100
101
  onKeyDown={closeOnEscape}
101
- className="fixed inset-x-0 top-0 z-30 flex h-[var(--app-shell-mobile-header-height)] items-center gap-2 bg-surface px-3 pt-[env(safe-area-inset-top,0px)] pl-[max(0.75rem,env(safe-area-inset-left))] pr-[max(0.75rem,env(safe-area-inset-right))] md:hidden"
102
+ className="fixed inset-x-0 top-0 z-30 grid h-[var(--app-shell-mobile-header-height)] grid-cols-[minmax(max-content,1fr)_minmax(0,auto)_minmax(max-content,1fr)] items-center gap-2 bg-surface px-3 pt-[env(safe-area-inset-top,0px)] pl-[max(0.75rem,env(safe-area-inset-left))] pr-[max(0.75rem,env(safe-area-inset-right))] md:hidden"
102
103
  >
103
104
  <Button
104
105
  type="button"
@@ -108,21 +109,26 @@ export function AppSidebar({
108
109
  aria-label={mobileMenuLabel}
109
110
  aria-expanded={mobileOpen}
110
111
  aria-controls={drawerId}
111
- className={mobileMenuButtonClass}
112
+ className={cn(mobileMenuButtonClass, "justify-self-start")}
112
113
  >
113
114
  {mobileMenuIcon(mobileOpen)}
114
115
  </Button>
115
116
  {mobileTitle ? (
116
- <h1 className="pointer-events-none absolute inset-x-14 top-[env(safe-area-inset-top,0px)] bottom-0 flex items-center justify-center truncate text-center text-base font-semibold leading-none tracking-tight text-foreground text-balance">
117
+ <h1 className="pointer-events-none min-w-0 truncate text-center text-base font-semibold leading-tight tracking-tight text-foreground">
117
118
  {mobileTitle}
118
119
  </h1>
119
- ) : null}
120
+ ) : (
121
+ <span aria-hidden="true" />
122
+ )}
120
123
  {mobileHeaderActions ? (
121
- <div data-app-sidebar-mobile-actions="" className="ml-auto flex shrink-0 items-center gap-2">
124
+ <div
125
+ data-app-sidebar-mobile-actions=""
126
+ className="flex shrink-0 items-center justify-self-end gap-2"
127
+ >
122
128
  {mobileHeaderActions}
123
129
  </div>
124
130
  ) : (
125
- <div className="ml-auto h-11 w-11 shrink-0" aria-hidden="true" />
131
+ <div className="h-11 w-11 shrink-0 justify-self-end" aria-hidden="true" />
126
132
  )}
127
133
  </header>
128
134
 
@@ -30,16 +30,33 @@ const CHIPS_HOST_CLASS = "contents empty:hidden [&>*]:mr-1.5";
30
30
 
31
31
  const ZERO_WIDTH_SPACE = "\u200b";
32
32
 
33
- /** Reads the plain text of the editor. Skips the chips host. */
33
+ /**
34
+ * A value of "\n" is an empty draft: Shift+Enter on an empty editor, or a
35
+ * stray line holder that apps stored before 0.5.4. Mirrors `isComposerCompact`.
36
+ */
37
+ function isEmptyDraft(value: string): boolean {
38
+ return value === "" || value === "\n";
39
+ }
40
+
41
+ /**
42
+ * Reads the plain text of the editor. Skips the chips host. Under `pre-wrap` a
43
+ * single trailing "\n" renders no line, so Chromium ends the text with an extra
44
+ * "\n" that only holds the last line open. That holder, or a trailing `<br>`,
45
+ * is dropped once.
46
+ */
34
47
  export function extractEditorText(root: HTMLElement): string {
35
48
  let out = "";
49
+ let heldByBr = false;
36
50
  const walk = (node: Node, isRoot: boolean) => {
37
51
  node.childNodes.forEach((child, index) => {
38
52
  if (child instanceof HTMLElement) {
39
53
  if (child.hasAttribute(CHIPS_HOST_ATTR)) return;
40
54
  if (child.tagName === "BR") {
41
55
  // A trailing <br> only holds the line open.
42
- if (isRoot && index === node.childNodes.length - 1) return;
56
+ if (isRoot && index === node.childNodes.length - 1) {
57
+ heldByBr = true;
58
+ return;
59
+ }
43
60
  out += "\n";
44
61
  return;
45
62
  }
@@ -57,7 +74,29 @@ export function extractEditorText(root: HTMLElement): string {
57
74
  });
58
75
  };
59
76
  walk(root, true);
60
- return out;
77
+ return !heldByBr && out.endsWith("\n") ? out.slice(0, -1) : out;
78
+ }
79
+
80
+ /** The DOM text for a value: a trailing "\n" gets its line holder. */
81
+ function editorTextFor(value: string): string {
82
+ return value.endsWith("\n") ? `${value}\n` : value;
83
+ }
84
+
85
+ /** Every editor node except the chips host: the anchor, text and line breaks. */
86
+ function textNodes(root: HTMLElement): ChildNode[] {
87
+ return Array.from(root.childNodes).filter(
88
+ (child) => !(child instanceof HTMLElement && child.hasAttribute(CHIPS_HOST_ATTR)),
89
+ );
90
+ }
91
+
92
+ /**
93
+ * True when the DOM shows `shown`. An empty draft must be the bare U+200B
94
+ * anchor: a stray "\n" or `<br>` reads as "" but pushes the placeholder down.
95
+ */
96
+ function editorShows(root: HTMLElement, shown: string): boolean {
97
+ if (shown !== "") return extractEditorText(root) === shown;
98
+ const [only, ...rest] = textNodes(root);
99
+ return rest.length === 0 && only instanceof Text && only.data === ZERO_WIDTH_SPACE;
61
100
  }
62
101
 
63
102
  function chipsHost(root: HTMLElement): HTMLElement | null {
@@ -96,11 +135,14 @@ function setSelection(
96
135
  window.getSelection()?.setBaseAndExtent(anchorNode, anchorOffset, focusNode, focusOffset);
97
136
  }
98
137
 
99
- /** End of the text: the last text node, or the end of the editor after a line break. */
138
+ /**
139
+ * End of the text: the last text node before its trailing line holder, or the
140
+ * end of the editor after a line break.
141
+ */
100
142
  function textEnd(root: HTMLElement): [Node, number] {
101
143
  ensureCaretAnchor(root);
102
144
  const last = root.lastChild;
103
- if (last instanceof Text) return [last, last.length];
145
+ if (last instanceof Text) return [last, last.data.endsWith("\n") ? last.length - 1 : last.length];
104
146
  return [root, root.childNodes.length];
105
147
  }
106
148
 
@@ -276,13 +318,10 @@ export function InputAreaEditor({
276
318
  useLayoutEffect(() => {
277
319
  const root = rootRef.current;
278
320
  if (!root || composingRef.current) return;
279
- if (extractEditorText(root) !== value) {
280
- Array.from(root.childNodes).forEach((child) => {
281
- if (!(child instanceof HTMLElement && child.hasAttribute(CHIPS_HOST_ATTR))) {
282
- root.removeChild(child);
283
- }
284
- });
285
- root.appendChild(document.createTextNode(ZERO_WIDTH_SPACE + value));
321
+ const shown = isEmptyDraft(value) ? "" : value;
322
+ if (!editorShows(root, shown)) {
323
+ textNodes(root).forEach((child) => root.removeChild(child));
324
+ root.appendChild(document.createTextNode(ZERO_WIDTH_SPACE + editorTextFor(shown)));
286
325
  if (document.activeElement === root) placeCaretAtEnd(root);
287
326
  }
288
327
  ensureCaretAnchor(root);
@@ -361,7 +400,7 @@ export function InputAreaEditor({
361
400
  aria-multiline
362
401
  {...ariaProps}
363
402
  role="combobox"
364
- data-empty={value === "" ? "" : undefined}
403
+ data-empty={isEmptyDraft(value) ? "" : undefined}
365
404
  data-placeholder={placeholder}
366
405
  data-suggestion={suggestion || undefined}
367
406
  className={cn(INLINE_EDITOR_CLASS, className)}
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`. `SettingsField` `layout`: `"stacked"` (default) puts the label above the control. `"horizontal"` renders one row with the label and description on the left and the control on the right. 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.