Make Piggy part of the product rather than a guest in it
Piggy arrived as a chat panel bolted onto a CRM and then grew a workspace around it. The layout was already right — the audit found the approval card to be the best-designed object in the repo, and the account page's empty panels less finished than anything in the workspace. What was wrong was vocabulary: nobody had written the small things down, so both halves kept inventing them. Piggy was drawn with five different marks — a pig in the dock, a sparkle in the sidebar and again on the model picker, a speech bubble on the Ask buttons, and a stock robot glyph on every assistant message, which is the one people look at most. There is now one mark. The composer, which is the first control in the product since sign-in lands on /piggy, was the only un-adapted shadcn field left: 6px radius against a 12px Send button it sat 8px from. A stat tile had been reinvented six times at three numeral scales, and the same uppercase micro-label existed in five variants, two of them one tab apart in the same rail. There were 63 hand-written font sizes: not a scale, sixty-three opinions. Underneath that, the focus ring was invisible. The global rule used ring-accent, which Tailwind deliberately aliases onto the hover tint, so the ring measured 1.01:1 against the light canvas — no visible focus indicator anywhere in the product, for any accent, in either theme. It is ring-brand now and measures 17:1. The warning, positive and info tones were darkened until each clears 4.5:1 on a card, on inset and on its own chip, and the light canvas moved to 98% so a card lifts without leaning on its shadow. The mobile work is the part worth reading. A landscape phone gave the transcript 28% of the viewport and a keyboard-up phone 16%, against a 45% floor — and the fixed tab bar painted over the composer, covering the safety sentence and half the Send button, because two source comments asserted the bar stood down on short viewports and it never had. Both fixed and measured by hit-testing rather than by screenshot. The composer itself was 64px tall for a blank second line nobody typed, because the auto-resize effect sizes to scrollHeight and scrollHeight counts rows — a CSS height could not win against an inline style, so the attribute was the honest lever. Verified across both themes driven through the app's own control: no horizontal overflow on 15 routes at four viewports, 672 stat values that fit, 297 labels at exactly 11px/500, Escape returning focus to its opener rather than the body on every overlay, and a rejected write no longer reporting "Succeeded" with a green check. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* A confirmation that has to be read.
|
||||
*
|
||||
* An alert dialog is not a dialog with different copy. It refuses to be
|
||||
* dismissed by clicking past it, it opens with focus on the safe choice, and it
|
||||
* names the thing it is about to destroy — because the surface it replaces was
|
||||
* a plain `Dialog` whose default focus landed on the delete button and whose
|
||||
* scrim dismissed a decision the person had not made.
|
||||
*
|
||||
* Built on `@radix-ui/react-dialog` with `role="alertdialog"` rather than
|
||||
* `@radix-ui/react-alert-dialog`, which is not a dependency of this app. The
|
||||
* three behaviours that package adds are the three declared below, so the
|
||||
* component is the contract, not the package.
|
||||
*/
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button, type ButtonProps } from "@/components/ui"
|
||||
import {
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogTitle,
|
||||
useOverlayFocusRestore,
|
||||
} from "@/components/ui/dialog"
|
||||
|
||||
const AlertDialog = DialogPrimitive.Root
|
||||
|
||||
const AlertDialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const AlertDialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const AlertDialogOverlay = DialogOverlay
|
||||
|
||||
/*
|
||||
* The cancel control registers itself here so the content can put initial
|
||||
* focus on it. It has to be a registration rather than "focus the first
|
||||
* focusable", because the destructive action deliberately comes first in the
|
||||
* DOM — see AlertDialogFooter — and a destructive button holding focus the
|
||||
* moment the dialog opens is one Enter away from the thing the dialog exists
|
||||
* to prevent.
|
||||
*/
|
||||
const AlertDialogCancelContext =
|
||||
React.createContext<React.MutableRefObject<HTMLButtonElement | null> | null>(null)
|
||||
|
||||
const AlertDialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
children,
|
||||
onCloseAutoFocus,
|
||||
onOpenAutoFocus,
|
||||
onInteractOutside,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const focus = useOverlayFocusRestore(ref, onCloseAutoFocus)
|
||||
const cancelRef = React.useRef<HTMLButtonElement | null>(null)
|
||||
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={focus.ref}
|
||||
role="alertdialog"
|
||||
onCloseAutoFocus={focus.onCloseAutoFocus}
|
||||
onOpenAutoFocus={(event) => {
|
||||
onOpenAutoFocus?.(event)
|
||||
if (event.defaultPrevented) return
|
||||
const cancel = cancelRef.current
|
||||
if (!cancel) return
|
||||
event.preventDefault()
|
||||
cancel.focus()
|
||||
}}
|
||||
onInteractOutside={(event) => {
|
||||
onInteractOutside?.(event)
|
||||
// A decision is not made by clicking somewhere else.
|
||||
event.preventDefault()
|
||||
}}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-[calc(100%-2rem)] max-w-md translate-x-[-50%] translate-y-[-50%] gap-4 rounded-2xl border border-border bg-surface p-5 shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:duration-3 data-[state=closed]:duration-3 data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=open]:ease-enter data-[state=closed]:ease-exit",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<AlertDialogCancelContext.Provider value={cancelRef}>
|
||||
{children}
|
||||
</AlertDialogCancelContext.Provider>
|
||||
</DialogPrimitive.Content>
|
||||
</AlertDialogPortal>
|
||||
)
|
||||
}
|
||||
)
|
||||
AlertDialogContent.displayName = "AlertDialogContent"
|
||||
|
||||
const AlertDialogHeader = DialogHeader
|
||||
|
||||
/**
|
||||
* Destructive first in the DOM, last on the screen.
|
||||
*
|
||||
* A screen reader reads the footer in source order, and the consequence has to
|
||||
* arrive before the escape from it. Sighted order is the platform convention —
|
||||
* safe choice on the left, the commit on the right — and is restored with
|
||||
* `order`, which changes the painting and not the reading.
|
||||
*/
|
||||
const AlertDialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn("flex flex-col gap-2 sm:flex-row sm:justify-end", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
AlertDialogFooter.displayName = "AlertDialogFooter"
|
||||
|
||||
const AlertDialogTitle = DialogTitle
|
||||
|
||||
const AlertDialogDescription = DialogDescription
|
||||
|
||||
/**
|
||||
* The commit. Destructive by default: this component exists for the deletes,
|
||||
* and a confirmation whose commit button looks like every other button is a
|
||||
* confirmation nobody reads. Pass `variant` for the non-destructive cases.
|
||||
*/
|
||||
const AlertDialogAction = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant = "danger", type = "button", ...props }, ref) => (
|
||||
<DialogPrimitive.Close asChild>
|
||||
<Button
|
||||
ref={ref}
|
||||
type={type}
|
||||
variant={variant}
|
||||
className={cn("order-1 sm:order-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
</DialogPrimitive.Close>
|
||||
)
|
||||
)
|
||||
AlertDialogAction.displayName = "AlertDialogAction"
|
||||
|
||||
const AlertDialogCancel = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant = "outline", type = "button", ...props }, ref) => {
|
||||
const registry = React.useContext(AlertDialogCancelContext)
|
||||
const setRef = React.useCallback(
|
||||
(node: HTMLButtonElement | null) => {
|
||||
if (registry) registry.current = node
|
||||
if (typeof ref === "function") ref(node)
|
||||
else if (ref) ref.current = node
|
||||
},
|
||||
[ref, registry]
|
||||
)
|
||||
return (
|
||||
<DialogPrimitive.Close asChild>
|
||||
<Button
|
||||
ref={setRef}
|
||||
type={type}
|
||||
variant={variant}
|
||||
className={cn("order-2 sm:order-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
</DialogPrimitive.Close>
|
||||
)
|
||||
}
|
||||
)
|
||||
AlertDialogCancel.displayName = "AlertDialogCancel"
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
}
|
||||
@@ -12,6 +12,95 @@ const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
/**
|
||||
* Focus capture and restore, for every overlay in the product.
|
||||
*
|
||||
* Radix returns focus to whatever opened the overlay only when it can still
|
||||
* find it, and on several of PIG's surfaces it cannot: sheets opened
|
||||
* programmatically have no `SheetTrigger` at all, and the palette's opener is a
|
||||
* control the next route unmounts. Measured, Escape from nine overlays left
|
||||
* focus on `<body>` — on `/piggy` that is 121 Tab presses back to where the
|
||||
* person was, which is a keyboard trap wearing a dismissal.
|
||||
*
|
||||
* The pattern was written once at `CommandPalette.tsx` and is hoisted here so
|
||||
* every dialog, sheet, drawer and alert inherits it without a call-site change.
|
||||
*
|
||||
* The capture hangs off the content element's ref rather than the wrapper's
|
||||
* first render, and that distinction is the whole component. `SheetContent`
|
||||
* renders on every render of the page that declares it — Radix gates on open
|
||||
* *below* it, inside the portal — so reading `document.activeElement` while our
|
||||
* own function body runs reads it at page load, which is `<body>`, which is the
|
||||
* bug being fixed. A ref callback fires only when the content genuinely mounts,
|
||||
* and refs are attached earlier in the commit than the effect Radix's
|
||||
* FocusScope uses to move focus into the overlay.
|
||||
*/
|
||||
export function useOverlayFocusRestore<T extends HTMLElement>(
|
||||
forwardedRef: React.ForwardedRef<T>,
|
||||
onCloseAutoFocus?: (event: Event) => void,
|
||||
): {
|
||||
ref: (node: T | null) => void
|
||||
onCloseAutoFocus: (event: Event) => void
|
||||
} {
|
||||
const opener = React.useRef<HTMLElement | null>(null)
|
||||
const captured = React.useRef(false)
|
||||
const forwarded = React.useRef(forwardedRef)
|
||||
forwarded.current = forwardedRef
|
||||
|
||||
// Deliberately stable: a ref callback that changed identity would be called
|
||||
// with null and then the node again mid-open, and the second capture would
|
||||
// read a control inside the overlay as the opener.
|
||||
const ref = React.useCallback((node: T | null) => {
|
||||
if (node) {
|
||||
if (!captured.current) {
|
||||
captured.current = true
|
||||
const active = document.activeElement
|
||||
opener.current =
|
||||
active instanceof HTMLElement && active !== document.body ? active : null
|
||||
}
|
||||
} else {
|
||||
// Armed for the next open. `opener` itself survives, because the close
|
||||
// handler below runs in a passive effect cleanup — after React has
|
||||
// already detached this ref.
|
||||
captured.current = false
|
||||
}
|
||||
const target = forwarded.current
|
||||
if (typeof target === "function") target(node)
|
||||
else if (target) target.current = node
|
||||
}, [])
|
||||
|
||||
const handleCloseAutoFocus = React.useCallback(
|
||||
(event: Event) => {
|
||||
onCloseAutoFocus?.(event)
|
||||
// The call site wins. CommandPalette has its own restore for the case
|
||||
// where choosing an item navigates away from the control that opened it.
|
||||
if (event.defaultPrevented) return
|
||||
const target = opener.current
|
||||
// `isConnected` because closing may have navigated, leaving the opener
|
||||
// detached — focusing a node in no document does nothing.
|
||||
//
|
||||
// The fallback is the page's own content landmark rather than Radix's,
|
||||
// which lands on `<body>`. That case is real and now reachable: an
|
||||
// account's contract row navigates to `/contracts?contract=…`, which
|
||||
// opens the detail sheet on a page the opener never existed on, so
|
||||
// dismissing it dropped the keyboard back to the top of the document.
|
||||
// `#page-content` is the same target the skip link uses and already
|
||||
// carries `tabIndex={-1}` for exactly this.
|
||||
if (!target || !target.isConnected) {
|
||||
const content = document.getElementById('page-content')
|
||||
if (!content) return
|
||||
event.preventDefault()
|
||||
content.focus()
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
target.focus()
|
||||
},
|
||||
[onCloseAutoFocus],
|
||||
)
|
||||
|
||||
return { ref, onCloseAutoFocus: handleCloseAutoFocus }
|
||||
}
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
@@ -19,7 +108,7 @@ const DialogOverlay = React.forwardRef<
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:duration-3 data-[state=closed]:duration-3 data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=open]:ease-enter data-[state=closed]:ease-exit",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -30,25 +119,29 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-1 top-1 flex size-[44px] items-center justify-center rounded-md opacity-70 ring-offset-background transition-opacity hover:bg-accent hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
>(({ className, children, onCloseAutoFocus, ...props }, ref) => {
|
||||
const focus = useOverlayFocusRestore(ref, onCloseAutoFocus)
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={focus.ref}
|
||||
onCloseAutoFocus={focus.onCloseAutoFocus}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-[calc(100%-2rem)] max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 rounded-2xl border border-border bg-surface p-5 shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:duration-3 data-[state=closed]:duration-3 data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] data-[state=open]:ease-enter data-[state=closed]:ease-exit",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-2 top-2 z-10 flex size-11 items-center justify-center rounded-lg text-muted opacity-70 transition-colors duration-1 hover:bg-surface-2 hover:opacity-100 disabled:pointer-events-none">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
})
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({
|
||||
@@ -57,7 +150,9 @@ const DialogHeader = ({
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
// Left-aligned at every width. The shadcn default centres below `sm`,
|
||||
// so the same dialog read as a different component on a phone.
|
||||
"flex flex-col gap-1 text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -71,7 +166,7 @@ const DialogFooter = ({
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -86,7 +181,9 @@ const DialogTitle = React.forwardRef<
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
// The section-heading role: 16px/600. An overlay title is not a page
|
||||
// title, and overlay chrome does not vary by feature.
|
||||
"text-base font-semibold leading-tight tracking-tight text-fg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -100,7 +197,7 @@ const DialogDescription = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
className={cn("text-sm text-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useRef, useState, type ComponentPropsWithoutRef, type ReactNode } from 'react';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* A fold, built on `<details>` so the browser gives it keyboard and
|
||||
* screen-reader semantics for free.
|
||||
*
|
||||
* Four of these existed — reasoning, tool steps, a rejected proposal, an
|
||||
* activity group — and each had independently rediscovered the same two
|
||||
* browser facts:
|
||||
*
|
||||
* - `list-style: none` removes Chrome's marker but not Safari's, which draws
|
||||
* its own from `::-webkit-details-marker`. Without both rules one browser
|
||||
* shows two triangles.
|
||||
* - A flex `<summary>` drops the native marker in Chrome and keeps it in
|
||||
* Firefox, so the chevron has to be an element we render ourselves.
|
||||
*
|
||||
* `onExpand` receives the element at the one moment worth having: inside the
|
||||
* click handler, where `open` still holds its pre-click value. That is how a
|
||||
* transcript pinned to its newest message can tell an expansion from a
|
||||
* collapse and scroll the revealed content back into view — an expansion adds
|
||||
* height below the fold, and a follow-the-tail scroller reads that as new
|
||||
* content and jumps past the very thing the user asked to see.
|
||||
*/
|
||||
export function Disclosure({
|
||||
summary,
|
||||
children,
|
||||
onExpand,
|
||||
defaultOpen = false,
|
||||
open,
|
||||
onOpenChange,
|
||||
className,
|
||||
summaryClassName,
|
||||
contentClassName,
|
||||
...props
|
||||
}: Omit<ComponentPropsWithoutRef<'details'>, 'onToggle' | 'open' | 'children' | 'className'> & {
|
||||
summary: ReactNode;
|
||||
children: ReactNode;
|
||||
/** Called with the `<details>` element as it is about to open, never as it closes. */
|
||||
onExpand?: (element: HTMLDetailsElement) => void;
|
||||
defaultOpen?: boolean;
|
||||
/** Supply with `onOpenChange` to drive the fold from outside. */
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
className?: string;
|
||||
summaryClassName?: string;
|
||||
contentClassName?: string;
|
||||
}) {
|
||||
const ref = useRef<HTMLDetailsElement>(null);
|
||||
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
|
||||
const isOpen = open ?? uncontrolledOpen;
|
||||
|
||||
return (
|
||||
<details
|
||||
ref={ref}
|
||||
open={isOpen}
|
||||
onToggle={(event) => {
|
||||
const next = event.currentTarget.open;
|
||||
if (open === undefined) setUncontrolledOpen(next);
|
||||
onOpenChange?.(next);
|
||||
}}
|
||||
className={cn('group min-w-0', className)}
|
||||
{...props}
|
||||
>
|
||||
<summary
|
||||
onClick={() => {
|
||||
const element = ref.current;
|
||||
// `open` is still the pre-click value here, so `false` means the
|
||||
// click is about to open it.
|
||||
if (element && !element.open) onExpand?.(element);
|
||||
}}
|
||||
className={cn(
|
||||
'flex min-h-11 min-w-0 cursor-pointer list-none items-center gap-2 py-2',
|
||||
'text-sm font-medium transition-colors duration-1 ease-enter hover:text-fg',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand',
|
||||
'focus-visible:ring-offset-2 focus-visible:ring-offset-bg',
|
||||
'[&::-webkit-details-marker]:hidden',
|
||||
summaryClassName,
|
||||
)}
|
||||
>
|
||||
<ChevronRight
|
||||
className="size-4 shrink-0 text-muted transition-transform duration-1 ease-enter group-open:rotate-90"
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="min-w-0 flex-1">{summary}</span>
|
||||
</summary>
|
||||
<div className={cn('min-w-0', contentClassName)}>{children}</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import * as React from "react"
|
||||
import { Drawer as DrawerPrimitive } from "vaul"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useOverlayFocusRestore } from "@/components/ui/dialog"
|
||||
|
||||
const Drawer = ({
|
||||
shouldScaleBackground = true,
|
||||
@@ -35,22 +36,39 @@ DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName
|
||||
const DrawerContent = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DrawerPortal>
|
||||
<DrawerOverlay />
|
||||
<DrawerPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
|
||||
{children}
|
||||
</DrawerPrimitive.Content>
|
||||
</DrawerPortal>
|
||||
))
|
||||
>(({ className, children, onCloseAutoFocus, ...props }, ref) => {
|
||||
const focus = useOverlayFocusRestore(ref, onCloseAutoFocus)
|
||||
return (
|
||||
<DrawerPortal>
|
||||
<DrawerOverlay />
|
||||
<DrawerPrimitive.Content
|
||||
ref={focus.ref}
|
||||
onCloseAutoFocus={focus.onCloseAutoFocus}
|
||||
className={cn(
|
||||
// The bottom inset is the drawer's own: it is anchored to the edge of
|
||||
// the screen the home indicator sits on, and the drawer's last child
|
||||
// is a composer. Measured at 393×852, its Send button finished 13px
|
||||
// off-screen.
|
||||
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-2xl border border-border bg-surface pb-[var(--safe-bottom)]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{/*
|
||||
A plain element, not `DrawerPrimitive.Handle`: vaul drags from
|
||||
anywhere in the content that is not marked no-drag, and the handle
|
||||
primitive brings its own hit area and sizing. This one is the grip
|
||||
mark only.
|
||||
*/}
|
||||
<div
|
||||
aria-hidden
|
||||
className="mx-auto mt-4 h-2 w-[100px] shrink-0 rounded-full bg-border"
|
||||
/>
|
||||
{children}
|
||||
</DrawerPrimitive.Content>
|
||||
</DrawerPortal>
|
||||
)
|
||||
})
|
||||
DrawerContent.displayName = "DrawerContent"
|
||||
|
||||
const DrawerHeader = ({
|
||||
@@ -58,7 +76,7 @@ const DrawerHeader = ({
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
|
||||
className={cn("flex min-w-0 flex-col gap-1 px-5 py-4 text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@@ -69,7 +87,7 @@ const DrawerFooter = ({
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
className={cn("mt-auto flex flex-col gap-2 px-5 py-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@@ -81,10 +99,7 @@ const DrawerTitle = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DrawerPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
className={cn("text-base font-semibold leading-tight text-fg", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
@@ -96,7 +111,7 @@ const DrawerDescription = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DrawerPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
className={cn("text-sm text-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { cloneElement, isValidElement, useId, type ReactNode } from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Label } from '@/components/ui';
|
||||
|
||||
type ControlProps = { id?: string; 'aria-label'?: string; 'aria-describedby'?: string };
|
||||
|
||||
/**
|
||||
* A labelled control.
|
||||
*
|
||||
* The id is cloned onto the *control*, never onto a wrapper: a `<label for>`
|
||||
* pointing at a `<div>` associates with nothing, and the screen reader then
|
||||
* reads an unlabelled input. Four copies of this wrapper existed, two of them
|
||||
* byte-identical, and all four carried a subtler version of the same bug —
|
||||
* they gave the control `children.props.id ?? id` while pointing the label at
|
||||
* `id` unconditionally, so any control that already had an id of its own ended
|
||||
* up with a label addressing an element that did not exist.
|
||||
*
|
||||
* The hint is wired through `aria-describedby` rather than left as loose text
|
||||
* beneath, because a hint that only sighted users receive is not a hint, it is
|
||||
* decoration.
|
||||
*/
|
||||
export function FormField({
|
||||
label,
|
||||
hint,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
label: string;
|
||||
hint?: ReactNode;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
const generated = useId();
|
||||
const hintId = `${generated}-hint`;
|
||||
|
||||
const element = isValidElement<ControlProps>(children) ? children : null;
|
||||
const controlId = element?.props.id ?? generated;
|
||||
|
||||
const control = element
|
||||
? cloneElement(element, {
|
||||
id: controlId,
|
||||
// The visible label is the accessible name via `for`/`id`; the
|
||||
// `aria-label` is a belt-and-braces fallback for controls that render
|
||||
// a button rather than a form element (Radix Select, for one) where
|
||||
// `for` does not always carry.
|
||||
'aria-label': element.props['aria-label'] ?? label,
|
||||
'aria-describedby':
|
||||
hint == null ? element.props['aria-describedby'] : (element.props['aria-describedby'] ?? hintId),
|
||||
})
|
||||
: children;
|
||||
|
||||
return (
|
||||
<div className={cn('flex min-w-0 flex-col gap-1.5', className)}>
|
||||
<Label as="label" htmlFor={controlId}>
|
||||
{label}
|
||||
</Label>
|
||||
{control}
|
||||
{hint == null ? null : (
|
||||
<p id={hintId} className="min-w-0 text-xs text-muted">
|
||||
{hint}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,11 +11,15 @@ import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
forwardRef,
|
||||
useState,
|
||||
type ButtonHTMLAttributes,
|
||||
type HTMLAttributes,
|
||||
type InputHTMLAttributes,
|
||||
type LabelHTMLAttributes,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
|
||||
@@ -42,8 +46,17 @@ export function cn(...inputs: ClassValue[]): string {
|
||||
*/
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center gap-2 rounded-lg text-sm font-medium ' +
|
||||
'transition-colors disabled:pointer-events-none disabled:opacity-50 ' +
|
||||
'transition-colors duration-1 ease-enter disabled:pointer-events-none disabled:opacity-50 ' +
|
||||
'[&_svg]:shrink-0 ' +
|
||||
/*
|
||||
* The ring is declared here as well as in the global `:focus-visible` rule.
|
||||
* A button that also carries a local `focus-visible:ring-*` class beat the
|
||||
* base rule on specificity and painted the near-invisible subtle accent;
|
||||
* declaring it in the cva puts it in the same cascade layer as those
|
||||
* overrides, so `cn()` merging resolves it rather than the stylesheet.
|
||||
*/
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand ' +
|
||||
'focus-visible:ring-offset-2 focus-visible:ring-offset-bg ' +
|
||||
// touch-manipulation removes the 300ms tap delay that older mobile Safari
|
||||
// applies while waiting to see whether a tap is a double-tap zoom.
|
||||
'touch-manipulation select-none whitespace-nowrap',
|
||||
@@ -179,28 +192,191 @@ export function Badge({
|
||||
return <span className={cn(badgeVariants({ tone }), className)} {...props} />;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------- stat
|
||||
// -------------------------------------------------------------- micro label
|
||||
|
||||
/**
|
||||
* A single headline number.
|
||||
* The one small-caps label in the product.
|
||||
*
|
||||
* There were five of these — 10px, 11px and 12px, `tracking-wide`,
|
||||
* `tracking-wider` and `tracking-[0.12em]`, `font-medium` and `font-semibold`
|
||||
* — across 58 hand-written call sites, which is why no two panels' labels
|
||||
* lined up. 11px at 0.06em was the majority reading and the one that survives
|
||||
* the sidebar rail's width.
|
||||
*
|
||||
* `as` exists because the same label is a `dt` in a definition list, a `th` in
|
||||
* a table head and a `span` inside a flex row; rendering all three as a `div`
|
||||
* is how a table stops being a table for a screen reader.
|
||||
*/
|
||||
const MICRO_LABEL =
|
||||
'text-[11px] font-medium uppercase leading-tight tracking-[0.06em] text-muted';
|
||||
|
||||
export function Label({
|
||||
as: Component = 'div',
|
||||
className,
|
||||
...props
|
||||
}: LabelHTMLAttributes<HTMLElement> & {
|
||||
as?: 'div' | 'span' | 'p' | 'dt' | 'th' | 'legend' | 'label' | 'h2' | 'h3' | 'h4';
|
||||
}) {
|
||||
return (
|
||||
<Component className={cn(MICRO_LABEL, className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ section
|
||||
|
||||
/**
|
||||
* A titled group of content: the panel heading, its count, its action, and
|
||||
* optionally a chevron that folds it away.
|
||||
*
|
||||
* Six of these existed at 11px-uppercase through 16px-sentence-case, so a page
|
||||
* built from three of them read as three products. `tone` is the only choice
|
||||
* left: `panel` owns a card, `micro` names a group inside one.
|
||||
*/
|
||||
export function Section({
|
||||
title,
|
||||
description,
|
||||
count,
|
||||
action,
|
||||
level = 3,
|
||||
tone = 'panel',
|
||||
collapsible = false,
|
||||
defaultOpen = true,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
title: ReactNode;
|
||||
description?: ReactNode;
|
||||
/** Rendered beside the title as a tabular figure. `0` still renders. */
|
||||
count?: number;
|
||||
action?: ReactNode;
|
||||
level?: 2 | 3 | 4;
|
||||
tone?: 'panel' | 'micro';
|
||||
collapsible?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
className?: string;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const Heading = ({ 2: 'h2', 3: 'h3', 4: 'h4' } as const)[level];
|
||||
|
||||
const titleClass =
|
||||
tone === 'panel' ? 'text-base font-semibold leading-tight text-fg' : MICRO_LABEL;
|
||||
|
||||
const heading = (
|
||||
<>
|
||||
<span className={cn('min-w-0 break-words', titleClass)}>{title}</span>
|
||||
{count == null ? null : <span className="nums shrink-0 text-sm text-muted">{count}</span>}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<section className={cn('min-w-0', className)}>
|
||||
<div className={cn('flex min-w-0 items-start gap-2', collapsible ? '' : 'py-0.5')}>
|
||||
<Heading className="min-w-0 flex-1">
|
||||
{collapsible ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((was) => !was)}
|
||||
aria-expanded={open}
|
||||
className="flex min-h-11 w-full items-center gap-2 rounded-lg text-left transition-colors duration-1 ease-enter hover:text-fg"
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'size-4 shrink-0 text-muted transition-transform duration-1 ease-enter',
|
||||
open && 'rotate-90',
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
{heading}
|
||||
</button>
|
||||
) : (
|
||||
<span className="flex min-w-0 items-baseline gap-2">{heading}</span>
|
||||
)}
|
||||
</Heading>
|
||||
{action ? <div className="shrink-0">{action}</div> : null}
|
||||
</div>
|
||||
{description ? (
|
||||
<p className={cn('mt-1 min-w-0 text-sm text-muted', collapsible && 'pl-6')}>{description}</p>
|
||||
) : null}
|
||||
{children != null && (!collapsible || open) ? (
|
||||
<div className={cn('mt-2 min-w-0', tone === 'panel' && !collapsible && 'mt-3')}>
|
||||
{children}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------- stat
|
||||
|
||||
const statValueSizes = {
|
||||
/** Inline tile inside a panel. */
|
||||
sm: 'text-sm',
|
||||
/** A figure a panel is about. */
|
||||
md: 'text-lg',
|
||||
/** The page's headline number. */
|
||||
lg: 'text-2xl sm:text-3xl',
|
||||
} as const;
|
||||
|
||||
const statSurfaces = {
|
||||
/** The default, and the only one that existed: a card of its own. */
|
||||
card: 'card p-4',
|
||||
/** A tile on `bg-surface-2` inside a card. No border, no shadow. */
|
||||
inset: 'rounded-md bg-surface-2 p-2.5',
|
||||
/** No surface at all — the caller owns the container. */
|
||||
bare: '',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* A single labelled number.
|
||||
*
|
||||
* `nums` applies tabular figures so a value does not jitter horizontally as it
|
||||
* updates — which it does, on a dashboard that refreshes while someone is
|
||||
* looking at it.
|
||||
*
|
||||
* `min-w-0 break-words` is on the value, not left to the caller: this tile is
|
||||
* always a grid child, a grid child refuses to shrink below its content, and
|
||||
* the content is an unbreakable currency string. The measured failure was
|
||||
* `GROSS MARGIN $658,194.3` — a digit short — on any phone under 400px, and
|
||||
* the same figure colliding with the next card at 1440 with the Piggy dock
|
||||
* open. A truncated financial figure is worse than no figure.
|
||||
*
|
||||
* That stopped the clipping and traded it for a second failure nobody measured,
|
||||
* because `scrollWidth === clientWidth` is true of a number that has WRAPPED:
|
||||
* `$658,194.` on one line and `37` on the next, at every width under about
|
||||
* 220px — a phone, and 1440 with the dock open. So the tile is now a container
|
||||
* query context (`stat-tile`) and the `lg` figure carries `stat-figure-lg`,
|
||||
* which steps 30 → 24 → 22 → 20 → 18 → 16px as the tile narrows. See the
|
||||
* measured ladder in `index.css`. Every tile in a grid row is the same width,
|
||||
* so a row steps together; `break-words` stays as the last-resort net.
|
||||
*
|
||||
* An absent value renders a muted em dash rather than the tone colour: "—" in
|
||||
* danger red reads as a number that went wrong rather than one nobody has.
|
||||
*/
|
||||
export function Stat({
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
tone,
|
||||
size = 'lg',
|
||||
surface = 'card',
|
||||
href,
|
||||
className,
|
||||
}: {
|
||||
label: string;
|
||||
value: ReactNode;
|
||||
hint?: ReactNode;
|
||||
tone?: 'positive' | 'warning' | 'danger' | 'default';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
surface?: 'card' | 'inset' | 'bare';
|
||||
/** Makes the whole tile a router link to the page that explains the figure. */
|
||||
href?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const toneClass =
|
||||
tone === 'positive'
|
||||
const absent = value == null || value === '' || value === '—' || value === '-';
|
||||
const toneClass = absent
|
||||
? 'text-muted'
|
||||
: tone === 'positive'
|
||||
? 'text-positive'
|
||||
: tone === 'warning'
|
||||
? 'text-warning'
|
||||
@@ -208,14 +384,52 @@ export function Stat({
|
||||
? 'text-danger'
|
||||
: 'text-fg';
|
||||
|
||||
return (
|
||||
<div className="card p-4">
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-muted">{label}</div>
|
||||
<div className={cn('nums mt-1 text-2xl font-semibold leading-tight sm:text-3xl', toneClass)}>
|
||||
{value}
|
||||
const body = (
|
||||
<>
|
||||
<Label>{label}</Label>
|
||||
<div
|
||||
className={cn(
|
||||
'nums min-w-0 break-words font-semibold leading-tight',
|
||||
size === 'sm' ? 'mt-0.5' : 'mt-1',
|
||||
statValueSizes[size],
|
||||
// Only the page KPI step is container-scaled; `sm` and `md` are
|
||||
// already small enough that no figure in the product wraps them.
|
||||
size === 'lg' && 'stat-figure-lg',
|
||||
toneClass,
|
||||
)}
|
||||
>
|
||||
{absent ? '—' : value}
|
||||
</div>
|
||||
{hint ? <div className="mt-1 text-xs text-muted">{hint}</div> : null}
|
||||
</div>
|
||||
{hint ? <div className="mt-1 min-w-0 text-xs text-muted">{hint}</div> : null}
|
||||
</>
|
||||
);
|
||||
|
||||
const shell = cn(
|
||||
'block min-w-0',
|
||||
/*
|
||||
* The container context is declared ONLY on the page-KPI step, and that is
|
||||
* a measured constraint rather than tidiness. `container-type: inline-size`
|
||||
* carries `contain: layout style inline-size`, which suppresses a box's
|
||||
* content-based intrinsic contribution — so putting it on every `Stat`
|
||||
* changed how the `sm` and `md` tiles inside Growth's account cards
|
||||
* negotiated width with the flex rows around them, and /growth began
|
||||
* overflowing its viewport by 32px at 393 and 12px at 1440. Zero horizontal
|
||||
* overflow is the product's oldest measured guarantee; a nicer number is
|
||||
* not worth spending it. The `lg` tiles are grid children with declared
|
||||
* tracks, where the contribution is not what decides the width.
|
||||
*/
|
||||
size === 'lg' && 'stat-tile',
|
||||
statSurfaces[surface],
|
||||
href && 'transition-colors duration-1 ease-enter hover:bg-surface-2',
|
||||
className,
|
||||
);
|
||||
|
||||
return href ? (
|
||||
<Link to={href} className={shell}>
|
||||
{body}
|
||||
</Link>
|
||||
) : (
|
||||
<div className={shell}>{body}</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -227,24 +441,59 @@ export function Skeleton({ className }: { className?: string }) {
|
||||
|
||||
// --------------------------------------------------------------- empty state
|
||||
|
||||
/**
|
||||
* Nothing here, said once.
|
||||
*
|
||||
* Seven bespoke empty states stood beside this one, differing only in how much
|
||||
* vertical room they took: a panel's worth of padding inside a 120px list row
|
||||
* pushes the thing below it off the screen. `size` is that decision and the
|
||||
* only one — `panel` is the historic rendering and stays the default.
|
||||
*/
|
||||
export function EmptyState({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
size = 'panel',
|
||||
className,
|
||||
}: {
|
||||
icon?: ReactNode;
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: ReactNode;
|
||||
/** `inline` inside a list or a card body, `panel` for a card, `page` for a route. */
|
||||
size?: 'inline' | 'panel' | 'page';
|
||||
className?: string;
|
||||
}) {
|
||||
const box =
|
||||
size === 'inline' ? 'gap-2 px-4 py-6' : size === 'page' ? 'gap-4 px-6 py-20' : 'gap-3 px-6 py-12';
|
||||
const titleClass =
|
||||
size === 'inline'
|
||||
? 'text-sm font-medium'
|
||||
: size === 'page'
|
||||
? 'text-base font-semibold'
|
||||
: 'font-medium';
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-3 px-6 py-12 text-center">
|
||||
<div
|
||||
className={cn(
|
||||
'flex min-w-0 flex-col items-center justify-center text-center',
|
||||
box,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{icon ? <div className="text-muted">{icon}</div> : null}
|
||||
<div>
|
||||
<p className="font-medium">{title}</p>
|
||||
<div className="min-w-0">
|
||||
<p className={cn('break-words', titleClass)}>{title}</p>
|
||||
{description ? (
|
||||
<p className="mx-auto mt-1 max-w-sm text-sm text-muted">{description}</p>
|
||||
<p
|
||||
className={cn(
|
||||
'mx-auto mt-1 max-w-sm break-words text-muted',
|
||||
size === 'inline' ? 'text-xs' : 'text-sm',
|
||||
)}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{action}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* The one route heading.
|
||||
*
|
||||
* Thirteen pages hand-rolled this block and drifted: the `<h1>` was 20px on
|
||||
* most, 30px on Growth and Learn, five pages carried two `<h1>`s, and the gap
|
||||
* to the first section below ran 16 / 20 / 24 / 40px depending on the file. A
|
||||
* document has one title, and a product has one title size.
|
||||
*
|
||||
* `ask` is a separate slot from `actions` deliberately. "Ask Piggy" is not a
|
||||
* page action — it is the same agent on every page, and it sits in the same
|
||||
* place on every page so a reader stops hunting for it among the buttons that
|
||||
* do differ.
|
||||
*/
|
||||
export function PageHeader({
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
ask,
|
||||
className,
|
||||
}: {
|
||||
title: ReactNode;
|
||||
description?: ReactNode;
|
||||
/** Page-specific controls, right-aligned at `sm` and up. */
|
||||
actions?: ReactNode;
|
||||
/** The `PiggyAskButton` for this page, kept in one consistent slot. */
|
||||
ask?: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
'flex min-w-0 flex-col gap-3 sm:flex-row sm:items-start sm:justify-between',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<h1 className="min-w-0 break-words text-xl font-semibold tracking-tight sm:text-2xl">
|
||||
{title}
|
||||
</h1>
|
||||
{description ? (
|
||||
<p className="mt-1 min-w-0 max-w-2xl text-sm text-muted">{description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
{actions || ask ? (
|
||||
// `flex-wrap` rather than `whitespace-nowrap`: at 393px a two-button
|
||||
// row plus the ask button overflows, and a page header is the last
|
||||
// place that should be the thing which introduces horizontal scroll.
|
||||
<div className="flex min-w-0 shrink-0 flex-wrap items-center gap-2 sm:justify-end">
|
||||
{actions}
|
||||
{ask}
|
||||
</div>
|
||||
) : null}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,17 @@ const SelectGroup = SelectPrimitive.Group
|
||||
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
|
||||
/**
|
||||
* The trigger is 44px, matching `Input` and `Button`, because it is the same
|
||||
* kind of thing a thumb aims at. Stock shadcn ships it at 36px, which is where
|
||||
* Settings' timezone select and every filter built on this primitive were
|
||||
* failing PIG's own touch rule — not because a call site chose 36px, but
|
||||
* because nobody had chosen anything.
|
||||
*
|
||||
* The rest of the treatment is `Input`'s exactly: 12px radius because it is a
|
||||
* control, `bg-surface` because a transparent field has no edge on a tinted
|
||||
* canvas, and focus read as the border changing rather than an extra outline.
|
||||
*/
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
@@ -19,7 +30,9 @@ const SelectTrigger = React.forwardRef<
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
"flex h-11 min-h-[44px] w-full items-center justify-between whitespace-nowrap rounded-lg border border-border bg-surface px-3 text-fg",
|
||||
"transition-colors duration-1 ease-enter data-[placeholder]:text-muted focus-visible:border-accent",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -75,7 +88,7 @@ const SelectContent = React.forwardRef<
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
|
||||
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-xl border border-border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
@@ -105,7 +118,12 @@ const SelectLabel = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
|
||||
// A group name, so it takes the product's one micro-label treatment
|
||||
// rather than looking like a selectable option set in bold.
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-[11px] font-medium uppercase tracking-[0.06em] text-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
@@ -118,7 +136,9 @@ const SelectItem = React.forwardRef<
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
// An option is a target too: a 30px row in an open listbox is as hard to
|
||||
// hit as a 30px button, and this menu is how a phone changes a filter.
|
||||
"relative flex min-h-11 w-full cursor-default select-none items-center rounded-md py-2 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -139,7 +159,9 @@ const SelectSeparator = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
// `bg-muted` here painted the muted TEXT colour: a near-black hairline in
|
||||
// light theme, because shadcn's `muted` is a surface and PIG's is not.
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
@@ -6,6 +6,7 @@ import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useOverlayFocusRestore } from "@/components/ui/dialog"
|
||||
|
||||
const Sheet = SheetPrimitive.Root
|
||||
|
||||
@@ -21,7 +22,7 @@ const SheetOverlay = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:duration-3 data-[state=closed]:duration-3 data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=open]:ease-enter data-[state=closed]:ease-exit",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -30,17 +31,23 @@ const SheetOverlay = React.forwardRef<
|
||||
))
|
||||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
|
||||
|
||||
/*
|
||||
* The top inset is written as `max(padding, --safe-top)` rather than the bare
|
||||
* inset, matching how the app header and the Piggy sheet already do it: a bare
|
||||
* `pt-[var(--safe-top)]` wins the cascade over the sheet's own padding and
|
||||
* collapses the top inset to zero on every device without a notch.
|
||||
*/
|
||||
const sheetVariants = cva(
|
||||
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||
"fixed z-50 gap-4 bg-surface p-5 shadow-lg transition data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:duration-3 data-[state=closed]:duration-3 data-[state=open]:ease-enter data-[state=closed]:ease-exit",
|
||||
{
|
||||
variants: {
|
||||
side: {
|
||||
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
|
||||
top: "inset-x-0 top-0 border-b pt-[max(1.25rem,var(--safe-top))] data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
|
||||
bottom:
|
||||
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
||||
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
|
||||
"inset-x-0 bottom-0 border-t pb-[max(1.25rem,var(--safe-bottom))] data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
||||
left: "inset-y-0 left-0 h-full w-3/4 border-r pt-[max(1.25rem,var(--safe-top))] data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
|
||||
right:
|
||||
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
|
||||
"inset-y-0 right-0 h-full w-3/4 border-l pt-[max(1.25rem,var(--safe-top))] data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
@@ -56,31 +63,61 @@ interface SheetContentProps
|
||||
const SheetContent = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Content>,
|
||||
SheetContentProps
|
||||
>(({ side = "right", className, children, ...props }, ref) => (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(sheetVariants({ side }), className)}
|
||||
{...props}
|
||||
>
|
||||
<SheetPrimitive.Close className="absolute right-2 top-2 flex h-11 w-11 items-center justify-center rounded-md opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
{children}
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
))
|
||||
>(({ side = "right", className, children, onCloseAutoFocus, ...props }, ref) => {
|
||||
const focus = useOverlayFocusRestore(ref, onCloseAutoFocus)
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
ref={focus.ref}
|
||||
onCloseAutoFocus={focus.onCloseAutoFocus}
|
||||
className={cn(sheetVariants({ side }), className)}
|
||||
{...props}
|
||||
>
|
||||
<SheetPrimitive.Close
|
||||
className={cn(
|
||||
// `z-10` because a sheet's body is usually a full-bleed rail the
|
||||
// call site paints itself, and a dismiss control underneath the
|
||||
// content is a sheet with no way out on a phone.
|
||||
"absolute right-2 z-10 flex size-11 items-center justify-center rounded-lg text-muted opacity-70 transition-colors duration-1 hover:bg-surface-2 hover:opacity-100 disabled:pointer-events-none",
|
||||
// Most sheets are opened with `p-0` and lay out their own header,
|
||||
// which means the content's safe-area padding is overridden at the
|
||||
// call site. The dismiss control is the primitive's own, so it
|
||||
// carries the inset itself — otherwise it opens under the notch,
|
||||
// which is where it was measured.
|
||||
side === "bottom" ? "top-2" : "top-[max(0.5rem,var(--safe-top))]"
|
||||
)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
{children}
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
)
|
||||
})
|
||||
SheetContent.displayName = SheetPrimitive.Content.displayName
|
||||
|
||||
const SheetHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
interface SheetHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
/**
|
||||
* Render the header as the sheet's fixed top band: the standard overlay
|
||||
* header padding, a rule against the body, and the top safe-area inset.
|
||||
*
|
||||
* Opt-in because the two families of sheet want opposite things. A sheet
|
||||
* that keeps the content padding already has its inset and would get it
|
||||
* twice; a sheet opened with `p-0` — which is most of them — owns its own
|
||||
* bands and was hand-rolling these four classes each time, at four slightly
|
||||
* different values.
|
||||
*/
|
||||
band?: boolean
|
||||
}
|
||||
|
||||
const SheetHeader = ({ className, band = false, ...props }: SheetHeaderProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-2 text-center sm:text-left",
|
||||
"flex min-w-0 flex-col gap-1 text-left",
|
||||
band &&
|
||||
"shrink-0 border-b border-border px-5 py-4 pt-[max(1rem,var(--safe-top))]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -94,7 +131,7 @@ const SheetFooter = ({
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -108,7 +145,9 @@ const SheetTitle = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold text-foreground", className)}
|
||||
// 16px/600, the section-heading role, everywhere. Overlay chrome that
|
||||
// varies by feature is how one product ends up looking like three.
|
||||
className={cn("text-base font-semibold leading-tight text-fg", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
@@ -120,7 +159,7 @@ const SheetDescription = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
className={cn("text-sm text-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
@@ -367,7 +367,13 @@ export const SidebarGroupLabel = React.forwardRef<
|
||||
ref={ref}
|
||||
data-sidebar="group-label"
|
||||
className={cn(
|
||||
'flex h-8 shrink-0 items-center rounded-md px-3 text-[10px] font-semibold uppercase tracking-[0.16em] text-muted/80',
|
||||
// The Label spec, not a sixth micro-label variant. This heading is the
|
||||
// most-repeated small-caps text in the product — four of them on all 16
|
||||
// routes — and it was the last one off the scale: 10px/600/0.16em in
|
||||
// `text-muted/80`, which measured 3.28:1 in light theme. `text-muted` at
|
||||
// 11px/500/0.06em is the one definition everything else already uses,
|
||||
// and it measures 4.83:1.
|
||||
'flex h-8 shrink-0 items-center rounded-md px-3 text-[11px] font-medium uppercase tracking-[0.06em] text-muted',
|
||||
'transition-[margin,opacity] duration-200 ease-linear',
|
||||
// Pulled up rather than hidden, so the icons above and below do not
|
||||
// jump as the label fades out.
|
||||
|
||||
@@ -1,3 +1,19 @@
|
||||
/**
|
||||
* The table primitive, on PIG's palette.
|
||||
*
|
||||
* This file arrived verbatim from shadcn and stayed that way through the whole
|
||||
* design pass, which mattered more here than anywhere else: `tailwind.config.js`
|
||||
* deliberately aliases `muted` onto `--muted`, a TEXT grey, so shadcn's
|
||||
* `hover:bg-muted/50` painted a 50%-opacity mid-grey slab across a hovered row.
|
||||
* Measured, the row's own text on that slab was 2.42:1 in light and 2.58:1 in
|
||||
* dark — on `/accounts` and `/contracts`, the two pages a GTM lead lives in,
|
||||
* and under the row-action icons that only reveal on hover. `surface-2` is the
|
||||
* product's own "this row is under the pointer" plane and reads at full text
|
||||
* contrast.
|
||||
*
|
||||
* `TableHead` also matches the 44px floor the sort buttons inside it already
|
||||
* carry: at `h-10` the header cell was shorter than its own control.
|
||||
*/
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
@@ -20,7 +36,7 @@ const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||
<thead ref={ref} className={cn("[&_tr]:border-b [&_tr]:border-border [&_tr:hover]:bg-transparent", className)} {...props} />
|
||||
))
|
||||
TableHeader.displayName = "TableHeader"
|
||||
|
||||
@@ -43,7 +59,7 @@ const TableFooter = React.forwardRef<
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
"border-t bg-surface-2 font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -58,7 +74,7 @@ const TableRow = React.forwardRef<
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
"border-b border-border transition-colors duration-1 ease-enter hover:bg-surface-2 data-[state=selected]:bg-surface-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -73,7 +89,7 @@ const TableHead = React.forwardRef<
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
"h-11 px-2 text-left align-middle font-medium text-muted [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -102,7 +118,7 @@ const TableCaption = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<caption
|
||||
ref={ref}
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
className={cn("mt-4 text-sm text-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
@@ -1,9 +1,26 @@
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
import * as React from 'react';
|
||||
import * as TabsPrimitive from '@radix-ui/react-tabs';
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Tabs = TabsPrimitive.Root
|
||||
/**
|
||||
* Radix tabs wearing PIG's palette and PIG's touch floor.
|
||||
*
|
||||
* Two things were wrong with the stock copy, and both were wrong at every call
|
||||
* site rather than at any one of them.
|
||||
*
|
||||
* First the palette: shadcn's `bg-muted` is a surface token. In PIG `muted` is
|
||||
* the muted TEXT colour, so an unstyled `TabsList` painted a mid-grey slab
|
||||
* with unreadable labels on it. Every call site had independently written the
|
||||
* same three overrides — `border border-border bg-surface`,
|
||||
* `data-[state=active]:bg-surface-2`, `text-muted` — which is the signal that
|
||||
* they belong here.
|
||||
*
|
||||
* Second the height: a 36px list holding 28px triggers cannot contain a 44px
|
||||
* touch target, and the rail's tabs measured 36px on a phone. The floor is now
|
||||
* in the primitive, the same way `buttonVariants` carries it.
|
||||
*/
|
||||
const Tabs = TabsPrimitive.Root;
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
@@ -12,13 +29,15 @@ const TabsList = React.forwardRef<
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
|
||||
className
|
||||
// 52px, not 44px: the list pads its triggers by 4px on each side, so a
|
||||
// 44px list would squeeze a 44px trigger down to 36px.
|
||||
'inline-flex min-h-[52px] items-center justify-center rounded-lg border border-border bg-surface p-1 text-muted',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsList.displayName = TabsPrimitive.List.displayName
|
||||
));
|
||||
TabsList.displayName = TabsPrimitive.List.displayName;
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
@@ -27,13 +46,21 @@ const TabsTrigger = React.forwardRef<
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
|
||||
className
|
||||
// `min-w-11` beside `min-h-11`: the floor is a square, and the repair
|
||||
// that added the height left the width alone — so the Accounts facet
|
||||
// control's "All" tab measured 40x44 at every viewport. A short label is
|
||||
// exactly the case a minimum exists for.
|
||||
'inline-flex min-h-11 min-w-11 items-center justify-center whitespace-nowrap rounded-md px-3 py-2',
|
||||
'text-sm font-medium text-muted transition-colors duration-1 ease-enter',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg',
|
||||
'disabled:pointer-events-none disabled:opacity-50',
|
||||
'data-[state=active]:bg-surface-2 data-[state=active]:text-fg',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||
));
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
@@ -42,12 +69,12 @@ const TabsContent = React.forwardRef<
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className
|
||||
'mt-3 min-w-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||
));
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||
|
||||
@@ -1,22 +1,43 @@
|
||||
import * as React from "react"
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Textarea = React.forwardRef<
|
||||
HTMLTextAreaElement,
|
||||
React.ComponentProps<"textarea">
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
Textarea.displayName = "Textarea"
|
||||
/**
|
||||
* The composer field, and the only multi-line input in the product.
|
||||
*
|
||||
* This was the last un-adapted shadcn primitive: a 6px radius, a transparent
|
||||
* fill, a `shadow-sm` nothing else in PIG carries, and a 1px `ring-ring` focus
|
||||
* treatment — sitting directly beside a 12px-radius Send button on the control
|
||||
* the whole company types into first, because `/` redirects to `/piggy`.
|
||||
*
|
||||
* It now matches `Input` from `@/components/ui` line for line: 12px radius
|
||||
* because it is a control, `bg-surface` because a transparent field on a
|
||||
* tinted canvas has no edge, and `focus-visible:border-accent` so focus reads
|
||||
* as the border changing rather than a second outline appearing outside it.
|
||||
* The global `:focus-visible` rule still paints the brand ring on top.
|
||||
*
|
||||
* `min-h-[60px]` is kept: this is a growing composer, and the auto-resize
|
||||
* logic at its call sites measures against a floor it already assumes.
|
||||
*/
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, React.ComponentProps<'textarea'>>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
'flex min-h-[60px] w-full rounded-lg border border-border bg-surface px-3 py-2 text-fg',
|
||||
'placeholder:text-muted focus-visible:border-accent',
|
||||
'transition-colors duration-1 ease-enter',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
// The base stylesheet enforces a 16px minimum here so Safari does not
|
||||
// zoom the viewport on focus; nothing may override it downward.
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Textarea.displayName = 'Textarea';
|
||||
|
||||
export { Textarea }
|
||||
export { Textarea };
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { compactNumber, percent } from '@/lib/api';
|
||||
|
||||
/**
|
||||
* How much of a capacity block is sold, how much is merely held, and how much
|
||||
* is still sellable.
|
||||
*
|
||||
* Sold and held are drawn as separate segments because a full-looking bar made
|
||||
* mostly of unconverted holds is a lie a seller would act on — held hours are
|
||||
* a claim someone can walk away from, sold hours are revenue.
|
||||
*
|
||||
* The track is `bg-surface-2`. One of the two copies of this bar had drifted to
|
||||
* `bg-surface`, which inside the allocation sheet's `bg-surface-2` panel
|
||||
* measured 1.02:1 against its own container: an invisible track, at the exact
|
||||
* moment someone commits GPU-hours to a customer.
|
||||
*/
|
||||
export function UtilisationBar({
|
||||
sold,
|
||||
held,
|
||||
total,
|
||||
label,
|
||||
className,
|
||||
}: {
|
||||
/** GPU-hours sold. */
|
||||
sold: number;
|
||||
/** GPU-hours held but not yet sold. */
|
||||
held: number;
|
||||
/** GPU-hours committed in total. Zero renders an empty track, not a full one. */
|
||||
total: number;
|
||||
/** What the bar is about, e.g. the block's name. Prefixes the spoken label. */
|
||||
label?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const soldPct = total > 0 ? sold / total : 0;
|
||||
const heldPct = total > 0 ? held / total : 0;
|
||||
const available = Math.max(0, total - sold - held);
|
||||
|
||||
// Held is clamped against sold so a book that has over-held a block renders a
|
||||
// full bar rather than a segment running past the end of its track.
|
||||
const soldWidth = Math.min(100, soldPct * 100);
|
||||
const heldWidth = Math.max(0, Math.min(100 - soldWidth, heldPct * 100));
|
||||
|
||||
const spoken = `${percent(soldPct)} sold, ${percent(heldPct)} held, ${compactNumber(available)} GPU-hours sellable`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('flex h-2 min-w-0 overflow-hidden rounded-full bg-surface-2', className)}
|
||||
role="img"
|
||||
aria-label={label ? `${label}: ${spoken}` : spoken}
|
||||
>
|
||||
<div className="bg-primary" style={{ width: `${soldWidth}%` }} />
|
||||
<div className="bg-primary/35" style={{ width: `${heldWidth}%` }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user