phase 9: enforce ui architecture imports

This commit is contained in:
Richard R 2026-06-01 04:18:19 -06:00
parent b4f30cf072
commit 740366a027
6 changed files with 77 additions and 38 deletions

View file

@ -23,6 +23,47 @@ const APP_DESIGN_SYSTEM_FILES = [
"src/app/(app)/**/*.{ts,tsx}", "src/app/(app)/**/*.{ts,tsx}",
"src/components/**/*.{ts,tsx}", "src/components/**/*.{ts,tsx}",
]; ];
const UI_ARCHITECTURE_FILES = [
"src/app/(app)/**/*.{ts,tsx}",
"src/components/auth/**/*.{ts,tsx}",
"src/components/doclist/**/*.{ts,tsx}",
"src/components/documents/DocumentUploader.tsx",
"src/components/documents/DocumentSelectionModal.tsx",
"src/components/documents/DexieMigrationModal.tsx",
"src/components/documents/ZoomControl.tsx",
"src/components/player/**/*.{ts,tsx}",
"src/components/reader/**/*.{ts,tsx}",
];
const COMPUTE_CORE_IMPORT_PATTERNS = [
{
group: [
"@openreader/compute-core/*",
"!@openreader/compute-core/local-runtime",
"!@openreader/compute-core/types",
"!@openreader/compute-core/api-contracts",
],
message:
"Use '@openreader/compute-core' root imports for light APIs. Allowed subpaths are '@openreader/compute-core/local-runtime', '@openreader/compute-core/types', and '@openreader/compute-core/api-contracts'.",
},
];
const UI_ARCHITECTURE_IMPORT_PATHS = [
{
name: "@/components/formPrimitives",
message:
"Use '@/components/ui' primitives in migrated app surfaces; formPrimitives is a compatibility shim.",
},
{
name: "@/components/ui/buttonPrimitives",
message:
"Use '@/components/ui' button exports in migrated app surfaces; buttonPrimitives is a compatibility shim.",
},
{
name: "@headlessui/react",
importNames: ["Button", "Input"],
message:
"Use app UI Button/Input primitives; Headless UI should be reserved for structural primitives such as Menu, Listbox, Popover, Dialog, and Transition.",
},
];
const RESTRICTED_APP_CLASS_PATTERNS = [ const RESTRICTED_APP_CLASS_PATTERNS = [
{ {
selector: "Literal[value=/(bg|text|border|ring)-(gray|slate|zinc|neutral|stone|red|orange|yellow|amber|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\\d/]", selector: "Literal[value=/(bg|text|border|ring)-(gray|slate|zinc|neutral|stone|red|orange|yellow|amber|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\\d/]",
@ -83,18 +124,7 @@ const eslintConfig = [
"no-restricted-imports": [ "no-restricted-imports": [
"error", "error",
{ {
patterns: [ patterns: COMPUTE_CORE_IMPORT_PATTERNS,
{
group: [
"@openreader/compute-core/*",
"!@openreader/compute-core/local-runtime",
"!@openreader/compute-core/types",
"!@openreader/compute-core/api-contracts",
],
message:
"Use '@openreader/compute-core' root imports for light APIs. Allowed subpaths are '@openreader/compute-core/local-runtime', '@openreader/compute-core/types', and '@openreader/compute-core/api-contracts'.",
},
],
}, },
], ],
}, },
@ -105,6 +135,18 @@ const eslintConfig = [
"no-restricted-syntax": ["error", ...RESTRICTED_APP_CLASS_PATTERNS], "no-restricted-syntax": ["error", ...RESTRICTED_APP_CLASS_PATTERNS],
}, },
}, },
{
files: UI_ARCHITECTURE_FILES,
rules: {
"no-restricted-imports": [
"error",
{
paths: UI_ARCHITECTURE_IMPORT_PATHS,
patterns: COMPUTE_CORE_IMPORT_PATTERNS,
},
],
},
},
{ {
files: ["src/app/api/**/*.ts", "src/lib/server/**/*.ts"], files: ["src/app/api/**/*.ts", "src/lib/server/**/*.ts"],
rules: { rules: {

View file

@ -1,6 +1,5 @@
'use client'; 'use client';
import { Button } from '@headlessui/react';
import Link from 'next/link'; import Link from 'next/link';
import { useAuthConfig } from '@/contexts/AuthRateLimitContext'; import { useAuthConfig } from '@/contexts/AuthRateLimitContext';
import { useFeatureFlag } from '@/contexts/RuntimeConfigContext'; import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
@ -8,6 +7,7 @@ import { useAuthSession } from '@/hooks/useAuthSession';
import { getAuthClient } from '@/lib/client/auth-client'; import { getAuthClient } from '@/lib/client/auth-client';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { UserIcon } from '@/components/icons/Icons'; import { UserIcon } from '@/components/icons/Icons';
import { IconButton, buttonClass } from '@/components/ui';
type UserMenuVariant = 'toolbar' | 'sidebar'; type UserMenuVariant = 'toolbar' | 'sidebar';
@ -55,15 +55,15 @@ export function UserMenu({
return ( return (
<div className={`flex gap-2 ${className}`}> <div className={`flex gap-2 ${className}`}>
<Link href="/signin"> <Link href="/signin">
<Button className="inline-flex items-center rounded-md bg-surface border border-line px-2 py-1 text-xs font-medium text-foreground hover:bg-accent-wash focus:outline-none focus:ring-2 focus:ring-accent-line focus:ring-offset-2 transform transition duration-base ease-standard hover:text-accent"> <span className={buttonClass({ variant: 'secondary', size: 'sm' })}>
Connect Connect
</Button> </span>
</Link> </Link>
{enableUserSignups && ( {enableUserSignups && (
<Link href="/signup"> <Link href="/signup">
<Button className="inline-flex items-center rounded-md bg-accent px-2 py-1 text-xs font-medium text-background hover:bg-secondary-accent focus:outline-none focus:ring-2 focus:ring-accent-line focus:ring-offset-2 transform transition duration-base ease-standard"> <span className={buttonClass({ variant: 'primary', size: 'sm' })}>
Create account Create account
</Button> </span>
</Link> </Link>
)} )}
</div> </div>
@ -95,17 +95,17 @@ export function UserMenu({
{session.user.email || 'Account'} {session.user.email || 'Account'}
</span> </span>
<Button <IconButton
onClick={handleDisconnectAccount} onClick={handleDisconnectAccount}
className="inline-flex items-center text-foreground text-xs hover:text-accent transform transition duration-base ease-standard"
title="Disconnect account" title="Disconnect account"
aria-label="Disconnect account"
> >
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path> <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
<polyline points="16 17 21 12 16 7"></polyline> <polyline points="16 17 21 12 16 7"></polyline>
<line x1="21" y1="12" x2="9" y2="12"></line> <line x1="21" y1="12" x2="9" y2="12"></line>
</svg> </svg>
</Button> </IconButton>
</div> </div>
); );
} }

View file

@ -2,10 +2,10 @@
import Link from 'next/link'; import Link from 'next/link';
import { useDrag, useDrop, type DragSourceMonitor } from 'react-dnd'; import { useDrag, useDrop, type DragSourceMonitor } from 'react-dnd';
import { Button } from '@headlessui/react';
import { PDFIcon, EPUBIcon, FileIcon } from '@/components/icons/Icons'; import { PDFIcon, EPUBIcon, FileIcon } from '@/components/icons/Icons';
import type { DocumentListDocument, IconSize } from '@/types/documents'; import type { DocumentListDocument, IconSize } from '@/types/documents';
import { DocumentPreview } from '@/components/doclist/DocumentPreview'; import { DocumentPreview } from '@/components/doclist/DocumentPreview';
import { IconButton } from '@/components/ui';
import { useDocumentSelection } from '../dnd/DocumentSelectionContext'; import { useDocumentSelection } from '../dnd/DocumentSelectionContext';
import { DND_DOCUMENT, documentIdentityKey, type DocumentDragItem } from '../dnd/dndTypes'; import { DND_DOCUMENT, documentIdentityKey, type DocumentDragItem } from '../dnd/dndTypes';
@ -185,9 +185,10 @@ export function DocumentTile({
</span> </span>
</Link> </Link>
{showDeleteButton && ( {showDeleteButton && (
<Button <IconButton
onClick={() => onDelete(doc)} onClick={() => onDelete(doc)}
className={`inline-flex items-center justify-center text-soft hover:text-accent hover:bg-surface focus:outline-none transition-colors duration-base ${TRASH_BTN_CLASSES[iconSize]}`} size="xs"
className={TRASH_BTN_CLASSES[iconSize]}
aria-label={`Delete ${doc.name}`} aria-label={`Delete ${doc.name}`}
> >
<svg className={TRASH_ICON_CLASSES[iconSize]} fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className={TRASH_ICON_CLASSES[iconSize]} fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -198,7 +199,7 @@ export function DocumentTile({
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/> />
</svg> </svg>
</Button> </IconButton>
)} )}
</div> </div>
</div> </div>

View file

@ -3,7 +3,6 @@
import Link from 'next/link'; import Link from 'next/link';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useDrag, useDrop } from 'react-dnd'; import { useDrag, useDrop } from 'react-dnd';
import { Button } from '@headlessui/react';
import type { import type {
DocumentListDocument, DocumentListDocument,
SortBy, SortBy,
@ -11,6 +10,7 @@ import type {
} from '@/types/documents'; } from '@/types/documents';
import { PDFIcon, EPUBIcon, FileIcon } from '@/components/icons/Icons'; import { PDFIcon, EPUBIcon, FileIcon } from '@/components/icons/Icons';
import { formatDocumentSize } from '@/components/doclist/formatSize'; import { formatDocumentSize } from '@/components/doclist/formatSize';
import { IconButton } from '@/components/ui';
import { useDocumentSelection } from '../dnd/DocumentSelectionContext'; import { useDocumentSelection } from '../dnd/DocumentSelectionContext';
import { DND_DOCUMENT, documentIdentityKey, type DocumentDragItem } from '../dnd/dndTypes'; import { DND_DOCUMENT, documentIdentityKey, type DocumentDragItem } from '../dnd/dndTypes';
@ -156,12 +156,12 @@ function DocRow({
<span className="px-2 text-[11px] text-soft tabular-nums"> <span className="px-2 text-[11px] text-soft tabular-nums">
{formatDate(doc.lastModified)} {formatDate(doc.lastModified)}
</span> </span>
<Button <IconButton
onClick={(e: React.MouseEvent) => { onClick={(e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
onDeleteDoc(doc); onDeleteDoc(doc);
}} }}
className="h-7 w-7 flex items-center justify-center text-soft hover:text-accent hover:bg-accent-wash rounded transition duration-base ease-standard" size="sm"
aria-label={`Delete ${doc.name}`} aria-label={`Delete ${doc.name}`}
> >
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -172,7 +172,7 @@ function DocRow({
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/> />
</svg> </svg>
</Button> </IconButton>
</div> </div>
); );
} }

View file

@ -4,7 +4,7 @@ import { Fragment, type ReactNode } from 'react';
import { Transition } from '@headlessui/react'; import { Transition } from '@headlessui/react';
import { XCircleIcon } from '@/components/icons/Icons'; import { XCircleIcon } from '@/components/icons/Icons';
import { useReaderSidebarBounds } from '@/hooks/useReaderSidebarBounds'; import { useReaderSidebarBounds } from '@/hooks/useReaderSidebarBounds';
import { buttonClass } from '@/components/ui/buttonPrimitives'; import { IconButton } from '@/components/ui';
interface ReaderSidebarShellProps { interface ReaderSidebarShellProps {
isOpen: boolean; isOpen: boolean;
@ -77,19 +77,15 @@ export function ReaderSidebarShell({
</div> </div>
<div className="flex items-center gap-1 shrink-0"> <div className="flex items-center gap-1 shrink-0">
{headerActions} {headerActions}
<button <IconButton
type="button"
onClick={onClose} onClick={onClose}
aria-label="Close" aria-label="Close"
title="Close" title="Close"
className={buttonClass({ tone="surface"
variant: 'secondary', className="text-soft"
size: 'icon',
className: 'h-8 w-8 text-soft',
})}
> >
<XCircleIcon className="w-4 h-4" /> <XCircleIcon className="w-4 h-4" />
</button> </IconButton>
</div> </div>
</div> </div>

View file

@ -8,7 +8,7 @@ import toast from 'react-hot-toast';
import { useTTS } from '@/contexts/TTSContext'; import { useTTS } from '@/contexts/TTSContext';
import { useConfig } from '@/contexts/ConfigContext'; import { useConfig } from '@/contexts/ConfigContext';
import { RefreshIcon, InfoIcon } from '@/components/icons/Icons'; import { RefreshIcon, InfoIcon } from '@/components/icons/Icons';
import { buttonClass } from '@/components/ui/buttonPrimitives'; import { buttonClass } from '@/components/ui';
import { ReaderSidebarShell } from '@/components/reader/ReaderSidebarShell'; import { ReaderSidebarShell } from '@/components/reader/ReaderSidebarShell';
import { compareSegmentLocators, locatorGroupKey, locatorIdentityKey } from '@/lib/shared/tts-locator'; import { compareSegmentLocators, locatorGroupKey, locatorIdentityKey } from '@/lib/shared/tts-locator';
import { buildSegmentKey, buildSegmentKeyPrefix } from '@/lib/shared/tts-segment-plan'; import { buildSegmentKey, buildSegmentKeyPrefix } from '@/lib/shared/tts-segment-plan';