diff --git a/apps/desktop/src/main/__tests__/ui-tsx-dead-export-contract.test.ts b/apps/desktop/src/main/__tests__/ui-tsx-dead-export-contract.test.ts new file mode 100644 index 0000000000..e2218be4b4 --- /dev/null +++ b/apps/desktop/src/main/__tests__/ui-tsx-dead-export-contract.test.ts @@ -0,0 +1,125 @@ +/** + * PR-UI-DEAD-EXPORT-SWEEP-0 — lock the rule "every named export in + * `packages/ui/src/ui.tsx` must have at least one consumer outside + * ui.tsx itself". + * + * Without this gate, `ui.tsx` slowly accumulates Base UI primitive + * re-exports + cva wrappers that nothing actually imports. They look + * cheap (`export const X = Base.Y`) but add real cost: every export + * shows up in autocomplete, in the bundle, in tsc work, and in design + * audits as "an option we offer". When ~30 of them are dead and ~50 + * are alive, design discussions get poisoned by phantom variants. + * + * Allowlist: + * - `ui.tsx` itself (internal references are allowed) + * - `components.tsx` (legacy mega-module; gradually being unwound + * by PR-UI-LIB-EXTRACT-N; counts as a consumer for this gate) + * + * If a new export is introduced with no immediate consumer, the + * caller has two options: + * 1. Wire the consumer in the same PR (preferred) + * 2. Add the symbol to ALLOWED_PENDING below with a justification + * and a target removal date (so it doesn't linger forever) + */ + +import { strict as assert } from 'node:assert'; +import { readFile, readdir } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { describe, it } from 'node:test'; + +const REPO_ROOT = resolve(import.meta.dirname, '../../../../..'); +const UI_FILE = resolve(REPO_ROOT, 'packages/ui/src/ui.tsx'); +const SOURCE_ROOTS = [ + resolve(REPO_ROOT, 'apps', 'desktop', 'src'), + resolve(REPO_ROOT, 'packages', 'ui', 'src'), + resolve(REPO_ROOT, 'packages', 'core', 'src'), + resolve(REPO_ROOT, 'packages', 'runtime', 'src'), + resolve(REPO_ROOT, 'packages', 'headless', 'src'), + resolve(REPO_ROOT, 'packages', 'storage', 'src'), +]; +const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx']); + +/** + * Symbols that are exported but currently lack an external consumer. + * Empty by design: PR-UI-DEAD-EXPORT-SWEEP-0 cleared the slate. Any + * future entry needs a one-line justification + target removal date. + */ +const ALLOWED_PENDING: ReadonlyArray<{ name: string; reason: string }> = [ + // intentionally empty +]; + +async function readSourceFiles(dir: string): Promise<{ path: string; content: string }[]> { + const entries = await readdir(dir, { withFileTypes: true }); + const files = await Promise.all( + entries.map(async (entry) => { + const entryPath = resolve(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name === '__tests__' || entry.name === 'node_modules' || entry.name === 'dist') return []; + return readSourceFiles(entryPath); + } + const ext = entryPath.slice(entryPath.lastIndexOf('.')); + if (!SOURCE_EXTENSIONS.has(ext)) return []; + const content = await readFile(entryPath, 'utf8'); + return [{ path: entryPath, content }]; + }), + ); + return files.flat(); +} + +function extractNamedExports(src: string): string[] { + // Match: `export const Foo`, `export function Foo`, `export interface Foo`, + // `export type Foo`, `export class Foo`. + const names = new Set(); + for (const m of src.matchAll(/^export (?:const|function|interface|type|class) ([A-Za-z][A-Za-z0-9_]*)/gm)) { + names.add(m[1]!); + } + // Match: `export { Foo } from './path.js'` — only `cn` re-export today, + // but include for completeness. + for (const m of src.matchAll(/^export \{ ([^}]+) \}/gm)) { + for (const part of m[1]!.split(',')) { + const name = part.trim().split(/\s+as\s+/)[0]?.trim(); + if (name && /^[A-Za-z]/.test(name)) names.add(name); + } + } + return [...names]; +} + +describe('PR-UI-DEAD-EXPORT-SWEEP-0 ui.tsx dead-export contract', () => { + it('every named export in ui.tsx has at least one consumer outside ui.tsx', async () => { + const [uiSrc, allSources] = await Promise.all([ + readFile(UI_FILE, 'utf8'), + Promise.all(SOURCE_ROOTS.map((root) => readSourceFiles(root))).then((groups) => groups.flat()), + ]); + const exports = extractNamedExports(uiSrc); + assert.ok(exports.length > 0, 'ui.tsx must continue to export symbols (sanity check)'); + + const pending = new Set(ALLOWED_PENDING.map((entry) => entry.name)); + const dead: string[] = []; + for (const name of exports) { + if (pending.has(name)) continue; + const re = new RegExp(`\\b${name}\\b`); + const hasConsumer = allSources.some((file) => { + if (file.path === UI_FILE) return false; + return re.test(file.content); + }); + if (!hasConsumer) dead.push(name); + } + + assert.deepEqual( + dead, + [], + `ui.tsx exports without any consumer outside ui.tsx (delete the export, wire a consumer, or add to ALLOWED_PENDING with a target removal date): ${dead.join(', ')}`, + ); + }); + + it('ALLOWED_PENDING does not accumulate stale entries', () => { + // The expectation here is that ALLOWED_PENDING stays empty by + // default. If it grows past a small handful, that's a signal the + // codebase has started reintroducing dead exports — at which point + // the cap forces a cleanup PR rather than letting the list rot. + assert.ok( + ALLOWED_PENDING.length <= 5, + `ALLOWED_PENDING has ${ALLOWED_PENDING.length} entries (cap 5). Either ship the consumers or delete the exports.`, + ); + }); +}); diff --git a/apps/desktop/src/main/__tests__/ui-tsx-design-contract.test.ts b/apps/desktop/src/main/__tests__/ui-tsx-design-contract.test.ts index 6c7285b7d4..50a40c6e75 100644 --- a/apps/desktop/src/main/__tests__/ui-tsx-design-contract.test.ts +++ b/apps/desktop/src/main/__tests__/ui-tsx-design-contract.test.ts @@ -45,7 +45,7 @@ const ALLOWED_BARE: ReadonlyArray<{ pattern: string; count: number; reason: stri pattern: 'z-50', count: 2, reason: - 'dialog popup (DialogContent) + sheet popup. The previously z-50 floating-overlay surfaces (TooltipPopup, SelectPopup, PopoverPopup) were tokenized to `z-[var(--z-overlay)]` so a Select opened from inside a Settings modal floats above the modal (WAWQAQ msg `d3ea9a33` 2026-06-26).', + 'dialog popup (DialogContent) + sheet popup. The previously z-50 floating-overlay surfaces (TooltipPopup, SelectPopup, PopoverPopup) were tokenized to `z-[var(--z-overlay)]` so a Select opened from inside a Settings modal floats above the modal (WAWQAQ msg `d3ea9a33` 2026-06-26). PR-UI-DEAD-EXPORT-SWEEP-0 then deleted PopoverPopup entirely (was unused).', }, { pattern: 'backdrop-blur-sm', @@ -53,12 +53,6 @@ const ALLOWED_BARE: ReadonlyArray<{ pattern: string; count: number; reason: stri reason: 'dialog + sheet backdrop visual depth. Pending kenji #6 audit decision on whether to drop blur entirely or tokenize a single --blur-scrim value.', }, - { - pattern: 'transition-[height]', - count: 1, - reason: - 'accordion content panel needs height animation because content is variable-height; `transform: scaleY` would distort children. Layout-property transition is intentional here.', - }, ]; describe('PR-FE-BUG-HUNT-12 ui.tsx design contract', () => { diff --git a/packages/ui/src/components.tsx b/packages/ui/src/components.tsx index f856ef12c9..9f5de795c9 100644 --- a/packages/ui/src/components.tsx +++ b/packages/ui/src/components.tsx @@ -180,7 +180,6 @@ import { import { Badge, Button as UiButton, - Card, Checkbox, DialogClose, DialogContent, diff --git a/packages/ui/src/ui.tsx b/packages/ui/src/ui.tsx index b72c30b1d9..683d91f640 100644 --- a/packages/ui/src/ui.tsx +++ b/packages/ui/src/ui.tsx @@ -1,25 +1,19 @@ import React, { forwardRef } from 'react'; -import { Accordion as BaseAccordion } from '@base-ui/react/accordion'; -import { Avatar as BaseAvatar } from '@base-ui/react/avatar'; import { Button as BaseButton } from '@base-ui/react/button'; import { Checkbox as BaseCheckbox } from '@base-ui/react/checkbox'; import { Dialog as BaseDialog } from '@base-ui/react/dialog'; import { Field as BaseField } from '@base-ui/react/field'; -import { Form as BaseForm } from '@base-ui/react/form'; -import { Popover as BasePopover } from '@base-ui/react/popover'; import { Progress as BaseProgress } from '@base-ui/react/progress'; import { Radio as BaseRadio } from '@base-ui/react/radio'; import { RadioGroup as BaseRadioGroup } from '@base-ui/react/radio-group'; -import { ScrollArea as BaseScrollArea } from '@base-ui/react/scroll-area'; import { Switch as BaseSwitch } from '@base-ui/react/switch'; import { Tabs as BaseTabs } from '@base-ui/react/tabs'; -import { Toast as BaseToast } from '@base-ui/react/toast'; import { Toggle as BaseToggle } from '@base-ui/react/toggle'; import { ToggleGroup as BaseToggleGroup } from '@base-ui/react/toggle-group'; import { Tooltip as BaseTooltip } from '@base-ui/react/tooltip'; import { Select as BaseSelect } from '@base-ui/react/select'; import { Separator as BaseSeparator } from '@base-ui/react/separator'; -import { Check, ChevronDown, ChevronRight, X } from './icons.js'; +import { Check, ChevronDown, X } from './icons.js'; import { cva, type VariantProps } from 'class-variance-authority'; import { cn } from './utils.js'; @@ -117,48 +111,7 @@ export function Badge({ className, variant, ...props }: BadgeProps) { return ; } -export const Card = forwardRef>(function Card( - { className, ...props }, - ref, -) { - return ( -
- ); -}); - -export const CardHeader = forwardRef>(function CardHeader( - { className, ...props }, - ref, -) { - return
; -}); - -export const CardTitle = forwardRef>(function CardTitle( - { className, ...props }, - ref, -) { - return

; -}); - -export const CardDescription = forwardRef>(function CardDescription( - { className, ...props }, - ref, -) { - return

; -}); - -export const CardContent = forwardRef>(function CardContent( - { className, ...props }, - ref, -) { - return

; -}); - -export const inputClasses = [ +const inputClasses = [ 'flex min-h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground shadow-sm', 'placeholder:text-muted-foreground/70', 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background', @@ -221,13 +174,10 @@ export const Checkbox = forwardRef< }); export const DialogRoot = BaseDialog.Root; -export const DialogTrigger = BaseDialog.Trigger; export const DialogClose = BaseDialog.Close; -export const DialogPortal = BaseDialog.Portal; -export const DialogTitle = BaseDialog.Title; -export const DialogDescription = BaseDialog.Description; +const DialogPortal = BaseDialog.Portal; -export const DialogBackdrop = forwardRef>(function DialogBackdrop( +const DialogBackdrop = forwardRef>(function DialogBackdrop( { className, ...props }, ref, ) { @@ -244,7 +194,7 @@ export const DialogBackdrop = forwardRef>(function DialogPopup( +const DialogPopup = forwardRef>(function DialogPopup( { className, children, ...props }, ref, ) { @@ -322,10 +272,7 @@ export const TabsPanel = forwardRef; }); -export const TooltipRoot = BaseTooltip.Root; export const TooltipTrigger = BaseTooltip.Trigger; -export const TooltipPortal = BaseTooltip.Portal; -export const TooltipPositioner = BaseTooltip.Positioner; export const TooltipPopup = forwardRef>(function TooltipPopup( { className, ...props }, ref, @@ -427,15 +374,7 @@ export const SelectItem = forwardRef>(function FieldError( - { className, ...props }, - ref, -) { - return ; -}); export const FieldDescription = forwardRef>(function FieldDescription( { className, ...props }, ref, @@ -563,45 +502,6 @@ export const Progress = forwardRef< ); }); -// ============================================================= -// ScrollArea -// Base UI provides native scroll container + custom scrollbars. -// ============================================================= - -export const ScrollAreaRoot = forwardRef< - HTMLDivElement, - React.ComponentPropsWithoutRef ->(function ScrollAreaRoot({ className, ...props }, ref) { - return ; -}); - -export const ScrollAreaViewport = forwardRef< - HTMLDivElement, - React.ComponentPropsWithoutRef ->(function ScrollAreaViewport({ className, ...props }, ref) { - return ; -}); - -export const ScrollAreaScrollbar = forwardRef< - HTMLDivElement, - React.ComponentPropsWithoutRef ->(function ScrollAreaScrollbar({ className, orientation = 'vertical', ...props }, ref) { - return ( - - - - ); -}); - // ============================================================= // Skeleton — animated loading placeholder // ============================================================= @@ -624,7 +524,6 @@ export const Skeleton = forwardRef< // Built on Base UI Dialog so it inherits a11y + focus trap. // ============================================================= -export const SheetRoot = BaseDialog.Root; export const SheetTrigger = BaseDialog.Trigger; export const SheetClose = BaseDialog.Close; export const SheetPortal = BaseDialog.Portal; @@ -644,7 +543,7 @@ export const SheetBackdrop = forwardRef< ); }); -export type SheetSide = 'right' | 'left' | 'top' | 'bottom'; +type SheetSide = 'right' | 'left' | 'top' | 'bottom'; const SHEET_SIDE_CLASSES: Record = { right: 'inset-y-0 right-0 h-full w-[min(420px,90vw)] border-l', left: 'inset-y-0 left-0 h-full w-[min(420px,90vw)] border-r', @@ -691,112 +590,6 @@ export const SheetContent = forwardRef< ); }); -// ============================================================= -// Avatar -// ============================================================= - -export const Avatar = forwardRef< - HTMLSpanElement, - React.ComponentPropsWithoutRef ->(function Avatar({ className, ...props }, ref) { - return ( - - ); -}); - -export const AvatarImage = forwardRef< - HTMLImageElement, - React.ComponentPropsWithoutRef ->(function AvatarImage({ className, ...props }, ref) { - return ; -}); - -export const AvatarFallback = forwardRef< - HTMLSpanElement, - React.ComponentPropsWithoutRef ->(function AvatarFallback({ className, ...props }, ref) { - return ; -}); - -// ============================================================= -// Accordion -// ============================================================= - -export const AccordionRoot = forwardRef< - HTMLDivElement, - React.ComponentPropsWithoutRef ->(function AccordionRoot({ className, ...props }, ref) { - return ; -}); - -export const AccordionItem = forwardRef< - HTMLDivElement, - React.ComponentPropsWithoutRef ->(function AccordionItem({ className, ...props }, ref) { - return ; -}); - -export const AccordionHeader = forwardRef< - HTMLHeadingElement, - React.ComponentPropsWithoutRef ->(function AccordionHeader({ className, ...props }, ref) { - return ; -}); - -export const AccordionTrigger = forwardRef< - HTMLButtonElement, - React.ComponentPropsWithoutRef ->(function AccordionTrigger({ className, children, ...props }, ref) { - return ( - svg]:rotate-90', - className, - )} - {...props} - > - {children} - - ); -}); - -export const AccordionPanel = forwardRef< - HTMLDivElement, - React.ComponentPropsWithoutRef ->(function AccordionPanel({ className, ...props }, ref) { - return ; -}); - -// ============================================================= -// Popover -// ============================================================= - -export const PopoverRoot = BasePopover.Root; -export const PopoverTrigger = BasePopover.Trigger; -export const PopoverPortal = BasePopover.Portal; -export const PopoverBackdrop = BasePopover.Backdrop; -export const PopoverPositioner = BasePopover.Positioner; -export const PopoverPopup = forwardRef< - HTMLDivElement, - React.ComponentPropsWithoutRef ->(function PopoverPopup({ className, ...props }, ref) { - return ( - - ); -}); - // Toast — left to the existing `packages/ui/src/toast.tsx` for now. // That module already wraps Base UI Toast with the project's // `useToast()` / `toast.confirm()` API. Rewriting it to expose the