From 8c931224eaa943a149052d8b6763f92b3ad9acd8 Mon Sep 17 00:00:00 2001 From: Abeuty Date: Fri, 28 Aug 2026 14:01:19 -0600 Subject: [PATCH 1/6] feat(ds): add Dialog and Drawer primitives, migrate 9 overlays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audited every overlay on the site and found 11 independent implementations across 6 positional postures. All 4 hand-rolled ones (no Radix) were missing focus trapping, focus restoration, or Escape-to-dismiss — the admin roles confirm was a destructive action a keyboard user could not dismiss. Adds two primitives, both Radix-backed with no opt-out: - Dialog — centered. Header/Body/Footer, sizes xs–xl, scrolling body capped at the viewport, tinted header (media + tint) for third-party brands, and DialogStatus for outcome panels. - Drawer — edge-anchored. side="right|left|bottom", sizes sm–2xl, and `fit` to size the panel to its content instead of filling the edge. Migrated onto them: LoginModal, AvatarCropModal, the npm-stats combine dialog, the admin roles confirm, BaselineSection, BuilderAssistant's model connections, both deploy dialogs, and BuilderGuideDialog (Drawer). The two deploy dialogs were byte-identical 1,065-line twins; they now share one header and one status panel and are fully off raw Tailwind colours. Tokens the primitives needed and the system did not have: - --color-scrim, heavier in dark (0.65 vs 0.5) — equal alpha reads as weaker separation over an already-dark page. Replaces 7 hand-picked black/NN values. - --z-scrim / --z-overlay, set to the 999/1000 pair already used by the majority, so adopting them moves nothing. Five stacking families existed. - Real dialog/drawer keyframes. The animate-in / fade-in-0 / zoom-in-95 classes used elsewhere come from tailwindcss-animate, which is NOT installed — they match zero CSS rules and animate nothing. Timing reuses the existing --motion-duration-* and --motion-ease-* tokens. Also rebalances the text scale. text-muted was #756c5b on #111111 — 3.64:1, below the 4.5:1 AA floor, so all muted copy in dark mode was failing. muted now takes the old secondary value and secondary lightens, adding ds-neutral-150 and -350 as ramp midpoints (not yet in Figma). Both roles now read at matched weight across themes: secondary 12.0/11.3, muted 7.9/7.8. Fixes DsKit's Swatch reading its hex once on mount, which left the palette and semantic pages showing #FFFFFF next to a black chip after a theme toggle. Documented at /ds/overlays (the audit itself), /ds/dialog and /ds/drawer. Not migrated, each blocked on a posture not yet built: SearchModal (command palette), CartDrawer (anchored panel), ProductDrawer (bottom sheet plus cross-panel chrome), LibrariesOverlay (full-bleed). Co-Authored-By: Claude Opus 5 --- src/components/AvatarCropModal.tsx | 71 ++- src/components/ExampleDeployDialog.tsx | 470 ++++++++-------- src/components/LoginModal.tsx | 44 +- .../application-starter/DeployDialog.tsx | 530 ++++++++---------- .../builder/BuilderAssistant.client.tsx | 182 +++--- src/components/charts/BuilderGuideDialog.tsx | 168 +++--- src/components/ds/DsKit.tsx | 19 +- src/components/ds/ds-nav.ts | 36 ++ .../overlay-audit/AvatarCropModalSpecimen.tsx | 62 ++ .../BuilderGuideDialogSpecimen.tsx | 71 +++ .../ds/overlay-audit/CartDrawerSpecimen.tsx | 89 +++ .../ExampleDeployDialogSpecimen.tsx | 177 ++++++ .../LibrariesOverlaySpecimen.tsx | 64 +++ .../ds/overlay-audit/LoginModalSpecimen.tsx | 52 ++ .../overlay-audit/NpmStatsDialogSpecimen.tsx | 35 ++ .../overlay-audit/ProductDrawerSpecimen.tsx | 93 +++ .../RolesConfirmDialogSpecimen.tsx | 45 ++ .../ds/overlay-audit/SearchModalSpecimen.tsx | 59 ++ .../StarterDeployDialogSpecimen.tsx | 92 +++ src/components/ds/overlay-audit/index.ts | 15 + .../ds/overlay-audit/specimen-meta.ts | 293 ++++++++++ src/components/ds/overlay-audit/types.ts | 4 + src/components/ds/ui/Dialog.tsx | 326 +++++++++++ src/components/ds/ui/Drawer.tsx | 221 ++++++++ src/components/ds/ui/index.tsx | 23 + src/components/npm-stats/BaselineSection.tsx | 36 +- src/routeTree.gen.ts | 63 +++ src/routes/admin/roles.$roleId.tsx | 102 ++-- src/routes/ds.dialog.tsx | 464 +++++++++++++++ src/routes/ds.drawer.tsx | 298 ++++++++++ src/routes/ds.overlays.tsx | 283 ++++++++++ src/routes/ds.palette.tsx | 21 +- src/routes/stats/npm/index.tsx | 39 +- src/styles/app.css | 243 +++++++- 34 files changed, 3909 insertions(+), 881 deletions(-) create mode 100644 src/components/ds/overlay-audit/AvatarCropModalSpecimen.tsx create mode 100644 src/components/ds/overlay-audit/BuilderGuideDialogSpecimen.tsx create mode 100644 src/components/ds/overlay-audit/CartDrawerSpecimen.tsx create mode 100644 src/components/ds/overlay-audit/ExampleDeployDialogSpecimen.tsx create mode 100644 src/components/ds/overlay-audit/LibrariesOverlaySpecimen.tsx create mode 100644 src/components/ds/overlay-audit/LoginModalSpecimen.tsx create mode 100644 src/components/ds/overlay-audit/NpmStatsDialogSpecimen.tsx create mode 100644 src/components/ds/overlay-audit/ProductDrawerSpecimen.tsx create mode 100644 src/components/ds/overlay-audit/RolesConfirmDialogSpecimen.tsx create mode 100644 src/components/ds/overlay-audit/SearchModalSpecimen.tsx create mode 100644 src/components/ds/overlay-audit/StarterDeployDialogSpecimen.tsx create mode 100644 src/components/ds/overlay-audit/index.ts create mode 100644 src/components/ds/overlay-audit/specimen-meta.ts create mode 100644 src/components/ds/overlay-audit/types.ts create mode 100644 src/components/ds/ui/Dialog.tsx create mode 100644 src/components/ds/ui/Drawer.tsx create mode 100644 src/routes/ds.dialog.tsx create mode 100644 src/routes/ds.drawer.tsx create mode 100644 src/routes/ds.overlays.tsx diff --git a/src/components/AvatarCropModal.tsx b/src/components/AvatarCropModal.tsx index 47772969c..d71d2512f 100644 --- a/src/components/AvatarCropModal.tsx +++ b/src/components/AvatarCropModal.tsx @@ -1,9 +1,14 @@ import * as React from 'react' -import * as DialogPrimitive from '@radix-ui/react-dialog' import Cropper from 'react-easy-crop' import type { Area, Point } from 'react-easy-crop' -import { XIcon } from '@phosphor-icons/react' import { Button } from '~/ui' +import { + Dialog, + DialogBody, + DialogContent, + DialogFooter, + DialogHeader, +} from '~/components/ds/ui' interface AvatarCropModalProps { open: boolean @@ -107,20 +112,12 @@ export function AvatarCropModal({ }, [open]) return ( - - - - -
- - Crop Profile Picture - - - - -
+ + + -
+ +
@@ -149,28 +146,28 @@ export function AvatarCropModal({ step={0.1} value={zoom} onChange={(e) => setZoom(Number(e.target.value))} - className="w-full h-2 bg-gray-200 dark:bg-gray-700 rounded-lg appearance-none cursor-pointer accent-blue-500" + className="w-full h-2 bg-background-subtle rounded-lg appearance-none cursor-pointer accent-ds-blue-400" />
- -
- - -
- - - +
+ + + + + + +
) } diff --git a/src/components/ExampleDeployDialog.tsx b/src/components/ExampleDeployDialog.tsx index 7c0fc9a97..900f5b7fe 100644 --- a/src/components/ExampleDeployDialog.tsx +++ b/src/components/ExampleDeployDialog.tsx @@ -18,6 +18,13 @@ import { import { twMerge } from 'tailwind-merge' import { useAsyncDebouncer } from '@tanstack/react-pacer' import { Button, GitHub } from '~/ui' +import { + Dialog, + DialogBody, + DialogContent, + DialogHeader, + DialogStatus, +} from '~/components/ds/ui' import { useDeployAuth } from './application-starter/useDeployAuth' import { type DeployProvider, @@ -222,259 +229,234 @@ export function ExampleDeployDialog({ exampleName, ]) - if (!isOpen) return null - return ( -
- {/* Backdrop */} - -
- )} - - {state.step === 'form' && ( -
-
- -
- setRepoName(e.target.value)} - placeholder="my-tanstack-app" - className={twMerge( - 'w-full px-3 py-2 pr-9 text-sm bg-white dark:bg-gray-800 border rounded-lg focus:outline-none focus:ring-2', - repoNameStatus === 'taken' || repoNameStatus === 'invalid' - ? 'border-red-400 dark:border-red-500 focus:ring-red-500' - : repoNameStatus === 'available' - ? 'border-green-400 dark:border-green-500 focus:ring-green-500' - : 'border-gray-300 dark:border-gray-600 focus:ring-blue-500 dark:focus:ring-cyan-500', - )} - /> -
- {repoNameStatus === 'checking' && ( - - )} - {repoNameStatus === 'available' && ( - - )} - {(repoNameStatus === 'taken' || - repoNameStatus === 'invalid') && ( - - )} + !open && onClose()}> + + } + tint={providerInfo.color} + /> + + +
+ {state.step === 'auth-check' && ( + + )} + + {state.step === 'needs-auth' && ( + } + title="GitHub Authorization Required" + description="To deploy this example, we need permission to create a repository on your GitHub account." + actions={ + + } + /> + )} + + {state.step === 'form' && ( +
+
+ +
+ setRepoName(e.target.value)} + placeholder="my-tanstack-app" + aria-invalid={ + repoNameStatus === 'taken' || + repoNameStatus === 'invalid' + } + aria-describedby={ + repoNameError ? 'repo-name-error' : undefined + } + className={twMerge( + 'w-full px-3 py-2 pr-9 text-sm bg-background-default border rounded-lg focus:outline-none focus:ring-2', + repoNameStatus === 'taken' || + repoNameStatus === 'invalid' + ? 'border-border-error focus:ring-status-error' + : repoNameStatus === 'available' + ? 'border-border-success focus:ring-status-success' + : 'border-border-default focus:ring-border-focus', + )} + /> +
+ {repoNameStatus === 'checking' && ( + + )} + {repoNameStatus === 'available' && ( + + )} + {(repoNameStatus === 'taken' || + repoNameStatus === 'invalid') && ( + + )} +
+ {repoNameError && ( +

+ {repoNameError} +

+ )}
- {repoNameError && ( -

{repoNameError}

- )} -
-
- - Visibility - -
- - +
+ + Visibility + +
+ + +
-
-
- Source: - {libraryName} / {examplePath} -
+
+ Source: + {libraryName} / {examplePath} +
-
- - -
-
- )} - - {state.step === 'deploying' && ( -
- -

- {state.message} -

-
- )} - - {state.step === 'success' && ( -
-
- +
+ + +
-

- Repository Created! -

- - {state.owner}/{state.repoName} - - - {countdown !== null && countdown > 0 && ( -

- Redirecting to {providerInfo.name} in {countdown}s... -

- )} - + } > - - Deploy Now - -
- )} - - {state.step === 'error' && ( -
-
- -
-

- Deployment Failed -

-

- {state.message} -

-
- - -
-
- )} -
-
-
+ {state.owner}/{state.repoName} + + + {countdown !== null && countdown > 0 && ( +

+ Redirecting to {providerInfo.name} in {countdown}state... +

+ )} + + )} + + {state.step === 'error' && ( + } + title="Deployment Failed" + description={state.message} + actions={ + <> + + + + } + /> + )} +
+ + + ) } diff --git a/src/components/LoginModal.tsx b/src/components/LoginModal.tsx index e4fa296a9..14bad9dc8 100644 --- a/src/components/LoginModal.tsx +++ b/src/components/LoginModal.tsx @@ -1,8 +1,12 @@ -import * as DialogPrimitive from '@radix-ui/react-dialog' -import { XIcon } from '@phosphor-icons/react/X' import { GithubIcon } from '~/components/icons/GithubIcon' import { GoogleIcon } from '~/components/icons/GoogleIcon' import { authClient } from '~/auth/client' +import { + Dialog, + DialogBody, + DialogContent, + DialogHeader, +} from '~/components/ds/ui' interface LoginModalProps { open: boolean @@ -24,35 +28,15 @@ export function LoginModal({ } return ( - - - - -
- - Sign in to continue - - - - -
- - - {description ?? 'Choose a sign-in method.'} - - + + + +
-
-
-
+ + + ) } diff --git a/src/components/application-starter/DeployDialog.tsx b/src/components/application-starter/DeployDialog.tsx index 1f4910fdb..471b01320 100644 --- a/src/components/application-starter/DeployDialog.tsx +++ b/src/components/application-starter/DeployDialog.tsx @@ -17,6 +17,13 @@ import { import { twMerge } from 'tailwind-merge' import { useAsyncDebouncer } from '@tanstack/react-pacer' import { Button, GitHub } from '~/ui' +import { + Dialog, + DialogBody, + DialogContent, + DialogHeader, + DialogStatus, +} from '~/components/ds/ui' import { useDeployAuth } from './useDeployAuth' import { useFeatures, @@ -286,300 +293,259 @@ export function DeployDialog({ providerInfo, ]) - if (!isOpen) return null - return ( -
- {/* Backdrop */} - -
- )} - - {state.step === 'form' && ( -
-
- -
- setRepoName(e.target.value)} - placeholder="my-tanstack-app" - className={twMerge( - 'w-full px-3 py-2 pr-9 text-sm bg-white dark:bg-gray-800 border rounded-lg focus:outline-none focus:ring-2', - repoNameStatus === 'taken' || repoNameStatus === 'invalid' - ? 'border-red-400 dark:border-red-500 focus:ring-red-500' - : repoNameStatus === 'available' - ? 'border-green-400 dark:border-green-500 focus:ring-green-500' - : 'border-gray-300 dark:border-gray-600 focus:ring-blue-500 dark:focus:ring-cyan-500', - )} - /> -
- {repoNameStatus === 'checking' && ( - - )} - {repoNameStatus === 'available' && ( - - )} - {(repoNameStatus === 'taken' || - repoNameStatus === 'invalid') && ( - - )} + description={ + providerInfo + ? 'Create a GitHub repo and deploy' + : 'Push your project to GitHub' + } + media={ + providerInfo ? ( + + ) : ( + + ) + } + tint={providerInfo ? providerInfo.color : '#332d24'} + /> + + +
+ {state.step === 'auth-check' && ( + + )} + + {state.step === 'needs-auth' && ( + } + title="GitHub Authorization Required" + description="To deploy, we need permission to create repositories on your GitHub account." + actions={ + + } + /> + )} + + {state.step === 'form' && ( +
+
+ +
+ setRepoName(e.target.value)} + placeholder="my-tanstack-app" + aria-invalid={ + repoNameStatus === 'taken' || + repoNameStatus === 'invalid' + } + aria-describedby={ + repoNameError ? 'repo-name-error' : undefined + } + className={twMerge( + 'w-full px-3 py-2 pr-9 text-sm bg-background-default border rounded-lg focus:outline-none focus:ring-2', + repoNameStatus === 'taken' || + repoNameStatus === 'invalid' + ? 'border-border-error focus:ring-status-error' + : repoNameStatus === 'available' + ? 'border-border-success focus:ring-status-success' + : 'border-border-default focus:ring-border-focus', + )} + /> +
+ {repoNameStatus === 'checking' && ( + + )} + {repoNameStatus === 'available' && ( + + )} + {(repoNameStatus === 'taken' || + repoNameStatus === 'invalid') && ( + + )} +
+
+ {repoNameError && ( +

+ {repoNameError} +

+ )} +
+ +
+ + Visibility + +
+ +
- {repoNameError && ( -

{repoNameError}

+ + {features.length > 0 && ( +
+ Integrations: + {features.join(', ')} +
)} -
-
- - Visibility - -
- - + -
-
- - {features.length > 0 && ( -
- Integrations: - {features.join(', ')} + + {providerInfo ? 'Create & Deploy' : 'Create Repository'} +
- )} - -
- - -
-
- )} - - {state.step === 'deploying' && ( -
- -

- {state.message} -

-
- )} - - {state.step === 'success' && ( -
-
-
-

- Repository Created! -

- { - trackDialogLinkClick('repo') - }} - className="text-sm text-blue-600 dark:text-cyan-400 hover:underline flex items-center gap-1 mb-4" + )} + + {state.step === 'deploying' && ( + + )} + + {state.step === 'success' && ( + } + title="Repository Created!" + actions={ + providerInfo ? ( + + ) : ( + + ) + } > - {state.owner}/{state.repoName} - - - {providerInfo ? ( - <> - {countdown !== null && countdown > 0 && ( -

- Redirecting to {providerInfo.name} in {countdown}s... -

- )} - {(() => { - const deployUrl = providerInfo.deployUrl( - state.owner, - state.repoName, - ) - - return ( - - ) - })()} - - ) : ( - - )} -
- )} - - {state.step === 'error' && ( -
-
- -
-

- Deployment Failed -

-

- {state.message} -

- -
- )} -
-
-
+ {state.owner}/{state.repoName} + + + {providerInfo && countdown !== null && countdown > 0 && ( +

+ Redirecting to {providerInfo.name} in {countdown}state... +

+ )} + + )} + + {state.step === 'error' && ( + } + title="Deployment Failed" + description={state.message} + actions={ + <> + + + + } + /> + )} +
+ + + ) } diff --git a/src/components/builder/BuilderAssistant.client.tsx b/src/components/builder/BuilderAssistant.client.tsx index 6e94f8a2e..aed28e8dd 100644 --- a/src/components/builder/BuilderAssistant.client.tsx +++ b/src/components/builder/BuilderAssistant.client.tsx @@ -1,5 +1,4 @@ import * as React from 'react' -import * as DialogPrimitive from '@radix-ui/react-dialog' import { ArrowDownIcon, CaretDownIcon, @@ -19,6 +18,10 @@ import type { ByokClient, ByokSnapshot } from '@tanstack/ai-client/byok' import { useVirtualizer } from '@tanstack/react-virtual' import { Button, + Dialog, + DialogBody, + DialogContent, + DialogHeader, Dropdown, DropdownContent, DropdownItem, @@ -3654,110 +3657,89 @@ function ConnectionsDialog({ onUnlock: (provider: BuilderAiRemoteProvider) => Promise }) { return ( - - - - -
- - Model connections - - - - -
- - Connect a ChatGPT plan or configure an API key. - -
- {showChatGpt ? ( -
-

ChatGPT

- {chatGpt.connected ? ( -
-
-

- {chatGpt.email || 'Connected'} +

+ {/* `sandbox-ui` scopes the builder's own type/colour context; the DS + panel supplies posture, elevation and behaviour. */} + + + + {showChatGpt ? ( +
+

ChatGPT

+ {chatGpt.connected ? ( +
+
+

+ {chatGpt.email || 'Connected'} +

+ {chatGpt.planType ? ( +

+ {formatPlan(chatGpt.planType)} plan

- {chatGpt.planType ? ( -

- {formatPlan(chatGpt.planType)} plan -

- ) : null} -
- + ) : null}
- ) : chatGptLogin ? ( - - ) : ( - <> - - {chatGptError ? ( - + +
+ ) : chatGptLogin ? ( + + ) : ( + <> +
- ) : null} -
-

API key

- + Continue with ChatGPT + + {chatGptError ? ( + + ) : null} + + )}
+ ) : null} +
+

API key

+
- - - + + + ) } diff --git a/src/components/charts/BuilderGuideDialog.tsx b/src/components/charts/BuilderGuideDialog.tsx index 9134cac21..27dadb595 100644 --- a/src/components/charts/BuilderGuideDialog.tsx +++ b/src/components/charts/BuilderGuideDialog.tsx @@ -1,7 +1,13 @@ -import * as DialogPrimitive from '@radix-ui/react-dialog' -import { ArrowSquareOutIcon, XIcon } from '@phosphor-icons/react' +import { ArrowSquareOutIcon } from '@phosphor-icons/react' import type * as React from 'react' -import { Button } from '~/components/ds/ui' +import { + Button, + Drawer, + DrawerBody, + DrawerContent, + DrawerHeader, + DrawerTrigger, +} from '~/components/ds/ui' import { builderEnvironmentRules, builderImportAliases, @@ -17,102 +23,80 @@ export function BuilderGuideDialog({ children: React.ReactNode }) { return ( - - {children} - - - -
- - Builder guide - -
- - - - -
-
+ + {children} + + + Plain text +
-
-
+ + + + + + ) } diff --git a/src/components/ds/DsKit.tsx b/src/components/ds/DsKit.tsx index 78298819a..fb8bec834 100644 --- a/src/components/ds/DsKit.tsx +++ b/src/components/ds/DsKit.tsx @@ -277,11 +277,24 @@ export function Swatch({ token }: { token: string }) { const swatchRef = React.useRef(null) React.useEffect(() => { - if (!swatchRef.current) return + const el = swatchRef.current + if (!el) return + // Read the *computed* background so var()-referencing semantic tokens // resolve to a real color, not the literal "var(--…)" declaration. - const resolved = getComputedStyle(swatchRef.current).backgroundColor - setHex(rgbToHex(resolved)) + const read = () => setHex(rgbToHex(getComputedStyle(el).backgroundColor)) + read() + + // Re-read on theme change. The swatch itself recolours on its own because + // it renders `var(--color-…)`, but the hex label is state and would other- + // wise keep the value it was mounted with — showing #FFFFFF next to a + // black chip after a toggle. + const observer = new MutationObserver(read) + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ['class'], + }) + return () => observer.disconnect() }, [token]) const handleCopy = React.useCallback(async () => { diff --git a/src/components/ds/ds-nav.ts b/src/components/ds/ds-nav.ts index 7c021296a..622df754b 100644 --- a/src/components/ds/ds-nav.ts +++ b/src/components/ds/ds-nav.ts @@ -141,6 +141,32 @@ export const dsNav: Array = [ to: '/ds/cards', sections: ['Card', 'Inline code', 'Blog post card', 'Tooltip'], }, + { + label: 'Dialog', + to: '/ds/dialog', + sections: [ + 'Basic', + 'Sizes', + 'Scrolling body', + 'Destructive confirm', + 'Tinted header', + 'Status panel', + 'Token coverage', + 'Behaviour from Radix', + ], + }, + { + label: 'Drawer', + to: '/ds/drawer', + sections: [ + 'Sides', + 'Sizes', + 'Fit to content', + 'Header actions', + 'With a footer', + 'Motion', + ], + }, { label: 'Dropdown', to: '/ds/dropdown', @@ -174,6 +200,16 @@ export const dsNav: Array = [ 'Source', ], }, + { + label: 'Overlay Audit', + to: '/ds/overlays', + sections: [ + 'Launch', + 'Property matrix', + 'Divergence', + 'Notes per specimen', + ], + }, { label: 'Page Header', to: '/ds/page-header', diff --git a/src/components/ds/overlay-audit/AvatarCropModalSpecimen.tsx b/src/components/ds/overlay-audit/AvatarCropModalSpecimen.tsx new file mode 100644 index 000000000..bd4bf99ab --- /dev/null +++ b/src/components/ds/overlay-audit/AvatarCropModalSpecimen.tsx @@ -0,0 +1,62 @@ +import * as DialogPrimitive from '@radix-ui/react-dialog' +import { XIcon } from '@phosphor-icons/react' +import { Button } from '~/ui' +import type { SpecimenProps } from './types' + +/** + * AUDIT SPECIMEN — verbatim shell of `src/components/AvatarCropModal.tsx`. + * The react-easy-crop surface is replaced by a static stand-in; the shell, + * footer action row and close affordance are unchanged. + */ +export function AvatarCropModalSpecimen({ open, onOpenChange }: SpecimenProps) { + return ( + + + + +
+ + Crop Profile Picture + + + + +
+ +
+
+
+ +
+ + +
+ +
+ + +
+ + + + ) +} diff --git a/src/components/ds/overlay-audit/BuilderGuideDialogSpecimen.tsx b/src/components/ds/overlay-audit/BuilderGuideDialogSpecimen.tsx new file mode 100644 index 000000000..b3e9d507e --- /dev/null +++ b/src/components/ds/overlay-audit/BuilderGuideDialogSpecimen.tsx @@ -0,0 +1,71 @@ +import * as DialogPrimitive from '@radix-ui/react-dialog' +import { ArrowSquareOutIcon, XIcon } from '@phosphor-icons/react' +import { Button } from '~/components/ds/ui' +import type { SpecimenProps } from './types' + +/** + * AUDIT SPECIMEN — verbatim shell of + * `src/components/charts/BuilderGuideDialog.tsx`. + * + * The strongest specimen in the set and the best starting point for the DS + * primitive: semantic tokens throughout (`border-border-default`, + * `bg-background-surface`, `text-text-primary`), real enter/exit animation via + * Radix data-state, a fixed header over an independently scrolling body, and a + * responsive posture that is full-bleed on mobile and a right-edge sheet from + * `sm` up. + */ +export function BuilderGuideDialogSpecimen({ + open, + onOpenChange, +}: SpecimenProps) { + return ( + + + + +
+ + Builder guide + +
+ + + + +
+
+ + + Module rules, available imports, and authoring tips. + + +
+ {Array.from({ length: 8 }).map((_, i) => ( +
+

+ Section {i + 1} +

+

+ Long-form guide content. This body scrolls independently of + the fixed header above, which is the behaviour the DS panel + needs to preserve. +

+
+ ))} +
+
+
+
+ ) +} diff --git a/src/components/ds/overlay-audit/CartDrawerSpecimen.tsx b/src/components/ds/overlay-audit/CartDrawerSpecimen.tsx new file mode 100644 index 000000000..3f1813134 --- /dev/null +++ b/src/components/ds/overlay-audit/CartDrawerSpecimen.tsx @@ -0,0 +1,89 @@ +import * as Dialog from '@radix-ui/react-dialog' +import { ShoppingCartIcon, XIcon } from '@phosphor-icons/react' +import { twMerge } from 'tailwind-merge' +import type { SpecimenProps } from './types' + +/** + * AUDIT SPECIMEN — verbatim shell of `src/components/shop/CartDrawer.tsx`. + * + * Two things to notice. First, it sits on a completely separate token + * namespace (`bg-shop-bg-2`, `border-shop-line`, `text-shop-text`) defined in + * `src/styles/shop.css`, which is only loaded on `/shop` routes — the DS audit + * page has to link that stylesheet explicitly for this to render. + * + * Second, it is anchored rather than edge-flush: offset from the navbar via + * `--navbar-height` and inset from the right by `1rem`, with its own `z-[100]` + * tier that does not match either the `z-50` or `z-[999]/[1000]` families. + */ +export function CartDrawerSpecimen({ open, onOpenChange }: SpecimenProps) { + return ( + + + + +
+ + Cart (2) + + + + +
+ +
    + {['Classic Tee', 'Sticker Pack'].map((name) => ( +
  • +
    +
    +

    {name}

    +

    Qty 1

    +
    + $28.00 +
  • + ))} +
+ +
+
+ Subtotal + $56.00 +
+ +
+
+
+
+ ) +} + +/** The drawer's own empty state — one of seven improvised empty states found. */ +export function CartDrawerEmptySpecimen() { + return ( +
+ +

Your cart is empty.

+
+ ) +} diff --git a/src/components/ds/overlay-audit/ExampleDeployDialogSpecimen.tsx b/src/components/ds/overlay-audit/ExampleDeployDialogSpecimen.tsx new file mode 100644 index 000000000..300bbf191 --- /dev/null +++ b/src/components/ds/overlay-audit/ExampleDeployDialogSpecimen.tsx @@ -0,0 +1,177 @@ +import * as React from 'react' +import { + CheckCircleIcon, + CircleNotchIcon, + RocketIcon, + WarningCircleIcon, +} from '@phosphor-icons/react' +import type { SpecimenProps } from './types' + +type Step = 'auth-check' | 'needs-auth' | 'form' | 'success' | 'error' + +const STEPS: Array = [ + 'auth-check', + 'needs-auth', + 'form', + 'success', + 'error', +] + +/** + * AUDIT SPECIMEN — verbatim shell of `src/components/ExampleDeployDialog.tsx`. + * + * Note the shell is byte-identical to StarterDeployDialogSpecimen: same + * `fixed inset-0 z-50`, same button-as-backdrop, same + * `max-w-md ... rounded-xl shadow-2xl border` panel. Neither uses Radix, so + * neither traps focus, restores focus on close, or closes on Escape. + * + * The step switcher is an audit affordance, not part of the original — it + * exposes all five wizard states that the real dialog reaches over time. + */ +export function ExampleDeployDialogSpecimen({ + open, + onOpenChange, +}: SpecimenProps) { + const [step, setStep] = React.useState('form') + + if (!open) return null + + const providerColor = '#F38020' + + return ( +
+ {/* Backdrop */} + + +
+
+ )} + + {step === 'success' && ( +
+
+ +
+

+ Deployment started. This can take a couple of minutes. +

+
+ )} + + {step === 'error' && ( +
+
+ +
+

+ Deployment failed. Check your account permissions and retry. +

+
+ )} +
+ + {/* Audit-only step switcher */} +
+ + Audit + + {STEPS.map((s) => ( + + ))} +
+
+
+ ) +} diff --git a/src/components/ds/overlay-audit/LibrariesOverlaySpecimen.tsx b/src/components/ds/overlay-audit/LibrariesOverlaySpecimen.tsx new file mode 100644 index 000000000..cfd1e3ade --- /dev/null +++ b/src/components/ds/overlay-audit/LibrariesOverlaySpecimen.tsx @@ -0,0 +1,64 @@ +import * as DialogPrimitive from '@radix-ui/react-dialog' +import { XIcon } from '@phosphor-icons/react' +import type { SpecimenProps } from './types' + +/** + * AUDIT SPECIMEN — verbatim shell of `src/components/LibrariesOverlay.tsx`. + * + * The immersive full-bleed posture: no panel, no visible chrome, content + * scrolls edge to edge and the close affordance floats over it on its own + * `z-[112]` tier. Uses the semantic token layer and a bespoke glass treatment + * (`.libraries-overlay-glass`, `.animate-library-overlay-in` in app.css) + * instead of a shared elevation or motion token. + */ +export function LibrariesOverlaySpecimen({ + open, + onOpenChange, +}: SpecimenProps) { + return ( + + + + + + Libraries + + + Browse every TanStack library. + + + + + + +
+ {[ + 'Router', + 'Query', + 'Table', + 'Form', + 'Store', + 'Virtual', + 'Ranger', + 'Pacer', + 'DB', + ].map((name) => ( +
+

TanStack {name}

+

+ Full-bleed overlay content, scrolling edge to edge. +

+
+ ))} +
+
+
+
+ ) +} diff --git a/src/components/ds/overlay-audit/LoginModalSpecimen.tsx b/src/components/ds/overlay-audit/LoginModalSpecimen.tsx new file mode 100644 index 000000000..0ba27230f --- /dev/null +++ b/src/components/ds/overlay-audit/LoginModalSpecimen.tsx @@ -0,0 +1,52 @@ +import * as DialogPrimitive from '@radix-ui/react-dialog' +import { XIcon } from '@phosphor-icons/react/X' +import type { SpecimenProps } from './types' + +/** + * AUDIT SPECIMEN — verbatim shell of `src/components/LoginModal.tsx`. + * + * Copied for side-by-side review only. Auth calls are stubbed; every class + * name on the overlay, panel, header and close button is unchanged from the + * original so the comparison is honest. Do not import this outside /ds. + */ +export function LoginModalSpecimen({ open, onOpenChange }: SpecimenProps) { + return ( + + + + +
+ + Sign in to continue + + + + +
+ + + Choose a sign-in method. + + +
+ + +
+
+
+
+ ) +} diff --git a/src/components/ds/overlay-audit/NpmStatsDialogSpecimen.tsx b/src/components/ds/overlay-audit/NpmStatsDialogSpecimen.tsx new file mode 100644 index 000000000..d41585da9 --- /dev/null +++ b/src/components/ds/overlay-audit/NpmStatsDialogSpecimen.tsx @@ -0,0 +1,35 @@ +import * as DialogPrimitive from '@radix-ui/react-dialog' +import { XIcon } from '@phosphor-icons/react' +import type { SpecimenProps } from './types' + +/** + * AUDIT SPECIMEN — verbatim shell of the inline dialog in + * `src/routes/stats/npm/index.tsx` (~line 1084). A fourth independent + * re-declaration of the centered Radix panel, defined inside a route file + * rather than a component. + */ +export function NpmStatsDialogSpecimen({ open, onOpenChange }: SpecimenProps) { + return ( + + + + +
+ + Compare packages + + + + +
+

+ Add up to five npm packages to chart their download trends together. +

+
+
+
+ ) +} diff --git a/src/components/ds/overlay-audit/ProductDrawerSpecimen.tsx b/src/components/ds/overlay-audit/ProductDrawerSpecimen.tsx new file mode 100644 index 000000000..e56be6664 --- /dev/null +++ b/src/components/ds/overlay-audit/ProductDrawerSpecimen.tsx @@ -0,0 +1,93 @@ +import * as React from 'react' +import { XIcon } from '@phosphor-icons/react' +import { twMerge } from 'tailwind-merge' +import type { SpecimenProps } from './types' + +/** + * AUDIT SPECIMEN — verbatim shell of `src/components/shop/ProductDrawer.tsx`. + * + * The only bottom-sheet posture on the site, and hand-rolled rather than + * Radix: the Escape handler below is copied from the original because there is + * no primitive supplying it. Uses three separate z-tiers (`60` scrim, `70` + * sheet, `71` side arrows) and is the one overlay already wired to the motion + * tokens (`--motion-duration-fast`, `--motion-ease-standard`). + */ +export function ProductDrawerSpecimen({ open, onOpenChange }: SpecimenProps) { + // Copied verbatim from the original — hand-rolled because there is no + // shared primitive providing dismiss-on-Escape. + React.useEffect(() => { + if (!open) return + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onOpenChange(false) + } + document.addEventListener('keydown', onKeyDown) + return () => document.removeEventListener('keydown', onKeyDown) + }, [open, onOpenChange]) + + if (!open) return null + + return ( + <> + + +
+
+
+
+

Classic Tee

+

+ A bottom-anchored sheet. Note there is no focus trap and no + focus restoration on close — both would come free from Radix. +

+ +
+
+
+
+ + {/* Side navigation arrows — a third z-tier above the sheet */} + + + + ) +} diff --git a/src/components/ds/overlay-audit/RolesConfirmDialogSpecimen.tsx b/src/components/ds/overlay-audit/RolesConfirmDialogSpecimen.tsx new file mode 100644 index 000000000..3ef433d50 --- /dev/null +++ b/src/components/ds/overlay-audit/RolesConfirmDialogSpecimen.tsx @@ -0,0 +1,45 @@ +import type { SpecimenProps } from './types' + +/** + * AUDIT SPECIMEN — verbatim shell of the inline confirm dialog in + * `src/routes/admin/roles.$roleId.tsx` (~line 326). + * + * The worst case in the audit and worth keeping visible: no portal, no Radix, + * no focus trap, no Escape handler, no labelled dialog role, no close button, + * and no max-height. Rendered inline in the route tree. + */ +export function RolesConfirmDialogSpecimen({ + open, + onOpenChange, +}: SpecimenProps) { + if (!open) return null + + return ( +
+
+

+ Confirm Removal +

+

+ Are you sure you want to remove this user from the role? +

+
+ + +
+
+
+ ) +} diff --git a/src/components/ds/overlay-audit/SearchModalSpecimen.tsx b/src/components/ds/overlay-audit/SearchModalSpecimen.tsx new file mode 100644 index 000000000..ccab94482 --- /dev/null +++ b/src/components/ds/overlay-audit/SearchModalSpecimen.tsx @@ -0,0 +1,59 @@ +import * as DialogPrimitive from '@radix-ui/react-dialog' +import { MagnifyingGlassIcon } from '@phosphor-icons/react' +import type { SpecimenProps } from './types' + +/** + * AUDIT SPECIMEN — verbatim shell of `src/components/SearchModal.tsx` + * (the outer 40 lines of a 3,766-line file). + * + * The command-palette posture: full-bleed below `sm`, then top-anchored and + * horizontally centred with a `max-w-4xl` cap — the only overlay that pins to + * the top rather than the centre. Uses `forceMount` so its own CSS transitions + * (`.search-modal-panel-transition`) can run instead of Radix data-state + * animations, and overrides `onInteractOutside` to survive nested portals. + * + * This one should be the last thing migrated to any new primitive, not the + * first — but the DS panel must be able to express this posture. + */ +export function SearchModalSpecimen({ open, onOpenChange }: SpecimenProps) { + return ( + + + + + + Search TanStack + + + Search TanStack and open TanStack AI from the current query. + + +
+
+ + + + ESC + +
+
+ {['Quick Start', 'Installation', 'Data Loading', 'SSR'].map( + (label) => ( +
+ {label} +
+ ), + )} +
+
+
+
+
+ ) +} diff --git a/src/components/ds/overlay-audit/StarterDeployDialogSpecimen.tsx b/src/components/ds/overlay-audit/StarterDeployDialogSpecimen.tsx new file mode 100644 index 000000000..8c789d3b1 --- /dev/null +++ b/src/components/ds/overlay-audit/StarterDeployDialogSpecimen.tsx @@ -0,0 +1,92 @@ +import { GitHub } from '~/ui' +import { twMerge } from 'tailwind-merge' +import type { SpecimenProps } from './types' + +/** + * AUDIT SPECIMEN — verbatim shell of + * `src/components/application-starter/DeployDialog.tsx`. + * + * Kept alongside ExampleDeployDialogSpecimen deliberately. The two originals + * are 480 and 585 lines and their shells are identical down to the class + * order; they differ only in the header's provider branding and the payload + * of the wizard steps. This is the clearest single case for extraction in the + * whole audit. + */ +export function StarterDeployDialogSpecimen({ + open, + onOpenChange, +}: SpecimenProps) { + if (!open) return null + + const providerInfo = null + + return ( +
+ {/* Backdrop */} + + +
+
+ + + ) +} diff --git a/src/components/ds/overlay-audit/index.ts b/src/components/ds/overlay-audit/index.ts new file mode 100644 index 000000000..ec6e0a94c --- /dev/null +++ b/src/components/ds/overlay-audit/index.ts @@ -0,0 +1,15 @@ +export type { SpecimenProps } from './types' +export { LoginModalSpecimen } from './LoginModalSpecimen' +export { AvatarCropModalSpecimen } from './AvatarCropModalSpecimen' +export { NpmStatsDialogSpecimen } from './NpmStatsDialogSpecimen' +export { RolesConfirmDialogSpecimen } from './RolesConfirmDialogSpecimen' +export { ExampleDeployDialogSpecimen } from './ExampleDeployDialogSpecimen' +export { StarterDeployDialogSpecimen } from './StarterDeployDialogSpecimen' +export { BuilderGuideDialogSpecimen } from './BuilderGuideDialogSpecimen' +export { + CartDrawerSpecimen, + CartDrawerEmptySpecimen, +} from './CartDrawerSpecimen' +export { ProductDrawerSpecimen } from './ProductDrawerSpecimen' +export { LibrariesOverlaySpecimen } from './LibrariesOverlaySpecimen' +export { SearchModalSpecimen } from './SearchModalSpecimen' diff --git a/src/components/ds/overlay-audit/specimen-meta.ts b/src/components/ds/overlay-audit/specimen-meta.ts new file mode 100644 index 000000000..87eded38b --- /dev/null +++ b/src/components/ds/overlay-audit/specimen-meta.ts @@ -0,0 +1,293 @@ +/** + * Audited properties of every overlay currently shipping on the site. + * Read off the source, not inferred — each `source` path is the original. + */ + +export type Posture = + | 'centered' + | 'anchored-panel' + | 'edge-sheet' + | 'bottom-sheet' + | 'top-anchored' + | 'full-bleed' + +export type Base = 'radix' | 'hand-rolled' + +export type TokenLayer = 'semantic' | 'raw-tailwind' | 'shop-scope' + +export type SpecimenMeta = { + id: string + name: string + source: string + sourceLines: number + posture: Posture + base: Base + tokens: TokenLayer + zIndex: string + overlay: string + width: string + /** Radix supplies these for free; hand-rolled dialogs must implement them. */ + focusTrap: boolean + focusRestore: boolean + escape: boolean + scrollLock: boolean + animated: boolean + notes: string +} + +export const SPECIMENS: Array = [ + { + id: 'login', + name: 'LoginModal', + source: 'src/components/LoginModal.tsx', + sourceLines: 73, + posture: 'centered', + base: 'radix', + tokens: 'raw-tailwind', + zIndex: '999 / 1000', + overlay: 'bg-black/60 backdrop-blur-sm', + width: 'max-w-xs', + focusTrap: true, + focusRestore: true, + escape: true, + scrollLock: true, + animated: false, + notes: + 'The baseline centered panel. Identical shell to AvatarCropModal and the npm stats dialog apart from max-width.', + }, + { + id: 'avatar-crop', + name: 'AvatarCropModal', + source: 'src/components/AvatarCropModal.tsx', + sourceLines: 176, + posture: 'centered', + base: 'radix', + tokens: 'raw-tailwind', + zIndex: '999 / 1000', + overlay: 'bg-black/60 backdrop-blur-sm', + width: 'max-w-md', + focusTrap: true, + focusRestore: true, + escape: true, + scrollLock: true, + animated: false, + notes: + 'Adds a footer action row (Cancel / Save) that no other centered dialog shares — a footer slot the primitive needs.', + }, + { + id: 'npm-stats', + name: 'npm stats dialog', + source: 'src/routes/stats/npm/index.tsx', + sourceLines: 40, + posture: 'centered', + base: 'radix', + tokens: 'raw-tailwind', + zIndex: '999 / 1000', + overlay: 'bg-black/60 backdrop-blur-sm', + width: 'max-w-md', + focusTrap: true, + focusRestore: true, + escape: true, + scrollLock: true, + animated: false, + notes: + 'A fourth copy of the same shell, declared inline in a route file. Adds a mobile gutter (w-[calc(100%-1rem)]) the other three lack.', + }, + { + id: 'roles-confirm', + name: 'Roles confirm', + source: 'src/routes/admin/roles.$roleId.tsx', + sourceLines: 20, + posture: 'centered', + base: 'hand-rolled', + tokens: 'raw-tailwind', + zIndex: '50', + overlay: 'bg-black/50 (no blur)', + width: 'max-w-md', + focusTrap: false, + focusRestore: false, + escape: false, + scrollLock: false, + animated: false, + notes: + 'Worst case. No portal, no dialog role, no close button, no Escape. A destructive confirm that a keyboard user cannot dismiss.', + }, + { + id: 'example-deploy', + name: 'ExampleDeployDialog', + source: 'src/components/ExampleDeployDialog.tsx', + sourceLines: 480, + posture: 'centered', + base: 'hand-rolled', + tokens: 'raw-tailwind', + zIndex: '50', + overlay: 'bg-black/50 backdrop-blur-sm', + width: 'max-w-md', + focusTrap: false, + focusRestore: false, + escape: false, + scrollLock: false, + animated: false, + notes: + 'Tinted header + 5-step wizard body. Shell is byte-identical to StarterDeployDialog.', + }, + { + id: 'starter-deploy', + name: 'Starter DeployDialog', + source: 'src/components/application-starter/DeployDialog.tsx', + sourceLines: 585, + posture: 'centered', + base: 'hand-rolled', + tokens: 'raw-tailwind', + zIndex: '50', + overlay: 'bg-black/50 backdrop-blur-sm', + width: 'max-w-md', + focusTrap: false, + focusRestore: false, + escape: false, + scrollLock: false, + animated: false, + notes: + 'The twin. Same shell, same wizard, different provider branding. Strongest extraction case in the audit.', + }, + { + id: 'builder-guide', + name: 'BuilderGuideDialog', + source: 'src/components/charts/BuilderGuideDialog.tsx', + sourceLines: 150, + posture: 'edge-sheet', + base: 'radix', + tokens: 'semantic', + zIndex: '999 / 1000', + overlay: 'bg-black/45 backdrop-blur-[1px]', + width: 'inset-3 → sm:max-w-2xl right sheet', + focusTrap: true, + focusRestore: true, + escape: true, + scrollLock: true, + animated: false, + notes: + 'Strongest on tokens and layout — semantic throughout, fixed header over an independently scrolling body, responsive posture change. But its `animate-in / fade-in-0 / slide-in-from-right` classes match ZERO css rules: no tailwindcss-animate plugin is installed. It appears animated in source and is not.', + }, + { + id: 'cart-drawer', + name: 'CartDrawer', + source: 'src/components/shop/CartDrawer.tsx', + sourceLines: 257, + posture: 'anchored-panel', + base: 'radix', + tokens: 'shop-scope', + zIndex: '100', + overlay: 'bg-black/40 (no blur)', + width: 'sm:w-[24rem], navbar-anchored', + focusTrap: true, + focusRestore: true, + escape: true, + scrollLock: true, + animated: false, + notes: + 'Header / scroll body / footer — the only three-region layout, and the shape the DS panel should adopt. Separate token namespace loaded only on /shop. No animation: shop.css defines no cart-panel transition.', + }, + { + id: 'product-drawer', + name: 'ProductDrawer', + source: 'src/components/shop/ProductDrawer.tsx', + sourceLines: 700, + posture: 'bottom-sheet', + base: 'hand-rolled', + tokens: 'shop-scope', + zIndex: '60 / 70 / 71', + overlay: 'bg-black/50 backdrop-blur-sm', + width: 'w-[calc(100%-2rem)] max-w-[1400px]', + focusTrap: false, + focusRestore: false, + escape: true, + scrollLock: false, + animated: true, + notes: + 'Only bottom-sheet posture. Hand-rolls its own Escape listener. The one overlay already using the motion tokens.', + }, + { + id: 'libraries-overlay', + name: 'LibrariesOverlay', + source: 'src/components/LibrariesOverlay.tsx', + sourceLines: 57, + posture: 'full-bleed', + base: 'radix', + tokens: 'semantic', + zIndex: '110 / 111 / 112', + overlay: 'bespoke .libraries-overlay-glass', + width: 'inset-0', + focusTrap: true, + focusRestore: true, + escape: true, + scrollLock: true, + animated: true, + notes: + 'Immersive: no panel at all, content scrolls edge to edge with a floating close button on its own tier.', + }, + { + id: 'search-modal', + name: 'SearchModal', + source: 'src/components/SearchModal.tsx', + sourceLines: 3766, + posture: 'top-anchored', + base: 'radix', + tokens: 'raw-tailwind', + zIndex: '999 / 1000', + overlay: 'bg-black/60 → xl:bg-black/30', + width: 'inset-0 → sm:max-w-4xl top-anchored', + focusTrap: true, + focusRestore: true, + escape: true, + scrollLock: true, + animated: true, + notes: + 'Command palette. forceMount + bespoke CSS transitions; overrides onInteractOutside for nested portals. Migrate last.', + }, +] + +/** Distinct values found per property — the raw material for the token decisions. */ +export const DIVERGENCE = [ + { + property: 'z-index tier', + values: ['50', '60/70/71', '100', '110/111/112', '999/1000'], + verdict: 'Five unrelated stacking families. Needs a documented scale.', + }, + { + property: 'Scrim', + values: [ + 'black/40 no blur', + 'black/50 no blur', + 'black/50 blur-sm', + 'black/45 blur-[1px]', + 'black/60 blur-sm', + 'black/60→30 responsive', + 'bespoke glass', + ], + verdict: 'Seven scrims. Should be two: standard and immersive.', + }, + { + property: 'Base', + values: ['Radix (7)', 'hand-rolled (4)'], + verdict: + 'The 4 hand-rolled ones account for every a11y failure in the audit.', + }, + { + property: 'Token layer', + values: ['semantic (2)', 'raw tailwind (7)', 'shop-scope (2)'], + verdict: + 'Only BuilderGuideDialog and LibrariesOverlay are on the semantic layer.', + }, + { + property: 'Animation', + values: ['real keyframes (3)', 'dead animate-in classes (1)', 'none (7)'], + verdict: + 'tailwindcss-animate is NOT installed, so every `animate-in` / `fade-in-0` / `zoom-in-95` class in the codebase is inert. Only LibrariesOverlay, SearchModal and ProductDrawer actually move.', + }, + { + property: 'Corner radius', + values: ['rounded-lg', 'rounded-xl', 'rounded-t-2xl', 'rounded-none (sm)'], + verdict: 'rounded-xl is the de facto standard; one outlier at lg.', + }, +] diff --git a/src/components/ds/overlay-audit/types.ts b/src/components/ds/overlay-audit/types.ts new file mode 100644 index 000000000..4e92450ce --- /dev/null +++ b/src/components/ds/overlay-audit/types.ts @@ -0,0 +1,4 @@ +export type SpecimenProps = { + open: boolean + onOpenChange: (open: boolean) => void +} diff --git a/src/components/ds/ui/Dialog.tsx b/src/components/ds/ui/Dialog.tsx new file mode 100644 index 000000000..88e86077f --- /dev/null +++ b/src/components/ds/ui/Dialog.tsx @@ -0,0 +1,326 @@ +import * as React from 'react' +import * as DialogPrimitive from '@radix-ui/react-dialog' +import { CircleNotchIcon } from '@phosphor-icons/react/CircleNotch' +import { XIcon } from '@phosphor-icons/react/X' +import { twMerge } from 'tailwind-merge' + +/** + * Centered modal dialog. + * + * Always Radix-backed, which is the whole point: focus trapping, focus + * restoration on close, Escape-to-dismiss and scroll lock come from the + * primitive rather than from each call site remembering to implement them. + * The overlay audit at /ds/overlays found that every accessibility failure on + * the site came from a hand-rolled dialog, so this component does not offer an + * opt-out. + * + * Composition mirrors Dropdown (Root + Trigger + Content + parts): + * + * + * + * + * + * + * + * + */ + +export const Dialog = DialogPrimitive.Root +export const DialogTrigger = DialogPrimitive.Trigger +export const DialogClose = DialogPrimitive.Close + +export type DialogSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl' + +const sizeStyles: Record = { + xs: 'max-w-xs', + sm: 'max-w-sm', + md: 'max-w-md', + lg: 'max-w-lg', + xl: 'max-w-xl', +} + +type DialogContentProps = { + children: React.ReactNode + size?: DialogSize + className?: string + /** Escape hatch for content that manages its own dismissal (e.g. a wizard mid-submit). */ + onInteractOutside?: DialogPrimitive.DialogContentProps['onInteractOutside'] +} + +export const DialogContent = React.forwardRef< + HTMLDivElement, + DialogContentProps +>(function DialogContent( + { children, size = 'sm', className, onInteractOutside }, + ref, +) { + return ( + + + + {children} + + + ) +}) + +type DialogHeaderProps = { + title: React.ReactNode + /** + * Rendered under the title. Radix warns when a dialog has no description, so + * when this is omitted the description is still emitted, visually hidden. + */ + description?: React.ReactNode + /** Extra controls placed left of the close button. */ + actions?: React.ReactNode + /** + * An icon or mark shown in a tile to the left of the title. Use it when the + * dialog is *about* a specific third party or object, not for decoration. + */ + media?: React.ReactNode + /** + * A CSS colour that tints the media tile and washes the header behind it. + * + * A deliberate escape hatch from the token layer: these are third-party + * brand colours (Cloudflare orange, Netlify teal) that cannot be DS tokens + * because they are not ours. Everything else in the header stays on tokens. + */ + tint?: string + showClose?: boolean + className?: string +} + +export function DialogHeader({ + title, + description, + actions, + media, + tint, + showClose = true, + className, +}: DialogHeaderProps) { + const tinted = media != null && tint != null + + return ( +
+ {media ? ( + + ) : null} + +
+ + {title} + + + {description ?? 'Dialog'} + +
+ +
+ {actions} + {showClose ? ( + + + ) : null} +
+
+ ) +} + +export function DialogBody({ + children, + className, +}: { + children: React.ReactNode + className?: string +}) { + return ( +
+ {children} +
+ ) +} + +export function DialogFooter({ + children, + className, +}: { + children: React.ReactNode + className?: string +}) { + return ( +
+ {children} +
+ ) +} + +/* ------------------------------------------------------------ DialogStatus -- */ + +export type DialogStatusTone = 'neutral' | 'success' | 'error' | 'loading' + +/** + * A centred status panel: mark, heading, description, actions. + * + * This is the shape every non-form step of the deploy dialogs already used — + * "GitHub Authorization Required", "Repository Created!", "Deployment Failed", + * and the two spinner states were five hand-built copies of one layout, each + * picking its own raw red/green Tailwind pair. Tones map onto the existing + * `status-*` tokens instead. + * + * Note this is not a stepper, and there is deliberately no Wizard component. + * The deploy dialogs are state machines with no back/next and no progress + * chrome, and they are only two call sites — below the threshold for + * extracting their step dispatch. They render their branches inline, wrapped + * in a keyed `data-ds-dialog-step` div so the panels cross-fade. + */ +const statusToneStyles: Record< + Exclude, + { ring: string; icon: string } +> = { + neutral: { ring: 'bg-background-subtle', icon: 'text-icon-default' }, + success: { ring: 'bg-status-success-bg', icon: 'text-status-success' }, + error: { ring: 'bg-status-error-bg', icon: 'text-status-error' }, +} + +export function DialogStatus({ + tone = 'neutral', + icon, + title, + description, + actions, + children, + className, +}: { + tone?: DialogStatusTone + /** Ignored when tone is "loading" — the spinner is the mark. */ + icon?: React.ReactNode + title?: React.ReactNode + description?: React.ReactNode + actions?: React.ReactNode + /** Extra content between the description and the actions. */ + children?: React.ReactNode + className?: string +}) { + return ( +
+ {tone === 'loading' ? ( +
+ ) +} diff --git a/src/components/ds/ui/Drawer.tsx b/src/components/ds/ui/Drawer.tsx new file mode 100644 index 000000000..2fa2b9a84 --- /dev/null +++ b/src/components/ds/ui/Drawer.tsx @@ -0,0 +1,221 @@ +import * as React from 'react' +import * as DialogPrimitive from '@radix-ui/react-dialog' +import { XIcon } from '@phosphor-icons/react/X' +import { twMerge } from 'tailwind-merge' + +/** + * Edge-anchored panel — the drawer / side sheet. + * + * Same Radix foundation as Dialog (focus trap, focus restoration, Escape, + * scroll lock) and the same three-region layout, but anchored to an edge + * instead of centred. `side` covers right, left and bottom: they are one + * mechanism — a surface arriving from off-screen — differing only in axis. + * + * + * + * + * + * + * + * + */ + +export const Drawer = DialogPrimitive.Root +export const DrawerTrigger = DialogPrimitive.Trigger +export const DrawerClose = DialogPrimitive.Close + +export type DrawerSide = 'right' | 'left' | 'bottom' +export type DrawerSize = 'sm' | 'md' | 'lg' | 'xl' | '2xl' + +/** Width for right/left; the panel spans the full height minus a gutter. */ +const horizontalSize: Record = { + sm: 'sm:max-w-sm', + md: 'sm:max-w-md', + lg: 'sm:max-w-lg', + xl: 'sm:max-w-xl', + '2xl': 'sm:max-w-2xl', +} + +/** Height for bottom; width is capped separately. */ +const bottomSize: Record = { + sm: 'h-[40dvh]', + md: 'h-[55dvh]', + lg: 'h-[70dvh]', + xl: 'h-[85dvh]', + '2xl': 'h-[92dvh]', +} + +const sideStyles: Record = { + // Full-bleed (minus a gutter) below `sm`, then a right-edge sheet. This is + // the posture change BuilderGuideDialog established and it is the right + // default: a 24rem side panel on a 375px screen is not a side panel. + right: + 'left-3 right-3 top-3 rounded-xl corner-squircle sm:left-auto sm:right-3 sm:w-full', + left: 'left-3 right-3 top-3 rounded-xl corner-squircle sm:right-auto sm:left-3 sm:w-full', + // Centred with auto margins, not a translate — see the note in app.css. + bottom: + 'left-4 right-4 bottom-0 mx-auto max-w-[1400px] rounded-t-2xl border-b-0', +} + +/** + * Vertical extent for right/left. `fit` sizes the panel to its content and + * caps it at the viewport, which is what the shipping CartDrawer does: a + * two-line cart pinned to a full-height panel strands its checkout button at + * the bottom of a column of empty space. + */ +const horizontalHeight = { + full: 'bottom-3', + fit: 'bottom-auto max-h-[calc(100dvh-1.5rem)]', +} as const + +type DrawerContentProps = { + children: React.ReactNode + side?: DrawerSide + size?: DrawerSize + /** + * Size the panel to its content instead of filling the edge, capped at the + * viewport. Use it when the drawer's content is short and self-contained (a + * cart, a filter list); leave it off for long-form content that should own + * the full edge. + */ + fit?: boolean + className?: string + onInteractOutside?: DialogPrimitive.DialogContentProps['onInteractOutside'] +} + +export const DrawerContent = React.forwardRef< + HTMLDivElement, + DrawerContentProps +>(function DrawerContent( + { + children, + side = 'right', + size = 'md', + fit = false, + className, + onInteractOutside, + }, + ref, +) { + return ( + + + + {children} + + + ) +}) + +type DrawerHeaderProps = { + title: React.ReactNode + description?: React.ReactNode + actions?: React.ReactNode + showClose?: boolean + className?: string +} + +/** + * Fixed header. Unlike DialogHeader this sits on a border, because a drawer's + * body scrolls under it far more often than a dialog's does and an unbounded + * header lets content appear to float out of nowhere. + */ +export function DrawerHeader({ + title, + description, + actions, + showClose = true, + className, +}: DrawerHeaderProps) { + return ( +
+
+ + {title} + + + {description ?? 'Drawer'} + +
+ +
+ {actions} + {showClose ? ( + + + ) : null} +
+
+ ) +} + +export function DrawerBody({ + children, + className, +}: { + children: React.ReactNode + className?: string +}) { + return ( +
+ {children} +
+ ) +} + +export function DrawerFooter({ + children, + className, +}: { + children: React.ReactNode + className?: string +}) { + return ( +
+ {children} +
+ ) +} diff --git a/src/components/ds/ui/index.tsx b/src/components/ds/ui/index.tsx index 5f5de0f9c..8250df4d8 100644 --- a/src/components/ds/ui/index.tsx +++ b/src/components/ds/ui/index.tsx @@ -905,6 +905,29 @@ export { segmentTrackClasses, type SegmentSize, } from './Tabs' +export { + Dialog, + DialogBody, + DialogClose, + DialogContent, + DialogFooter, + DialogHeader, + DialogStatus, + DialogTrigger, + type DialogSize, + type DialogStatusTone, +} from './Dialog' +export { + Drawer, + DrawerBody, + DrawerClose, + DrawerContent, + DrawerFooter, + DrawerHeader, + DrawerTrigger, + type DrawerSide, + type DrawerSize, +} from './Drawer' export { PalmSpinner } from './PalmSpinner' export { PixelSpinner } from './PixelSpinner' export { diff --git a/src/components/npm-stats/BaselineSection.tsx b/src/components/npm-stats/BaselineSection.tsx index 3081b21e3..b2132ce3e 100644 --- a/src/components/npm-stats/BaselineSection.tsx +++ b/src/components/npm-stats/BaselineSection.tsx @@ -16,6 +16,12 @@ import { } from '@radix-ui/react-dropdown-menu' import { twMerge } from 'tailwind-merge' import { Tooltip } from '~/components/Tooltip' +import { + Dialog, + DialogBody, + DialogContent, + DialogHeader, +} from '~/components/ds/ui' import { PackageSearch } from './PackageSearch' import { getBaselineDisplayName, type PackageGroup } from './shared' import type { BaselinePreset } from '~/routes/stats/npm/-comparisons' @@ -250,21 +256,17 @@ export function BaselineSection({ - {showSearch && ( -
-
-
-

- + + + + Add baseline package -

- -
+ + } + /> + { onAddBaseline(pkg) @@ -274,9 +276,9 @@ export function BaselineSection({ // eslint-disable-next-line jsx-a11y/no-autofocus autoFocus={true} /> -
-
- )} + + + ) } diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index cc5a8a49b..01b9f8755 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -76,6 +76,7 @@ import { Route as DsPartnerRailRouteImport } from './routes/ds.partner-rail' import { Route as DsPanelRouteImport } from './routes/ds.panel' import { Route as DsPaletteRouteImport } from './routes/ds.palette' import { Route as DsPageHeaderRouteImport } from './routes/ds.page-header' +import { Route as DsOverlaysRouteImport } from './routes/ds.overlays' import { Route as DsNavbarRouteImport } from './routes/ds.navbar' import { Route as DsMaintainersRouteImport } from './routes/ds.maintainers' import { Route as DsLogosRouteImport } from './routes/ds.logos' @@ -84,6 +85,8 @@ import { Route as DsIconographyRouteImport } from './routes/ds.iconography' import { Route as DsEyebrowRouteImport } from './routes/ds.eyebrow' import { Route as DsEffectsRouteImport } from './routes/ds.effects' import { Route as DsDropdownRouteImport } from './routes/ds.dropdown' +import { Route as DsDrawerRouteImport } from './routes/ds.drawer' +import { Route as DsDialogRouteImport } from './routes/ds.dialog' import { Route as DsColorsRouteImport } from './routes/ds.colors' import { Route as DsCardsRouteImport } from './routes/ds.cards' import { Route as DsButtonsRouteImport } from './routes/ds.buttons' @@ -549,6 +552,11 @@ const DsPageHeaderRoute = DsPageHeaderRouteImport.update({ path: '/page-header', getParentRoute: () => DsRoute, } as any) +const DsOverlaysRoute = DsOverlaysRouteImport.update({ + id: '/overlays', + path: '/overlays', + getParentRoute: () => DsRoute, +} as any) const DsNavbarRoute = DsNavbarRouteImport.update({ id: '/navbar', path: '/navbar', @@ -591,6 +599,16 @@ const DsDropdownRoute = DsDropdownRouteImport.update({ path: '/dropdown', getParentRoute: () => DsRoute, } as any) +const DsDrawerRoute = DsDrawerRouteImport.update({ + id: '/drawer', + path: '/drawer', + getParentRoute: () => DsRoute, +} as any) +const DsDialogRoute = DsDialogRouteImport.update({ + id: '/dialog', + path: '/dialog', + getParentRoute: () => DsRoute, +} as any) const DsColorsRoute = DsColorsRouteImport.update({ id: '/colors', path: '/colors', @@ -1371,6 +1389,8 @@ export interface FileRoutesByFullPath { '/ds/buttons': typeof DsButtonsRoute '/ds/cards': typeof DsCardsRoute '/ds/colors': typeof DsColorsRoute + '/ds/dialog': typeof DsDialogRoute + '/ds/drawer': typeof DsDrawerRoute '/ds/dropdown': typeof DsDropdownRoute '/ds/effects': typeof DsEffectsRoute '/ds/eyebrow': typeof DsEyebrowRoute @@ -1379,6 +1399,7 @@ export interface FileRoutesByFullPath { '/ds/logos': typeof DsLogosRoute '/ds/maintainers': typeof DsMaintainersRoute '/ds/navbar': typeof DsNavbarRoute + '/ds/overlays': typeof DsOverlaysRoute '/ds/page-header': typeof DsPageHeaderRoute '/ds/palette': typeof DsPaletteRoute '/ds/panel': typeof DsPanelRoute @@ -1569,6 +1590,8 @@ export interface FileRoutesByTo { '/ds/buttons': typeof DsButtonsRoute '/ds/cards': typeof DsCardsRoute '/ds/colors': typeof DsColorsRoute + '/ds/dialog': typeof DsDialogRoute + '/ds/drawer': typeof DsDrawerRoute '/ds/dropdown': typeof DsDropdownRoute '/ds/effects': typeof DsEffectsRoute '/ds/eyebrow': typeof DsEyebrowRoute @@ -1577,6 +1600,7 @@ export interface FileRoutesByTo { '/ds/logos': typeof DsLogosRoute '/ds/maintainers': typeof DsMaintainersRoute '/ds/navbar': typeof DsNavbarRoute + '/ds/overlays': typeof DsOverlaysRoute '/ds/page-header': typeof DsPageHeaderRoute '/ds/palette': typeof DsPaletteRoute '/ds/panel': typeof DsPanelRoute @@ -1773,6 +1797,8 @@ export interface FileRoutesById { '/ds/buttons': typeof DsButtonsRoute '/ds/cards': typeof DsCardsRoute '/ds/colors': typeof DsColorsRoute + '/ds/dialog': typeof DsDialogRoute + '/ds/drawer': typeof DsDrawerRoute '/ds/dropdown': typeof DsDropdownRoute '/ds/effects': typeof DsEffectsRoute '/ds/eyebrow': typeof DsEyebrowRoute @@ -1781,6 +1807,7 @@ export interface FileRoutesById { '/ds/logos': typeof DsLogosRoute '/ds/maintainers': typeof DsMaintainersRoute '/ds/navbar': typeof DsNavbarRoute + '/ds/overlays': typeof DsOverlaysRoute '/ds/page-header': typeof DsPageHeaderRoute '/ds/palette': typeof DsPaletteRoute '/ds/panel': typeof DsPanelRoute @@ -1981,6 +2008,8 @@ export interface FileRouteTypes { | '/ds/buttons' | '/ds/cards' | '/ds/colors' + | '/ds/dialog' + | '/ds/drawer' | '/ds/dropdown' | '/ds/effects' | '/ds/eyebrow' @@ -1989,6 +2018,7 @@ export interface FileRouteTypes { | '/ds/logos' | '/ds/maintainers' | '/ds/navbar' + | '/ds/overlays' | '/ds/page-header' | '/ds/palette' | '/ds/panel' @@ -2179,6 +2209,8 @@ export interface FileRouteTypes { | '/ds/buttons' | '/ds/cards' | '/ds/colors' + | '/ds/dialog' + | '/ds/drawer' | '/ds/dropdown' | '/ds/effects' | '/ds/eyebrow' @@ -2187,6 +2219,7 @@ export interface FileRouteTypes { | '/ds/logos' | '/ds/maintainers' | '/ds/navbar' + | '/ds/overlays' | '/ds/page-header' | '/ds/palette' | '/ds/panel' @@ -2382,6 +2415,8 @@ export interface FileRouteTypes { | '/ds/buttons' | '/ds/cards' | '/ds/colors' + | '/ds/dialog' + | '/ds/drawer' | '/ds/dropdown' | '/ds/effects' | '/ds/eyebrow' @@ -2390,6 +2425,7 @@ export interface FileRouteTypes { | '/ds/logos' | '/ds/maintainers' | '/ds/navbar' + | '/ds/overlays' | '/ds/page-header' | '/ds/palette' | '/ds/panel' @@ -3090,6 +3126,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DsPageHeaderRouteImport parentRoute: typeof DsRoute } + '/ds/overlays': { + id: '/ds/overlays' + path: '/overlays' + fullPath: '/ds/overlays' + preLoaderRoute: typeof DsOverlaysRouteImport + parentRoute: typeof DsRoute + } '/ds/navbar': { id: '/ds/navbar' path: '/navbar' @@ -3146,6 +3189,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DsDropdownRouteImport parentRoute: typeof DsRoute } + '/ds/drawer': { + id: '/ds/drawer' + path: '/drawer' + fullPath: '/ds/drawer' + preLoaderRoute: typeof DsDrawerRouteImport + parentRoute: typeof DsRoute + } + '/ds/dialog': { + id: '/ds/dialog' + path: '/dialog' + fullPath: '/ds/dialog' + preLoaderRoute: typeof DsDialogRouteImport + parentRoute: typeof DsRoute + } '/ds/colors': { id: '/ds/colors' path: '/colors' @@ -4318,6 +4375,8 @@ interface DsRouteChildren { DsButtonsRoute: typeof DsButtonsRoute DsCardsRoute: typeof DsCardsRoute DsColorsRoute: typeof DsColorsRoute + DsDialogRoute: typeof DsDialogRoute + DsDrawerRoute: typeof DsDrawerRoute DsDropdownRoute: typeof DsDropdownRoute DsEffectsRoute: typeof DsEffectsRoute DsEyebrowRoute: typeof DsEyebrowRoute @@ -4326,6 +4385,7 @@ interface DsRouteChildren { DsLogosRoute: typeof DsLogosRoute DsMaintainersRoute: typeof DsMaintainersRoute DsNavbarRoute: typeof DsNavbarRoute + DsOverlaysRoute: typeof DsOverlaysRoute DsPageHeaderRoute: typeof DsPageHeaderRoute DsPaletteRoute: typeof DsPaletteRoute DsPanelRoute: typeof DsPanelRoute @@ -4346,6 +4406,8 @@ const DsRouteChildren: DsRouteChildren = { DsButtonsRoute: DsButtonsRoute, DsCardsRoute: DsCardsRoute, DsColorsRoute: DsColorsRoute, + DsDialogRoute: DsDialogRoute, + DsDrawerRoute: DsDrawerRoute, DsDropdownRoute: DsDropdownRoute, DsEffectsRoute: DsEffectsRoute, DsEyebrowRoute: DsEyebrowRoute, @@ -4354,6 +4416,7 @@ const DsRouteChildren: DsRouteChildren = { DsLogosRoute: DsLogosRoute, DsMaintainersRoute: DsMaintainersRoute, DsNavbarRoute: DsNavbarRoute, + DsOverlaysRoute: DsOverlaysRoute, DsPageHeaderRoute: DsPageHeaderRoute, DsPaletteRoute: DsPaletteRoute, DsPanelRoute: DsPanelRoute, diff --git a/src/routes/admin/roles.$roleId.tsx b/src/routes/admin/roles.$roleId.tsx index 85cd06bad..c8a663e83 100644 --- a/src/routes/admin/roles.$roleId.tsx +++ b/src/routes/admin/roles.$roleId.tsx @@ -20,6 +20,12 @@ import { import { requireCapability } from '~/utils/auth.functions' import { hasCapability } from '~/db/types' import { Badge, Button } from '~/ui' +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, +} from '~/components/ds/ui' export const Route = createFileRoute('/admin/roles/$roleId')({ beforeLoad: async () => { @@ -322,52 +328,56 @@ function RoleDetailPage() { )} - {confirmRemove && ( -
-
-

- Confirm Removal -

-

- Remove {confirmRemove.name} from role "{role?.name}"? -

-
- - -
-
-
- )} + { + if (!open) setConfirmRemove(null) + }} + > + + + + + + + +
diff --git a/src/routes/ds.dialog.tsx b/src/routes/ds.dialog.tsx new file mode 100644 index 000000000..e149964d7 --- /dev/null +++ b/src/routes/ds.dialog.tsx @@ -0,0 +1,464 @@ +import * as React from 'react' +import { createFileRoute } from '@tanstack/react-router' +import { + CheckIcon, + GithubLogoIcon, + RocketIcon, + WarningCircleIcon, +} from '@phosphor-icons/react' +import { seo } from '~/utils/seo' +import { + Button, + Dialog, + DialogBody, + DialogClose, + DialogContent, + DialogFooter, + DialogHeader, + DialogStatus, + type DialogSize, + type DialogStatusTone, +} from '~/components/ds/ui' +import { ComponentPreview, DsPage, DsSection } from '~/components/ds/DsKit' + +export const Route = createFileRoute('/ds/dialog')({ + component: DialogPage, + head: () => ({ + meta: seo({ + title: 'Dialog | TanStack Design System', + description: + 'The centered modal dialog — Radix-backed, on the semantic token layer.', + }), + }), +}) + +const SIZES: Array = ['xs', 'sm', 'md', 'lg', 'xl'] + +/** Which DS token supplies each attribute of the panel. */ +const TOKEN_COVERAGE: Array<{ + attribute: string + token: string + status: 'existing' | 'added' + note: string +}> = [ + { + attribute: 'Panel surface', + token: 'bg-background-elevated', + status: 'existing', + note: 'Highest of the three background tiers. Identical to `surface` in light mode; #2b2b2b in dark, where the shipping dialogs use the warm gray-900 (#201b15) off a different ramp.', + }, + { + attribute: 'Panel border', + token: 'border-border-default', + status: 'existing', + note: 'Carries the edge in light mode, where all three background tiers are #ffffff and only shadow separates layers.', + }, + { + attribute: 'Elevation', + token: 'shadow-2xl', + status: 'existing', + note: 'Already the de facto modal elevation; 4 of 6 centered dialogs use it (one outlier at shadow-xl).', + }, + { + attribute: 'Corner radius', + token: 'rounded-xl corner-squircle', + status: 'existing', + note: 'rounded-xl is the audited majority. corner-squircle matches Card and Button.', + }, + { + attribute: 'Title / body text', + token: 'text-text-primary, text-text-muted', + status: 'existing', + note: 'Replaces text-gray-900 dark:text-gray-100 at every call site.', + }, + { + attribute: 'Close affordance', + token: 'text-icon-muted, hover:bg-surface-state-hover', + status: 'existing', + note: 'The interaction-state overlay tokens already exist and were unused by every dialog.', + }, + { + attribute: 'Focus ring', + token: 'ring-border-focus', + status: 'existing', + note: 'focus-visible only, so the ring does not appear on mouse click.', + }, + { + attribute: 'Scrim', + token: 'bg-scrim', + status: 'added', + note: 'Did not exist. Seven hand-picked black/NN values across the audit. Deliberately heavier in dark (0.65 vs 0.5) — equal alpha reads as weaker separation over an already-dark page.', + }, + { + attribute: 'Stacking tier', + token: 'z-[var(--z-scrim)] / z-[var(--z-overlay)]', + status: 'added', + note: 'Did not exist. Five unrelated z-index families were in use. Values set to 999/1000 — the existing majority — so adopting them moves nothing.', + }, + { + attribute: 'Motion', + token: 'animate-dialog-panel-in / -out', + status: 'added', + note: 'Keyframes are real. The animate-in / fade-in-0 / zoom-in-95 classes used elsewhere in the codebase come from tailwindcss-animate, which is not installed — they match zero CSS rules. Timing reuses --motion-duration-fast and --motion-ease-standard.', + }, +] + +function DialogPage() { + const [basic, setBasic] = React.useState(false) + const [scrolling, setScrolling] = React.useState(false) + const [destructive, setDestructive] = React.useState(false) + const [size, setSize] = React.useState(null) + const [tinted, setTinted] = React.useState(false) + const [statusTone, setStatusTone] = React.useState( + null, + ) + + return ( + + + + + + + + + + + + + +`} + > + + + + + +

+ Body content sits in its own scroll region, so a long dialog + scrolls internally instead of pushing the footer off-screen. +

+
+ + + + + + +
+
+
+
+ + + …`}> +
+ {SIZES.map((s) => ( + + ))} +
+ !open && setSize(null)} + > + + + +

+ max-w-{size} with a calc(100vw - 2rem) floor. +

+
+ + + + + +
+
+
+
+ + + + + {/* long content */} + +`} + > + + + + + + {Array.from({ length: 12 }).map((_, i) => ( +

+ Section {i + 1}. Long-form content demonstrating that the + body scrolls independently while the header and footer + remain pinned. +

+ ))} +
+ + + + + + +
+
+
+
+ + + + + + + + + + +`} + > + + + + + + + + + + + + + + + + + } + tint="#F38020" +/>`} + > + + + + } + tint="#F38020" + /> + +

+ A tinted header takes a rule and even padding, so it reads as + a banded region rather than bleeding into the body. +

+
+
+
+
+
+ + + } + title="Repository Created!" + description="Your repo is ready." + actions={} +/>`} + > +
+ {(['loading', 'neutral', 'success', 'error'] as const).map((t) => ( + + ))} +
+ !open && setStatusTone(null)} + > + + + + {statusTone === 'loading' ? ( + + ) : statusTone === 'neutral' ? ( + } + title="GitHub Authorization Required" + description="We need permission to create a repository on your account." + actions={} + /> + ) : statusTone === 'success' ? ( + } + title="Repository Created!" + description="tanstack/start-basic" + actions={} + /> + ) : statusTone === 'error' ? ( + } + title="Deployment Failed" + description="The repository name is already taken." + actions={ + <> + + + + } + /> + ) : null} + + + +
+
+ + +
+ + + + + + + + + + + {TOKEN_COVERAGE.map((row) => ( + + + + + + + ))} + +
AttributeTokenStatusNote
+ {row.attribute} + + + {row.token} + + + + {row.status} + + {row.note}
+
+
+ + +
    + {[ + 'Focus moves into the panel on open and returns to the trigger on close.', + 'Tab and Shift+Tab are trapped inside the panel.', + 'Escape dismisses. Clicking the scrim dismisses.', + 'Body scroll is locked while open.', + 'aria-modal, role="dialog", and the title/description associations are wired automatically.', + 'prefers-reduced-motion removes the animation but keeps the state change.', + ].map((line) => ( +
  • + + {line} +
  • + ))} +
+
+
+ ) +} diff --git a/src/routes/ds.drawer.tsx b/src/routes/ds.drawer.tsx new file mode 100644 index 000000000..07ade289c --- /dev/null +++ b/src/routes/ds.drawer.tsx @@ -0,0 +1,298 @@ +import * as React from 'react' +import { createFileRoute } from '@tanstack/react-router' +import { ArrowSquareOutIcon } from '@phosphor-icons/react' +import { seo } from '~/utils/seo' +import { + Button, + Drawer, + DrawerBody, + DrawerClose, + DrawerContent, + DrawerFooter, + DrawerHeader, + type DrawerSide, + type DrawerSize, +} from '~/components/ds/ui' +import { ComponentPreview, DsPage, DsSection } from '~/components/ds/DsKit' + +export const Route = createFileRoute('/ds/drawer')({ + component: DrawerPage, + head: () => ({ + meta: seo({ + title: 'Drawer | TanStack Design System', + description: + 'The edge-anchored panel — right, left and bottom, on one Radix foundation.', + }), + }), +}) + +const SIDES: Array = ['right', 'left', 'bottom'] +const SIZES: Array = ['sm', 'md', 'lg', 'xl', '2xl'] + +function DrawerPage() { + const [side, setSide] = React.useState(null) + const [size, setSize] = React.useState(null) + const [guide, setGuide] = React.useState(false) + const [footer, setFooter] = React.useState(false) + const [fitDemo, setFitDemo] = React.useState<'full' | 'fit' | null>(null) + + return ( + + + + + + + +`} + > +
+ {SIDES.map((s) => ( + + ))} +
+ !open && setSide(null)} + > + + + +

+ The panel slides without fading. A sheet reads as a physical + surface arriving from off-screen; fading it at the same time + makes it read as a dissolve instead. Only the scrim fades. +

+
+
+
+
+
+ + + …`} + > +
+ {SIZES.map((s) => ( + + ))} +
+ !open && setSize(null)} + > + + + +

+ sm through 2xl. 2xl matches the width the charts builder guide + uses today. +

+
+
+
+
+
+ + + … + +{/* hugs its content — footer sits under the last item */} +`} + > +
+ + +
+ !open && setFitDemo(null)} + > + + + +

+ Short content. Compare where the footer lands. +

+
+ + + + + +
+
+
+
+ + + + Plain text + + } +/>`} + > + + + + + Plain text + + + + + + + + + + + + +`} + > + + + + + + {['Classic Tee', 'Sticker Pack'].map((name) => ( +
+
+
+

+ {name} +

+

Qty 1

+
+ + $28.00 + +
+ ))} + + +
+ Subtotal + $56.00 +
+ + + +
+ + + + + + +
    + {[ + 'The panel slides only — no fade. The scrim fades.', + 'Exit animations play: Radix Presence waits for animationend before unmounting.', + 'prefers-reduced-motion removes the movement but keeps the state change.', + 'Bottom is centred with auto margins rather than a translate, so transform stays free for the slide — Tailwind v4 compiles -translate-x-1/2 to the independent translate property, which composes with transform rather than being replaced by it.', + ].map((line) => ( +
  • + + {line} +
  • + ))} +
+
+ + ) +} diff --git a/src/routes/ds.overlays.tsx b/src/routes/ds.overlays.tsx new file mode 100644 index 000000000..c27a91afd --- /dev/null +++ b/src/routes/ds.overlays.tsx @@ -0,0 +1,283 @@ +import * as React from 'react' +import { createFileRoute } from '@tanstack/react-router' +import { CheckIcon, XIcon } from '@phosphor-icons/react' +import shopCss from '~/styles/shop.css?url' +import { seo } from '~/utils/seo' +import { Button } from '~/components/ds/ui' +import { DsPage, DsSection } from '~/components/ds/DsKit' +import { + AvatarCropModalSpecimen, + BuilderGuideDialogSpecimen, + CartDrawerSpecimen, + ExampleDeployDialogSpecimen, + LibrariesOverlaySpecimen, + LoginModalSpecimen, + NpmStatsDialogSpecimen, + ProductDrawerSpecimen, + RolesConfirmDialogSpecimen, + SearchModalSpecimen, + StarterDeployDialogSpecimen, +} from '~/components/ds/overlay-audit' +import { + DIVERGENCE, + SPECIMENS, + type SpecimenMeta, +} from '~/components/ds/overlay-audit/specimen-meta' + +export const Route = createFileRoute('/ds/overlays')({ + component: OverlayAuditPage, + // The two shop specimens depend on tokens defined in shop.css, which is + // otherwise only loaded under /shop. Linking it here is itself an audit + // finding: two of eleven overlays cannot render outside their own route. + head: () => ({ + links: [{ rel: 'stylesheet', href: shopCss }], + meta: seo({ + title: 'Overlay Audit | TanStack Design System', + description: + 'Every dialog, drawer and overlay shipping on the site, pulled into one page for side-by-side review.', + }), + }), +}) + +const SPECIMEN_COMPONENTS: Record< + string, + React.ComponentType<{ open: boolean; onOpenChange: (o: boolean) => void }> +> = { + login: LoginModalSpecimen, + 'avatar-crop': AvatarCropModalSpecimen, + 'npm-stats': NpmStatsDialogSpecimen, + 'roles-confirm': RolesConfirmDialogSpecimen, + 'example-deploy': ExampleDeployDialogSpecimen, + 'starter-deploy': StarterDeployDialogSpecimen, + 'builder-guide': BuilderGuideDialogSpecimen, + 'cart-drawer': CartDrawerSpecimen, + 'product-drawer': ProductDrawerSpecimen, + 'libraries-overlay': LibrariesOverlaySpecimen, + 'search-modal': SearchModalSpecimen, +} + +const POSTURE_ORDER: Array = [ + 'centered', + 'edge-sheet', + 'anchored-panel', + 'bottom-sheet', + 'top-anchored', + 'full-bleed', +] + +const POSTURE_LABEL: Record = { + centered: 'Centered', + 'edge-sheet': 'Edge sheet', + 'anchored-panel': 'Anchored panel', + 'bottom-sheet': 'Bottom sheet', + 'top-anchored': 'Top anchored', + 'full-bleed': 'Full bleed', +} + +function OverlayAuditPage() { + const [openId, setOpenId] = React.useState(null) + + return ( + + +
+ {POSTURE_ORDER.map((posture) => { + const group = SPECIMENS.filter((s) => s.posture === posture) + if (!group.length) return null + return ( +
+

+ {POSTURE_LABEL[posture]} + + {group.length} {group.length === 1 ? 'variant' : 'variants'} + +

+
+ {group.map((s) => ( + + ))} +
+
+ ) + })} +
+
+ + +
+ + + + + + + + + + + + + + + + + + {SPECIMENS.map((s) => ( + + + + + + + + + + + + + + ))} + +
SpecimenPostureBaseTokensz-indexScrimTrapRestoreEscLockAnim
+ +

+ {s.source.replace('src/', '')} · {s.sourceLines} lines +

+
+ {POSTURE_LABEL[s.posture]} + + + {s.base} + + + + {s.tokens} + + + {s.zIndex} + + {s.overlay} +
+
+
+ + +
+ {DIVERGENCE.map((d) => ( +
+
+

{d.property}

+ + {d.values.length} distinct values + +
+
+ {d.values.map((v) => ( + + {v} + + ))} +
+

{d.verdict}

+
+ ))} +
+
+ + +
+ {SPECIMENS.map((s) => ( +
+
{s.name}
+
{s.notes}
+
+ ))} +
+
+ + {SPECIMENS.map((s) => { + const Component = SPECIMEN_COMPONENTS[s.id] + if (!Component) return null + return ( + setOpenId(next ? s.id : null)} + /> + ) + })} +
+ ) +} + +function BoolCell({ value }: { value: boolean }) { + return ( + + {value ? ( + + ) : ( + + )} + + ) +} diff --git a/src/routes/ds.palette.tsx b/src/routes/ds.palette.tsx index e966eb8f9..75b67fc00 100644 --- a/src/routes/ds.palette.tsx +++ b/src/routes/ds.palette.tsx @@ -15,6 +15,15 @@ export const Route = createFileRoute('/ds/palette')({ const RAMPS = ['green', 'terracotta', 'blue', 'purple', 'amber', 'neutral'] const STEPS = [100, 200, 300, 400, 500] +/** + * Neutral carries two half-steps the chromatic ramps do not need. Each is the + * exact midpoint of its neighbours, added so the text scale has a legible + * `secondary` in dark and a legible `muted` in light. + */ +const RAMP_STEPS: Record> = { + neutral: [100, 150, 200, 300, 350, 400, 500], +} + const CATEGORY_COLORS = ['framework', 'data', 'ui', 'performance', 'tooling'] const LIBRARY_COLORS = [ @@ -42,9 +51,17 @@ function PalettePage() { description="The primitive color ramps sourced from Figma. These feed the semantic tokens — change a primitive here (in app.css) and every semantic token referencing it updates across the system. Click a swatch to copy its var() reference." > {RAMPS.map((ramp) => ( - +
- {STEPS.map((step) => ( + {(RAMP_STEPS[ramp] ?? STEPS).map((step) => ( ))}
diff --git a/src/routes/stats/npm/index.tsx b/src/routes/stats/npm/index.tsx index ee83fa58b..4f9fb7ffa 100644 --- a/src/routes/stats/npm/index.tsx +++ b/src/routes/stats/npm/index.tsx @@ -2,10 +2,15 @@ import * as React from 'react' import { createFileRoute } from '@tanstack/react-router' import * as v from 'valibot' import { useThrottledCallback, useThrottler } from '@tanstack/react-pacer' -import * as DialogPrimitive from '@radix-ui/react-dialog' -import { QuestionIcon, XIcon } from '@phosphor-icons/react' +import { QuestionIcon } from '@phosphor-icons/react' import { useQuery } from '@tanstack/react-query' -import { Card } from '~/components/ds/ui' +import { + Card, + Dialog, + DialogBody, + DialogContent, + DialogHeader, +} from '~/components/ds/ui' import { Tooltip } from '~/components/Tooltip' import { seo } from '~/utils/seo' import { chartHeightSchema, chartWidthSchema } from '~/utils/schemas' @@ -1074,27 +1079,17 @@ function RouteComponent() {
{/* Combine Package Dialog */} - { if (!open) setCombiningPackage(null) }} > - - - -
- - Add packages to {combiningPackage} - - - - -
- - Search for additional npm packages to combine with{' '} - {combiningPackage}. - + + + {combiningPackage && ( )} -
-
-
+ + + {/* Color Picker Popover */} {colorPickerPackage && colorPickerPosition && ( diff --git a/src/styles/app.css b/src/styles/app.css index b22f9bacb..15e1dc477 100644 --- a/src/styles/app.css +++ b/src/styles/app.css @@ -154,6 +154,215 @@ html.theme-switching *::after { --motion-duration-sheet: 280ms; --motion-ease-standard: cubic-bezier(0.2, 0.8, 0.2, 1); --motion-ease-sheet: cubic-bezier(0.32, 0.72, 0, 1); + + /* Stacking tiers, shared by every modal posture (dialog, drawer, …). + Tailwind has no `--z-*` theme namespace, so these are applied as + `z-[var(--z-overlay)]`. Values match the 999/1000 pair already used by the + majority of overlays, so adopting them moves nothing. */ + --z-scrim: 999; + --z-overlay: 1000; +} + +/* ----------------------------------------------------------------- Dialog -- + Enter/exit motion for the DS Dialog, driven off Radix's data-state rather + than utility classes. Written as real keyframes on purpose: the + `animate-in` / `fade-in-0` / `zoom-in-95` classes used elsewhere in this + codebase come from tailwindcss-animate, which is NOT installed — they match + no CSS rule and animate nothing. Timing reuses the existing motion tokens. + + Radix's Presence waits for animationend before unmounting, so the closed + state genuinely plays instead of being cut off. + + The panel keyframes animate `transform` only. Tailwind v4's + `-translate-x-1/2 -translate-y-1/2` compile to the independent `translate` + property, which composes with `transform` rather than being overwritten by + it — repeating the centring translate here would shift the panel a full + height instead of half and hang it off the top of the viewport. + -------------------------------------------------------------------------- */ +@keyframes dialog-scrim-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} +@keyframes dialog-scrim-out { + from { + opacity: 1; + } + to { + opacity: 0; + } +} +@keyframes dialog-panel-in { + from { + opacity: 0; + transform: scale(0.96); + } + to { + opacity: 1; + transform: scale(1); + } +} +@keyframes dialog-panel-out { + from { + opacity: 1; + transform: scale(1); + } + to { + opacity: 0; + transform: scale(0.96); + } +} + +[data-ds-dialog-scrim][data-state='open'] { + animation: dialog-scrim-in var(--motion-duration-fast) + var(--motion-ease-standard) both; +} +[data-ds-dialog-scrim][data-state='closed'] { + animation: dialog-scrim-out var(--motion-duration-fast) + var(--motion-ease-standard) both; +} +[data-ds-dialog-panel][data-state='open'] { + animation: dialog-panel-in var(--motion-duration-fast) + var(--motion-ease-standard) both; +} +[data-ds-dialog-panel][data-state='closed'] { + animation: dialog-panel-out var(--motion-duration-fast) + var(--motion-ease-standard) both; +} + +/* The dialog is the first DS component with real motion, so it states the + reduced-motion contract the rest of the system should follow: the state + change still happens, it just stops moving. */ +/* ----------------------------------------------------------------- Drawer -- + Edge-anchored panels. Slides only — the panel does not fade, because a + sheet reads as a physical surface arriving from off-screen and a + simultaneous fade makes it read as a dissolve instead. The scrim fades. + + Timing uses --motion-duration-sheet / --motion-ease-sheet, which already + existed for exactly this and were previously used only by /shop. + + `bottom` is centred with `left-4 right-4 mx-auto` rather than a translate, + which deliberately leaves `transform` free for the slide. Do not swap that + for `left-1/2 -translate-x-1/2`: Tailwind v4 compiles those to the + independent `translate` property, which composes with `transform`. + -------------------------------------------------------------------------- */ +@keyframes drawer-right-in { + from { + transform: translateX(100%); + } + to { + transform: translateX(0); + } +} +@keyframes drawer-right-out { + from { + transform: translateX(0); + } + to { + transform: translateX(100%); + } +} +@keyframes drawer-left-in { + from { + transform: translateX(-100%); + } + to { + transform: translateX(0); + } +} +@keyframes drawer-left-out { + from { + transform: translateX(0); + } + to { + transform: translateX(-100%); + } +} +@keyframes drawer-bottom-in { + from { + transform: translateY(100%); + } + to { + transform: translateY(0); + } +} +@keyframes drawer-bottom-out { + from { + transform: translateY(0); + } + to { + transform: translateY(100%); + } +} + +[data-ds-drawer-panel][data-side='right'][data-state='open'] { + animation: drawer-right-in var(--motion-duration-sheet) + var(--motion-ease-sheet) both; +} +[data-ds-drawer-panel][data-side='right'][data-state='closed'] { + animation: drawer-right-out var(--motion-duration-sheet) + var(--motion-ease-sheet) both; +} +[data-ds-drawer-panel][data-side='left'][data-state='open'] { + animation: drawer-left-in var(--motion-duration-sheet) + var(--motion-ease-sheet) both; +} +[data-ds-drawer-panel][data-side='left'][data-state='closed'] { + animation: drawer-left-out var(--motion-duration-sheet) + var(--motion-ease-sheet) both; +} +[data-ds-drawer-panel][data-side='bottom'][data-state='open'] { + animation: drawer-bottom-in var(--motion-duration-sheet) + var(--motion-ease-sheet) both; +} +[data-ds-drawer-panel][data-side='bottom'][data-state='closed'] { + animation: drawer-bottom-out var(--motion-duration-sheet) + var(--motion-ease-sheet) both; +} + +/* Wizard step cross-fade. Steps swap instantly today, so an outcome can + replace a spinner with no visual connection between them. Short and + opacity-only: the panel must not resize or shift, or the footer buttons jump + under the cursor mid-click. */ +@keyframes dialog-step-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +[data-ds-dialog-step] { + animation: dialog-step-in var(--motion-duration-fast) + var(--motion-ease-standard) both; +} + +@media (prefers-reduced-motion: reduce) { + [data-ds-dialog-step] { + animation: none !important; + } +} + +[data-ds-drawer-scrim][data-state='open'] { + animation: dialog-scrim-in var(--motion-duration-sheet) + var(--motion-ease-sheet) both; +} +[data-ds-drawer-scrim][data-state='closed'] { + animation: dialog-scrim-out var(--motion-duration-sheet) + var(--motion-ease-sheet) both; +} + +@media (prefers-reduced-motion: reduce) { + [data-ds-dialog-scrim], + [data-ds-dialog-panel], + [data-ds-drawer-scrim], + [data-ds-drawer-panel] { + animation: none !important; + } } /* ============================================================================ @@ -199,8 +408,15 @@ html.theme-switching *::after { --color-ds-neutral-0: #ffffff; --color-ds-neutral-100: #eeebd4; + /* Half-steps. Both are the exact midpoint of their neighbours, so they sit + on the ramp's hue rather than beside it. They exist because the text scale + needed a legible `secondary` in dark (200 was only 7.8:1 against #111111, + against 12.0:1 for its light counterpart) and a legible `muted` in light + (300 was 5.2:1). NOTE: not yet in Figma — add them there when syncing. */ + --color-ds-neutral-150: #cec8b2; --color-ds-neutral-200: #aea691; --color-ds-neutral-300: #756c5b; + --color-ds-neutral-350: #5a5042; --color-ds-neutral-400: #3e3529; --color-ds-neutral-500: #111111; @@ -299,7 +515,7 @@ html.theme-switching *::after { using neutral-500 for the light context. Confirm intended value. */ --color-text-primary: var(--color-ds-neutral-500); --color-text-secondary: var(--color-ds-neutral-400); - --color-text-muted: var(--color-ds-neutral-300); + --color-text-muted: var(--color-ds-neutral-350); /* Mega-menu item title rest color; brightens to text-primary on hover. Light menu → muted dark; dark menu → neutral tint (see html.dark). */ --color-text-menu-title: var(--color-ds-neutral-400); @@ -317,6 +533,11 @@ html.theme-switching *::after { --color-background-subtle: #fafafa; --color-background-inverse: var(--color-ds-neutral-500); + /* Scrim behind modal surfaces. Deliberately heavier in dark mode: an + identical alpha reads as weaker separation once the page beneath it is + already dark. Replaces seven hand-picked black/NN values. */ + --color-scrim: rgb(0 0 0 / 0.5); + --color-border-default: var(--color-ds-neutral-200); --color-border-strong: var(--color-ds-neutral-400); --color-border-subtle: var(--color-ds-neutral-100); @@ -538,8 +759,11 @@ html.light body { Primitives don't change between modes — only these semantic mappings do. */ html.dark { --color-text-primary: #ffffff; - --color-text-secondary: #aea691; - --color-text-muted: #756c5b; + /* = ds-neutral-150 and ds-neutral-200. Kept as literals to match the rest of + this block; secondary and muted each moved one step lighter so both clear + AA against #111111 (11.3:1 and 7.8:1, from 7.8:1 and a failing 3.6:1). */ + --color-text-secondary: #cec8b2; + --color-text-muted: #aea691; --color-text-menu-title: var(--color-ds-neutral-tint-200); --color-text-accent: #61adbf; --color-text-disabled: #3e3529; @@ -555,6 +779,8 @@ html.dark { --color-background-subtle: #1b1b1b; --color-background-inverse: #ffffff; + --color-scrim: rgb(0 0 0 / 0.65); + --color-border-default: #2d2d2d; --color-border-strong: #aea691; --color-border-subtle: #232323; @@ -619,7 +845,7 @@ html.dark { color-scheme: light; --color-text-primary: var(--color-ds-neutral-500); --color-text-secondary: var(--color-ds-neutral-400); - --color-text-muted: var(--color-ds-neutral-300); + --color-text-muted: var(--color-ds-neutral-350); --color-text-inverse: #ffffff; --color-text-error: var(--color-ds-terracotta-500); --color-background-default: #ffffff; @@ -627,6 +853,7 @@ html.dark { --color-background-elevated: #ffffff; --color-background-subtle: #fafafa; --color-background-inverse: var(--color-ds-neutral-500); + --color-scrim: rgb(0 0 0 / 0.5); --color-border-default: var(--color-ds-neutral-200); --color-border-subtle: var(--color-ds-neutral-100); --color-border-strong: var(--color-ds-neutral-400); @@ -661,8 +888,11 @@ html.dark { .ds-mode-dark { color-scheme: dark; --color-text-primary: #ffffff; - --color-text-secondary: #aea691; - --color-text-muted: #756c5b; + /* = ds-neutral-150 and ds-neutral-200. Kept as literals to match the rest of + this block; secondary and muted each moved one step lighter so both clear + AA against #111111 (11.3:1 and 7.8:1, from 7.8:1 and a failing 3.6:1). */ + --color-text-secondary: #cec8b2; + --color-text-muted: #aea691; --color-text-inverse: #111111; --color-text-error: var(--color-ds-terracotta-200); --color-background-default: #111111; @@ -670,6 +900,7 @@ html.dark { --color-background-elevated: #2b2b2b; --color-background-subtle: #1b1b1b; --color-background-inverse: #ffffff; + --color-scrim: rgb(0 0 0 / 0.65); --color-border-default: #2d2d2d; --color-border-subtle: #232323; --color-border-strong: #aea691; From c36b4b5598b33d8608032d68527531eebd7ccf17 Mon Sep 17 00:00:00 2001 From: Abeuty Date: Fri, 28 Aug 2026 14:37:29 -0600 Subject: [PATCH 2/6] feat(ds): add Drawer anchor prop, migrate CartDrawer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the audit's sixth posture — the anchored panel. It turned out not to need a new component. CartDrawer already matched `Drawer side="right" fit` in every respect except one: its top edge cleared the site header. That offset had nowhere to live, which is the only reason it was a separate implementation. So this adds `anchor="viewport" | "navbar"` rather than a near-duplicate of Drawer. `navbar` reads --navbar-height with the same 56px fallback the navbar itself uses, and shortens the `fit` height cap by the same amount so a content-sized panel still cannot run off the bottom. Verified against a live 58px navbar: viewport anchors at 12px, navbar at 66px. CartDrawer now uses it, and settles the open question about the shop's parallel token namespace: the primitive supplies posture and behaviour while the caller passes `shop-scope` and its surface colours through className. Panel geometry is unchanged at 384px wide; the right gutter normalises from 16px to the DS 12px. That leaves ProductDrawer as the last hand-rolled overlay on the site. Co-Authored-By: Claude Opus 5 --- src/components/ds/ds-nav.ts | 1 + .../ds/overlay-audit/specimen-meta.ts | 4 +- src/components/ds/ui/Drawer.tsx | 43 +++++++++- src/components/ds/ui/index.tsx | 1 + src/components/shop/CartDrawer.tsx | 85 +++++++++---------- src/routes/ds.drawer.tsx | 52 ++++++++++++ 6 files changed, 138 insertions(+), 48 deletions(-) diff --git a/src/components/ds/ds-nav.ts b/src/components/ds/ds-nav.ts index 622df754b..1e9461866 100644 --- a/src/components/ds/ds-nav.ts +++ b/src/components/ds/ds-nav.ts @@ -162,6 +162,7 @@ export const dsNav: Array = [ 'Sides', 'Sizes', 'Fit to content', + 'Anchor', 'Header actions', 'With a footer', 'Motion', diff --git a/src/components/ds/overlay-audit/specimen-meta.ts b/src/components/ds/overlay-audit/specimen-meta.ts index 87eded38b..1f80659ce 100644 --- a/src/components/ds/overlay-audit/specimen-meta.ts +++ b/src/components/ds/overlay-audit/specimen-meta.ts @@ -186,7 +186,7 @@ export const SPECIMENS: Array = [ scrollLock: true, animated: false, notes: - 'Header / scroll body / footer — the only three-region layout, and the shape the DS panel should adopt. Separate token namespace loaded only on /shop. No animation: shop.css defines no cart-panel transition.', + 'MIGRATED — now Drawer side="right" size="sm" fit anchor="navbar". Its navbar offset became the DS `anchor` prop, which is the whole of this posture. Keeps its own surface colours by passing shop-scope through className: the primitive supplies posture and behaviour, the shop supplies the palette.', }, { id: 'product-drawer', @@ -271,7 +271,7 @@ export const DIVERGENCE = [ property: 'Base', values: ['Radix (7)', 'hand-rolled (4)'], verdict: - 'The 4 hand-rolled ones account for every a11y failure in the audit.', + 'The 4 hand-rolled ones accounted for every a11y failure in the audit. Three are now migrated; ProductDrawer is the last one left.', }, { property: 'Token layer', diff --git a/src/components/ds/ui/Drawer.tsx b/src/components/ds/ui/Drawer.tsx index 2fa2b9a84..6917b4388 100644 --- a/src/components/ds/ui/Drawer.tsx +++ b/src/components/ds/ui/Drawer.tsx @@ -25,6 +25,14 @@ export const DrawerTrigger = DialogPrimitive.Trigger export const DrawerClose = DialogPrimitive.Close export type DrawerSide = 'right' | 'left' | 'bottom' +/** + * What the panel is pinned to. `viewport` sits in the window's top gutter; + * `navbar` clears the site header, so the panel reads as belonging to the + * chrome that opened it rather than floating over it. This is the audit's + * "anchored panel" posture — it was a separate implementation in CartDrawer + * only because a top offset had nowhere to live. + */ +export type DrawerAnchor = 'viewport' | 'navbar' export type DrawerSize = 'sm' | 'md' | 'lg' | 'xl' | '2xl' /** Width for right/left; the panel spans the full height minus a gutter. */ @@ -50,8 +58,8 @@ const sideStyles: Record = { // the posture change BuilderGuideDialog established and it is the right // default: a 24rem side panel on a 375px screen is not a side panel. right: - 'left-3 right-3 top-3 rounded-xl corner-squircle sm:left-auto sm:right-3 sm:w-full', - left: 'left-3 right-3 top-3 rounded-xl corner-squircle sm:right-auto sm:left-3 sm:w-full', + 'left-3 right-3 rounded-xl corner-squircle sm:left-auto sm:right-3 sm:w-full', + left: 'left-3 right-3 rounded-xl corner-squircle sm:right-auto sm:left-3 sm:w-full', // Centred with auto margins, not a translate — see the note in app.css. bottom: 'left-4 right-4 bottom-0 mx-auto max-w-[1400px] rounded-t-2xl border-b-0', @@ -68,10 +76,32 @@ const horizontalHeight = { fit: 'bottom-auto max-h-[calc(100dvh-1.5rem)]', } as const +/** + * Top edge and, when `fit`, the height cap that follows from it. The navbar + * height is read from `--navbar-height` with the same 56px fallback the + * navbar itself uses, so the panel stays correct if the header resizes. + */ +const horizontalAnchor: Record = + { + viewport: { + top: 'top-3', + fitCap: 'max-h-[calc(100dvh-1.5rem)]', + }, + navbar: { + top: 'top-[calc(var(--navbar-height,56px)+0.5rem)]', + fitCap: 'max-h-[calc(100dvh-var(--navbar-height,56px)-1rem)]', + }, + } + type DrawerContentProps = { children: React.ReactNode side?: DrawerSide size?: DrawerSize + /** + * Where a right/left panel's top edge sits. Ignored for `side="bottom"`, + * which is anchored to the opposite edge. + */ + anchor?: DrawerAnchor /** * Size the panel to its content instead of filling the edge, capped at the * viewport. Use it when the drawer's content is short and self-contained (a @@ -91,6 +121,7 @@ export const DrawerContent = React.forwardRef< children, side = 'right', size = 'md', + anchor = 'viewport', fit = false, className, onInteractOutside, @@ -116,7 +147,13 @@ export const DrawerContent = React.forwardRef< ? fit ? 'h-auto max-h-[85dvh]' : bottomSize[size] - : [horizontalSize[size], horizontalHeight[fit ? 'fit' : 'full']], + : [ + horizontalSize[size], + horizontalAnchor[anchor].top, + fit + ? ['bottom-auto', horizontalAnchor[anchor].fitCap] + : horizontalHeight.full, + ], className, )} > diff --git a/src/components/ds/ui/index.tsx b/src/components/ds/ui/index.tsx index 8250df4d8..bbd3f0a6a 100644 --- a/src/components/ds/ui/index.tsx +++ b/src/components/ds/ui/index.tsx @@ -925,6 +925,7 @@ export { DrawerFooter, DrawerHeader, DrawerTrigger, + type DrawerAnchor, type DrawerSide, type DrawerSize, } from './Drawer' diff --git a/src/components/shop/CartDrawer.tsx b/src/components/shop/CartDrawer.tsx index e9582e004..2d6791170 100644 --- a/src/components/shop/CartDrawer.tsx +++ b/src/components/shop/CartDrawer.tsx @@ -1,17 +1,21 @@ -import * as Dialog from '@radix-ui/react-dialog' import { Link } from '@tanstack/react-router' import { MinusIcon, PlusIcon, ShoppingCartIcon, TrashIcon, - XIcon, } from '@phosphor-icons/react' import { twMerge } from 'tailwind-merge' import { useCart, useRemoveCartLine, useUpdateCartLine } from '~/hooks/useCart' import { formatMoney, shopifyImageUrl } from '~/utils/shopify-format' import type { CartLineDetail } from '~/utils/shopify-queries' import { ShopLabel, ShopMono } from './ui' +import { + Drawer, + DrawerBody, + DrawerContent, + DrawerHeader, +} from '~/components/ds/ui' type CartDrawerProps = { open: boolean @@ -28,38 +32,33 @@ export function CartDrawer({ open, onOpenChange }: CartDrawerProps) { const hasLines = !!cart && cart.lines.nodes.length > 0 return ( - - - - -
- - - Cart{totalQuantity > 0 ? ` (${totalQuantity})` : ''} - - - - - -
+ + {/* The DS panel supplies posture and behaviour; the shop keeps its own + surface colours by overriding the semantic defaults. `anchor="navbar"` + is what used to be this component's hand-written top offset. */} + + + Cart{totalQuantity > 0 ? ` (${totalQuantity})` : ''} + + } + /> - {hasLines ? ( - <> -
    + {hasLines ? ( + <> + +
      {cart.lines.nodes.map((line) => ( ))}
    - onOpenChange(false)} /> - - ) : ( - onOpenChange(false)} /> - )} - - - +
    + onOpenChange(false)} /> + + ) : ( + onOpenChange(false)} /> + )} + + ) } -function DrawerEmpty({ onClose }: { onClose: () => void }) { +function CartEmpty({ onClose }: { onClose: () => void }) { return (
    @@ -102,7 +101,7 @@ function DrawerEmpty({ onClose }: { onClose: () => void }) { ) } -function DrawerFooter({ +function CartFooter({ cart, onClose, }: { diff --git a/src/routes/ds.drawer.tsx b/src/routes/ds.drawer.tsx index 07ade289c..0b6f3c80e 100644 --- a/src/routes/ds.drawer.tsx +++ b/src/routes/ds.drawer.tsx @@ -10,6 +10,7 @@ import { DrawerContent, DrawerFooter, DrawerHeader, + type DrawerAnchor, type DrawerSide, type DrawerSize, } from '~/components/ds/ui' @@ -35,6 +36,7 @@ function DrawerPage() { const [guide, setGuide] = React.useState(false) const [footer, setFooter] = React.useState(false) const [fitDemo, setFitDemo] = React.useState<'full' | 'fit' | null>(null) + const [anchorDemo, setAnchorDemo] = React.useState(null) return ( + + … + +{/* clears the site header */} +`} + > +
    + + +
    + !open && setAnchorDemo(null)} + > + + + +

    + Scroll up to the header and compare where the panel's top edge + lands. `navbar` also shortens the height cap by the same + amount, so a `fit` panel still never runs off the bottom. +

    +
    +
    +
    +
    +
    + Date: Fri, 28 Aug 2026 14:45:22 -0600 Subject: [PATCH 3/6] feat(ds): migrate ProductDrawer to the DS Drawer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last hand-rolled overlay on the site. It was a bare - - {allHandles.length > 1 ? ( - <> - - - - - ) : null} - + + ) } /* ─── Full product content ────────────────────────────────────────────── */ -function DrawerContent({ +function ProductPanel({ product, animateIn, }: { diff --git a/src/styles/shop.css b/src/styles/shop.css index 0fa3ecfc3..29ef79756 100644 --- a/src/styles/shop.css +++ b/src/styles/shop.css @@ -95,16 +95,11 @@ html.dark .shop-scope { min-height: 100%; } -.shop-product-scrim { - transition-duration: var(--motion-duration-fast); -} - +/* Height and enter/exit motion now come from the DS Drawer. This keeps only + the custom property, which descendant rules below still read, so the cap + has exactly one source of truth (applied as a max-h utility on the panel). */ .shop-product-sheet { --shop-product-sheet-top: calc(var(--navbar-height, 56px) + 48px); - - max-height: calc(100svh - var(--shop-product-sheet-top)); - transition-duration: var(--motion-duration-sheet); - transition-timing-function: var(--motion-ease-sheet); } .shop-product-animate .shop-product-reveal { From a88ce8fff3669e5a006d7920ff4c2ae097adf768 Mon Sep 17 00:00:00 2001 From: Abeuty Date: Fri, 28 Aug 2026 14:58:46 -0600 Subject: [PATCH 4/6] feat(ds): add Takeover primitive, migrate LibrariesOverlay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The full-bleed posture — the fifth of the audit's six, and the last one with a shipping caller other than SearchModal. Unlike Dialog and Drawer there is no panel: the content element is itself the viewport-filling scroll container, with the close affordance floating over it. That is why it is a third component rather than a Drawer variant — a drawer is defined by having a panel anchored to an edge, and this has neither. Its bespoke glass treatment becomes the DS's second scrim, which is exactly the pair the audit asked for after finding seven hand-picked values: --color-scrim for panels, and `glass` for takeovers that should feel like a new surface rather than a layer over the old one. Verified identical to the original at blur(40px) saturate(1.5) over rgb(0 0 0 / 0.95) in dark. Backdrop dismissal routes through the same close button the component already renders rather than a second dismissal path that could drift from it — Radix never sees a click as "outside" when the content fills the viewport. LibrariesBrowser also drops its direct @radix-ui/react-dialog import in favour of TakeoverTitle / TakeoverDescription, so content components no longer reach past the design system for their accessible name. Verified in the real overlay: glass scrim intact, role=dialog, labelled and described, custom scrollbar preserved, focus trapped inside. The audit's Scrim row is now closed apart from SearchModal. Co-Authored-By: Claude Opus 5 --- src/components/LibrariesBrowser.tsx | 10 +- src/components/LibrariesOverlay.tsx | 50 +++--- src/components/ds/ds-nav.ts | 5 + .../ds/overlay-audit/specimen-meta.ts | 5 +- src/components/ds/ui/Takeover.tsx | 118 +++++++++++++ src/components/ds/ui/index.tsx | 9 + src/routeTree.gen.ts | 21 +++ src/routes/ds.takeover.tsx | 158 ++++++++++++++++++ src/styles/app.css | 58 +++++++ 9 files changed, 397 insertions(+), 37 deletions(-) create mode 100644 src/components/ds/ui/Takeover.tsx create mode 100644 src/routes/ds.takeover.tsx diff --git a/src/components/LibrariesBrowser.tsx b/src/components/LibrariesBrowser.tsx index 2254a454a..cfbaa2612 100644 --- a/src/components/LibrariesBrowser.tsx +++ b/src/components/LibrariesBrowser.tsx @@ -1,8 +1,8 @@ import * as React from 'react' -import * as DialogPrimitive from '@radix-ui/react-dialog' import { CaretDownIcon } from '@phosphor-icons/react/CaretDown' import { CheckIcon } from '@phosphor-icons/react/Check' import { type Framework } from '~/libraries' +import { TakeoverDescription, TakeoverTitle } from '~/components/ds/ui' import { frameworkOptions } from '~/libraries/frameworks' import LibraryGridCard from '~/components/LibraryGridCard' import { libraryCategories, type LibraryCategory } from '~/libraries/categories' @@ -109,9 +109,9 @@ export function LibrariesBrowser({ /> ) : null} {variant === 'dialog' ? ( - + {title} - + ) : (

    {title} @@ -119,7 +119,7 @@ export function LibrariesBrowser({ )}

    {variant === 'dialog' ? ( - {description} - + ) : (

    void }) { return ( - { if (!next) onClose() }} > - - - { - const target = event.detail.originalEvent.target - if (target instanceof Element && target.closest('[role="menu"]')) { - event.preventDefault() - } - }} - onClick={(event) => { - if (event.target === event.currentTarget) onClose() - }} - > - {onBack ? ( + Back to menu - ) : null} - - - - - - - + ) : null + } + onInteractOutside={(event) => { + const target = event.detail.originalEvent.target + if (target instanceof Element && target.closest('[role="menu"]')) { + event.preventDefault() + } + }} + > + + + ) } diff --git a/src/components/ds/ds-nav.ts b/src/components/ds/ds-nav.ts index 1e9461866..027387d0b 100644 --- a/src/components/ds/ds-nav.ts +++ b/src/components/ds/ds-nav.ts @@ -236,6 +236,11 @@ export const dsNav: Array = [ to: '/ds/stats', sections: ['Preview'], }, + { + label: 'Takeover', + to: '/ds/takeover', + sections: ['Scrims', 'Leading action', 'Notes'], + }, { label: 'Tabs', to: '/ds/tabs', diff --git a/src/components/ds/overlay-audit/specimen-meta.ts b/src/components/ds/overlay-audit/specimen-meta.ts index a16b19efa..91576b217 100644 --- a/src/components/ds/overlay-audit/specimen-meta.ts +++ b/src/components/ds/overlay-audit/specimen-meta.ts @@ -224,7 +224,7 @@ export const SPECIMENS: Array = [ scrollLock: true, animated: true, notes: - 'Immersive: no panel at all, content scrolls edge to edge with a floating close button on its own tier.', + 'MIGRATED — now Takeover with scrim="glass". Its bespoke glass treatment became the DS\'s second scrim, which is the pair the audit asked for after finding seven. LibrariesBrowser also dropped its direct Radix import in favour of TakeoverTitle / TakeoverDescription.', }, { id: 'search-modal', @@ -266,7 +266,8 @@ export const DIVERGENCE = [ 'black/60→30 responsive', 'bespoke glass', ], - verdict: 'Seven scrims. Should be two: standard and immersive.', + verdict: + "Seven scrims. Now two, as recommended: --color-scrim for panels, and the glass treatment as Takeover's immersive variant. Only SearchModal still declares its own.", }, { property: 'Base', diff --git a/src/components/ds/ui/Takeover.tsx b/src/components/ds/ui/Takeover.tsx new file mode 100644 index 000000000..0adddc8ae --- /dev/null +++ b/src/components/ds/ui/Takeover.tsx @@ -0,0 +1,118 @@ +import * as React from 'react' +import * as DialogPrimitive from '@radix-ui/react-dialog' +import { XIcon } from '@phosphor-icons/react/X' +import { twMerge } from 'tailwind-merge' + +/** + * Full-bleed takeover — the immersive posture. + * + * Unlike Dialog and Drawer there is no panel: the content fills the viewport + * and scrolls edge to edge, with the close affordance floating over it. Use it + * when the overlay *is* the destination rather than something shown alongside + * the page. + * + * + * }> + * All Libraries + * … + * + * + */ + +export const Takeover = DialogPrimitive.Root +export const TakeoverTrigger = DialogPrimitive.Trigger +export const TakeoverClose = DialogPrimitive.Close + +/** + * The accessible name and description. A takeover has no header bar, so these + * are placed wherever the content wants them — visible or `sr-only`. + */ +export const TakeoverTitle = DialogPrimitive.Title +export const TakeoverDescription = DialogPrimitive.Description + +export type TakeoverScrim = 'standard' | 'glass' + +type TakeoverContentProps = { + children: React.ReactNode + /** + * `standard` dims the page with the shared scrim. `glass` dissolves it + * behind a heavy blur — for takeovers that should feel like a new surface + * rather than a layer over the old one. + */ + scrim?: TakeoverScrim + /** An action floated top-left, opposite the close button (e.g. "Back"). */ + leading?: React.ReactNode + closeLabel?: string + /** + * Close when the backdrop area of the content is clicked. On by default: + * the content fills the viewport, so its empty space reads as the backdrop + * even though it is technically inside the dialog. + */ + dismissOnBackdropClick?: boolean + className?: string + onInteractOutside?: DialogPrimitive.DialogContentProps['onInteractOutside'] +} + +export const TakeoverContent = React.forwardRef< + HTMLDivElement, + TakeoverContentProps +>(function TakeoverContent( + { + children, + scrim = 'standard', + leading, + closeLabel = 'Close', + dismissOnBackdropClick = true, + className, + onInteractOutside, + }, + ref, +) { + // The content fills the viewport, so Radix never sees a click as "outside". + // Backdrop dismissal therefore routes through the close button we already + // render, rather than a second dismissal path that could drift from it. + const closeRef = React.useRef(null) + + return ( + + + { + // Only the content's own empty space, never a click that + // bubbled up from something inside it. + if (event.target === event.currentTarget) { + closeRef.current?.click() + } + } + : undefined + } + className={twMerge( + 'fixed inset-0 z-[var(--z-overlay)] flex flex-col overflow-y-auto outline-none', + className, + )} + > + {leading ? ( +

    {leading}
    + ) : null} + + + + + + {children} + + + ) +}) diff --git a/src/components/ds/ui/index.tsx b/src/components/ds/ui/index.tsx index 78b41cc0f..b3725f4f6 100644 --- a/src/components/ds/ui/index.tsx +++ b/src/components/ds/ui/index.tsx @@ -931,6 +931,15 @@ export { type DrawerSide, type DrawerSize, } from './Drawer' +export { + Takeover, + TakeoverClose, + TakeoverContent, + TakeoverDescription, + TakeoverTitle, + TakeoverTrigger, + type TakeoverScrim, +} from './Takeover' export { PalmSpinner } from './PalmSpinner' export { PixelSpinner } from './PixelSpinner' export { diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 01b9f8755..6825cf783 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -67,6 +67,7 @@ import { Route as OauthRegisterRouteImport } from './routes/oauth/register' import { Route as OauthAuthorizeRouteImport } from './routes/oauth/authorize' import { Route as LibrariesFrameworkRouteImport } from './routes/libraries_.$framework' import { Route as DsTypographyRouteImport } from './routes/ds.typography' +import { Route as DsTakeoverRouteImport } from './routes/ds.takeover' import { Route as DsTabsRouteImport } from './routes/ds.tabs' import { Route as DsStatsRouteImport } from './routes/ds.stats' import { Route as DsSpinnerRouteImport } from './routes/ds.spinner' @@ -507,6 +508,11 @@ const DsTypographyRoute = DsTypographyRouteImport.update({ path: '/typography', getParentRoute: () => DsRoute, } as any) +const DsTakeoverRoute = DsTakeoverRouteImport.update({ + id: '/takeover', + path: '/takeover', + getParentRoute: () => DsRoute, +} as any) const DsTabsRoute = DsTabsRouteImport.update({ id: '/tabs', path: '/tabs', @@ -1409,6 +1415,7 @@ export interface FileRoutesByFullPath { '/ds/spinner': typeof DsSpinnerRoute '/ds/stats': typeof DsStatsRoute '/ds/tabs': typeof DsTabsRoute + '/ds/takeover': typeof DsTakeoverRoute '/ds/typography': typeof DsTypographyRoute '/libraries/$framework': typeof LibrariesFrameworkRoute '/oauth/authorize': typeof OauthAuthorizeRoute @@ -1610,6 +1617,7 @@ export interface FileRoutesByTo { '/ds/spinner': typeof DsSpinnerRoute '/ds/stats': typeof DsStatsRoute '/ds/tabs': typeof DsTabsRoute + '/ds/takeover': typeof DsTakeoverRoute '/ds/typography': typeof DsTypographyRoute '/libraries/$framework': typeof LibrariesFrameworkRoute '/oauth/authorize': typeof OauthAuthorizeRoute @@ -1817,6 +1825,7 @@ export interface FileRoutesById { '/ds/spinner': typeof DsSpinnerRoute '/ds/stats': typeof DsStatsRoute '/ds/tabs': typeof DsTabsRoute + '/ds/takeover': typeof DsTakeoverRoute '/ds/typography': typeof DsTypographyRoute '/libraries_/$framework': typeof LibrariesFrameworkRoute '/oauth/authorize': typeof OauthAuthorizeRoute @@ -2028,6 +2037,7 @@ export interface FileRouteTypes { | '/ds/spinner' | '/ds/stats' | '/ds/tabs' + | '/ds/takeover' | '/ds/typography' | '/libraries/$framework' | '/oauth/authorize' @@ -2229,6 +2239,7 @@ export interface FileRouteTypes { | '/ds/spinner' | '/ds/stats' | '/ds/tabs' + | '/ds/takeover' | '/ds/typography' | '/libraries/$framework' | '/oauth/authorize' @@ -2435,6 +2446,7 @@ export interface FileRouteTypes { | '/ds/spinner' | '/ds/stats' | '/ds/tabs' + | '/ds/takeover' | '/ds/typography' | '/libraries_/$framework' | '/oauth/authorize' @@ -3063,6 +3075,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DsTypographyRouteImport parentRoute: typeof DsRoute } + '/ds/takeover': { + id: '/ds/takeover' + path: '/takeover' + fullPath: '/ds/takeover' + preLoaderRoute: typeof DsTakeoverRouteImport + parentRoute: typeof DsRoute + } '/ds/tabs': { id: '/ds/tabs' path: '/tabs' @@ -4395,6 +4414,7 @@ interface DsRouteChildren { DsSpinnerRoute: typeof DsSpinnerRoute DsStatsRoute: typeof DsStatsRoute DsTabsRoute: typeof DsTabsRoute + DsTakeoverRoute: typeof DsTakeoverRoute DsTypographyRoute: typeof DsTypographyRoute DsIndexRoute: typeof DsIndexRoute } @@ -4426,6 +4446,7 @@ const DsRouteChildren: DsRouteChildren = { DsSpinnerRoute: DsSpinnerRoute, DsStatsRoute: DsStatsRoute, DsTabsRoute: DsTabsRoute, + DsTakeoverRoute: DsTakeoverRoute, DsTypographyRoute: DsTypographyRoute, DsIndexRoute: DsIndexRoute, } diff --git a/src/routes/ds.takeover.tsx b/src/routes/ds.takeover.tsx new file mode 100644 index 000000000..2de71a5e9 --- /dev/null +++ b/src/routes/ds.takeover.tsx @@ -0,0 +1,158 @@ +import * as React from 'react' +import { createFileRoute } from '@tanstack/react-router' +import { ArrowLeftIcon } from '@phosphor-icons/react' +import { seo } from '~/utils/seo' +import { + Button, + Takeover, + TakeoverContent, + TakeoverDescription, + TakeoverTitle, + type TakeoverScrim, +} from '~/components/ds/ui' +import { ComponentPreview, DsPage, DsSection } from '~/components/ds/DsKit' + +export const Route = createFileRoute('/ds/takeover')({ + component: TakeoverPage, + head: () => ({ + meta: seo({ + title: 'Takeover | TanStack Design System', + description: 'The full-bleed, immersive overlay posture.', + }), + }), +}) + +function TakeoverPage() { + const [scrim, setScrim] = React.useState(null) + const [withLeading, setWithLeading] = React.useState(false) + + return ( + + + + + All Libraries + … + +`} + > +
    + {(['standard', 'glass'] as const).map((s) => ( + + ))} +
    + !open && setScrim(null)} + > + +
    + + scrim="{scrim}" + + + The content is the scroll container. Clicking its empty space + dismisses, which is why a takeover does not need a separate + backdrop element. + +
    + {Array.from({ length: 8 }).map((_, i) => ( +
    +

    + Card {i + 1} +

    +

    + Content scrolls edge to edge. +

    +
    + ))} +
    +
    +
    +
    +
    +
    + + + + Back to menu + + } +/>`} + > + + + + + Back to menu + + } + > +
    + + Leading action + + + Both floating controls sit above the scrolling content and + stay put as it moves. + +
    +
    +
    +
    +
    + + +
      + {[ + 'No panel and no header bar. TakeoverTitle / TakeoverDescription go wherever the content wants them — visible or sr-only.', + 'The content element is the scroll container, so a takeover never nests a second scrollbar.', + 'Clicking the content’s own empty space dismisses. It routes through the same close button rather than a second dismissal path that could drift from it.', + 'onInteractOutside is forwarded, for content that portals menus of its own and must not close when they are clicked.', + 'The surface fades rather than slides: nothing is arriving from an edge, the whole surface is being replaced.', + ].map((line) => ( +
    • + + {line} +
    • + ))} +
    +
    +
    + ) +} diff --git a/src/styles/app.css b/src/styles/app.css index 15e1dc477..85b7b3d84 100644 --- a/src/styles/app.css +++ b/src/styles/app.css @@ -236,6 +236,64 @@ html.theme-switching *::after { /* The dialog is the first DS component with real motion, so it states the reduced-motion contract the rest of the system should follow: the state change still happens, it just stops moving. */ +/* --------------------------------------------------------------- Takeover -- + The full-bleed posture. Two scrims, which is what the overlay audit asked + for: `standard` is the same --color-scrim every other overlay uses, and + `glass` is the immersive treatment that dissolves the page behind a heavy + blur instead of dimming it. Seven hand-picked scrims existed before this. + + The panel fades rather than slides — nothing is arriving from an edge, the + whole surface is being replaced. + -------------------------------------------------------------------------- */ +[data-ds-takeover-scrim='standard'] { + background: var(--color-scrim); +} + +[data-ds-takeover-scrim='glass'] { + background: rgb(255 255 255 / 0.95); + -webkit-backdrop-filter: blur(40px) saturate(1.5); + backdrop-filter: blur(40px) saturate(1.5); +} + +html.dark [data-ds-takeover-scrim='glass'] { + background: rgb(0 0 0 / 0.95); +} + +@keyframes takeover-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} +@keyframes takeover-out { + from { + opacity: 1; + } + to { + opacity: 0; + } +} + +[data-ds-takeover-scrim][data-state='open'], +[data-ds-takeover-panel][data-state='open'] { + animation: takeover-in var(--motion-duration-fast) var(--motion-ease-standard) + both; +} +[data-ds-takeover-scrim][data-state='closed'], +[data-ds-takeover-panel][data-state='closed'] { + animation: takeover-out var(--motion-duration-fast) + var(--motion-ease-standard) both; +} + +@media (prefers-reduced-motion: reduce) { + [data-ds-takeover-scrim], + [data-ds-takeover-panel] { + animation: none !important; + } +} + /* ----------------------------------------------------------------- Drawer -- Edge-anchored panels. Slides only — the panel does not fade, because a sheet reads as a physical surface arriving from off-screen and a From 3dde83648b0a83f648e238a694e55535c2b2939d Mon Sep 17 00:00:00 2001 From: Abeuty Date: Fri, 28 Aug 2026 15:07:43 -0600 Subject: [PATCH 5/6] refactor(ds): put SearchModal on the DS scrim and stacking tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the last bespoke scrim and the last bespoke z-tier in the overlay audit. Deliberately does NOT extract a CommandPalette. SearchModal is already Radix, already accessible, and already has real data-state animation, so an extraction buys no correctness on 3,766 load-bearing lines. It would also need five caller-specific escape hatches: forceMount on Portal/Overlay/Content to keep InstantSearch state alive between opens; animation applied to an inner panel because Content is a full-bleed hit area on mobile; the top-anchored responsive posture; a conditional sm:bottom-4 for full-height results; and a scrim that lightens at xl. Five hatches for one caller is a primitive shaped entirely by its first consumer. The reasoning is recorded in the audit metadata so it outlives the conversation — revisit if a second palette ever appears. What did change is tokens only, 7 lines: - The overlay moves to bg-scrim. The xl override stays as the one deliberate exception: past that width the palette covers a small share of the screen and the full scrim reads heavier than the interaction warrants. - z-[999] / z-[1000] become --z-scrim / --z-overlay, on both the palette and the AI dock that shares the tier. - Adds --z-above-overlay (1200) for chrome that must float over an open overlay — the palette's tooltips had nowhere else to go. That completes the stacking scale; no overlay declares its own tier any more. Verified live: scrim resolves to --color-scrim below xl and to the intended lighter value above it, tiers read 999/1000, the top-4 anchoring and the component's own enter animation are untouched. Co-Authored-By: Claude Opus 5 --- src/components/SearchModal.tsx | 11 +++++++---- src/components/ds/overlay-audit/specimen-meta.ts | 4 ++-- src/routes/ds.dialog.tsx | 2 +- src/styles/app.css | 4 ++++ 4 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/components/SearchModal.tsx b/src/components/SearchModal.tsx index ecb9a3861..070501ecd 100644 --- a/src/components/SearchModal.tsx +++ b/src/components/SearchModal.tsx @@ -2763,7 +2763,7 @@ function SearchResultsToggle() { {tooltipRect && typeof document !== 'undefined' ? createPortal(
    { @@ -3706,7 +3709,7 @@ export function AiDock() { inert={!isAiDockOpen} style={dockStyle} className={twMerge( - 'fixed top-[var(--navbar-height)] right-0 bottom-0 z-[1000] w-full max-w-full pointer-events-none', + 'fixed top-[var(--navbar-height)] right-0 bottom-0 z-[var(--z-overlay)] w-full max-w-full pointer-events-none', isDockMaximized ? 'sm:w-[min(var(--ai-dock-max-width),100vw)]' : 'sm:w-[var(--ai-dock-width)]', diff --git a/src/components/ds/overlay-audit/specimen-meta.ts b/src/components/ds/overlay-audit/specimen-meta.ts index 91576b217..fa3ab110f 100644 --- a/src/components/ds/overlay-audit/specimen-meta.ts +++ b/src/components/ds/overlay-audit/specimen-meta.ts @@ -243,7 +243,7 @@ export const SPECIMENS: Array = [ scrollLock: true, animated: true, notes: - 'Command palette. forceMount + bespoke CSS transitions; overrides onInteractOutside for nested portals. Migrate last.', + 'TOKENS ADOPTED, POSTURE NOT EXTRACTED. Now on bg-scrim and the --z-scrim / --z-overlay / --z-above-overlay tiers, which closes the last bespoke values in the audit. The shell itself stays: it is already Radix, already accessible, and already has real data-state animation, so extracting a CommandPalette buys no correctness — and it would need five caller-specific escape hatches (forceMount on Portal/Overlay/Content to keep InstantSearch state alive; animation on an inner panel because Content is a full-bleed hit area on mobile; the top-anchored responsive posture; a conditional sm:bottom-4; and a scrim that lightens at xl). One caller is below the extraction threshold. Revisit if a second command palette appears.', }, ] @@ -253,7 +253,7 @@ export const DIVERGENCE = [ property: 'z-index tier', values: ['50', '60/70/71', '100', '110/111/112', '999/1000'], verdict: - 'Five unrelated stacking families. Now a documented pair (--z-scrim / --z-overlay) that every migrated overlay uses; only SearchModal and LibrariesOverlay still declare their own.', + 'Five unrelated stacking families. Now a documented scale — --z-scrim, --z-overlay, and --z-above-overlay for chrome that must float over an open overlay. No overlay declares its own tier any more.', }, { property: 'Scrim', diff --git a/src/routes/ds.dialog.tsx b/src/routes/ds.dialog.tsx index e149964d7..4a1172972 100644 --- a/src/routes/ds.dialog.tsx +++ b/src/routes/ds.dialog.tsx @@ -93,7 +93,7 @@ const TOKEN_COVERAGE: Array<{ attribute: 'Stacking tier', token: 'z-[var(--z-scrim)] / z-[var(--z-overlay)]', status: 'added', - note: 'Did not exist. Five unrelated z-index families were in use. Values set to 999/1000 — the existing majority — so adopting them moves nothing.', + note: 'Did not exist. Five unrelated z-index families were in use. Values set to 999/1000 — the existing majority — so adopting them moves nothing. A third tier, --z-above-overlay (1200), covers chrome that must float over an open overlay, such as a tooltip on a control inside a modal.', }, { attribute: 'Motion', diff --git a/src/styles/app.css b/src/styles/app.css index 85b7b3d84..990e3505d 100644 --- a/src/styles/app.css +++ b/src/styles/app.css @@ -161,6 +161,10 @@ html.theme-switching *::after { majority of overlays, so adopting them moves nothing. */ --z-scrim: 999; --z-overlay: 1000; + /* Floats above an open overlay — a tooltip on a control inside a modal has + nowhere else to go. Kept well clear of --z-overlay so overlay-internal + chrome can still stack between them. */ + --z-above-overlay: 1200; } /* ----------------------------------------------------------------- Dialog -- From 1602c0799e76a75e5591032d96a69a7de438f1c0 Mon Sep 17 00:00:00 2001 From: Abeuty Date: Fri, 28 Aug 2026 15:32:51 -0600 Subject: [PATCH 6/6] fix(ds): replace inert animation classes with real keyframes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every `animate-in` / `animate-out` / `fade-in-0` / `zoom-in-95` / `slide-in-from-*` class in the codebase came from tailwindcss-animate, which is not installed. They matched no CSS rule and animated nothing — the Tooltip, the workbench dropdown, the game HUD and the intro overlay have all been static since they were written. The `duration-300` / `duration-500` utilities sitting alongside them were inert for the same work: Tailwind's `duration-*` sets transition-duration, not animation-duration, so it never applied to an animation either. Replaces them with real keyframes on the existing motion tokens, following the pattern already used for Dialog, Drawer and Takeover rather than adding a dependency: - [data-ds-pop] for Radix-driven transient surfaces (Tooltip, the workbench dropdown menu), with enter and exit. - .ds-enter-fade / .ds-enter-rise / .ds-enter-pop for content that mounts already open (game HUD callouts, intro overlay). Tooltip does NOT report data-state="open". It uses `delayed-open` after the hover delay and `instant-open` inside the skip-delay window, so the selector has to match all three — matching only `open` leaves tooltips silently un-animated, which is how this was caught. `transform` carries scale and offset only, never a centring translate: Tailwind v4 compiles `-translate-x-1/2` to the independent `translate` property, which composes with `transform` rather than replacing it. That also leaves Radix's --radix-*-content-transform-origin free to govern where a popover grows from. Also aligns Tooltip's stacking from a bare z-1300 onto --z-above-overlay, so "floats above an open overlay" has one name rather than two values. Verified live: the tooltip animates ds-pop-in at 180ms with the DS easing on tier 1200. Co-Authored-By: Claude Opus 5 --- .../examples/ExampleWorkbench.client.tsx | 3 +- src/components/game/ui/GameHUD.tsx | 8 +- src/components/game/ui/IntroOverlay.tsx | 2 +- src/styles/app.css | 84 +++++++++++++++++++ src/ui/Tooltip.tsx | 7 +- 5 files changed, 94 insertions(+), 10 deletions(-) diff --git a/src/components/examples/ExampleWorkbench.client.tsx b/src/components/examples/ExampleWorkbench.client.tsx index cc62e6fb3..2d0054cf5 100644 --- a/src/components/examples/ExampleWorkbench.client.tsx +++ b/src/components/examples/ExampleWorkbench.client.tsx @@ -3545,7 +3545,8 @@ export function ExampleWorkbench({ align="start" sideOffset={4} collisionPadding={8} - className="sandbox-ui w-64 max-w-[calc(100vw-1rem)] origin-[var(--radix-dropdown-menu-content-transform-origin)] rounded-xl border-black/10 p-1 shadow-xl duration-100 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 dark:border-white/10 motion-reduce:animate-none" + data-ds-pop="" + className="sandbox-ui w-64 max-w-[calc(100vw-1rem)] origin-[var(--radix-dropdown-menu-content-transform-origin)] rounded-xl border-black/10 p-1 shadow-xl dark:border-white/10" > {/* Coin hint */} {showCoinHint && ( -
    +