wordle-five: the engine, the reward, the solver and the probe that checks them
The Python is the source of truth; src/demos/wordle/engine.ts will be a port of it, and CI gates the two against a SHA-256 over all 21.2M (guess, answer) pattern pairs rather than a hand-picked vector file — a vector file only ever catches the cases somebody thought of. The reward is three weighted components, and the third one is the reason this demo is worth building. `solved` and `economy` pull toward winning. `consistency` pulls against them, because a player maximising information deliberately guesses words that cannot win — a word that splits the remaining candidates evenly teaches more than a word that might happen to be right. That is good play, and it costs consistency. The probe ladder proves the tension is real rather than asserted: inaction 0.0000 crude 0.0111 plausible 0.1224 candidate_only 0.8925 exhaustive 0.9031 oracle 0.9458 The two good policies are 0.05 apart and neither dominates — the entropy oracle takes 1.00 economy and 0.73 consistency, the candidate-only player takes 0.75 and 1.00. Which one wins is a decision about what you want, which is the whole argument the site exists to make. probe.py fails CI if either starts dominating. Two traps found by building it. `consistency` is scored over turns SPENT, not guesses accepted: counting only legal guesses hands a free 1.0 to a policy that plays one word and then jams the parser five times — one guess, no contradictions, perfect score. And `economy`'s denominator is the depth the SHIPPED solver reaches, not a depth-optimal search: entropy-greedy is not depth-optimal, so grading it against an exact optimum would make the oracle rung fail its own assertion on some seeds. The word lists are built from Wordnik (MIT) intersected with SCOWL, never from the original game's 2,315 answers. 4,603 answers makes this materially harder than the original, so the published SALET/3.4212 results are cited as belonging to that list and our own reference player's TARES/3.72 is measured here. verifiers is an optional extra. The engine, reward, solver and probe all run — and gate — without an RL stack resolvable. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019mt6sHQHEnEYrJZvoMCJSB
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
import * as React from 'react';
|
||||
import * as AccordionPrimitive from '@radix-ui/react-accordion';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Accordion = AccordionPrimitive.Root;
|
||||
|
||||
function AccordionItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
|
||||
return <AccordionPrimitive.Item className={cn('border-b border-border', className)} {...props} />;
|
||||
}
|
||||
|
||||
function AccordionTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
|
||||
return (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
className={cn(
|
||||
'tap flex flex-1 items-center justify-between gap-3 py-3 text-left text-sm font-medium text-fg transition-colors duration-1 ease-enter hover:text-accent-fg [&[data-state=open]>svg]:rotate-180',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDown
|
||||
aria-hidden="true"
|
||||
className="size-4 shrink-0 text-muted transition-transform duration-2 ease-enter"
|
||||
/>
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* There is no height animation here on purpose.
|
||||
*
|
||||
* shadcn's accordion animates height with `accordion-down` / `accordion-up`
|
||||
* keyframes that its CLI writes into tailwind.config.js. That file is
|
||||
* hand-maintained in this repo and is not ours to edit, so those keyframes do
|
||||
* not exist. A `transition-[height]` on `--radix-accordion-content-height`
|
||||
* looks like a substitute and is not one: Radix's Presence waits for an
|
||||
* `animationend`, so with no animation-name the node unmounts the instant you
|
||||
* collapse it and the closing transition never plays. Fade + slide is a real
|
||||
* animation, so Presence holds the node, and it degrades correctly under
|
||||
* reduced motion.
|
||||
*/
|
||||
function AccordionContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
|
||||
return (
|
||||
<AccordionPrimitive.Content
|
||||
className="overflow-hidden duration-2 ease-enter data-[state=closed]:animate-out data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=open]:slide-in-from-top-1"
|
||||
{...props}
|
||||
>
|
||||
<div className={cn('pb-3 pt-0', className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
);
|
||||
}
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
|
||||
@@ -0,0 +1,33 @@
|
||||
import * as React from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const badgeVariants = cva(
|
||||
'inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-xs font-medium leading-5 transition-colors duration-1 ease-enter',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'border-transparent bg-accent-subtle text-accent-fg',
|
||||
outline: 'border-border text-muted',
|
||||
solid: 'border-transparent bg-brand text-accent-on',
|
||||
positive: 'border-transparent bg-positive/10 text-positive',
|
||||
warning: 'border-transparent bg-warning/10 text-warning',
|
||||
danger: 'border-transparent bg-danger/10 text-danger',
|
||||
info: 'border-transparent bg-info/10 text-info',
|
||||
muted: 'border-border bg-surface-2 text-muted',
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: 'default' },
|
||||
},
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLSpanElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return <span className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
@@ -0,0 +1,54 @@
|
||||
import * as React from 'react';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* shadcn "new-york" button, retuned to PIG's tokens.
|
||||
*
|
||||
* Note `accent` in tailwind.config.js is the SUBTLE hover surface, not the
|
||||
* brand — that mapping is deliberate and documented there. So the solid CTA
|
||||
* uses `bg-brand text-accent-on`, never `bg-accent`.
|
||||
*/
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors duration-1 ease-enter disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-brand text-accent-on shadow-sm hover:bg-brand/90',
|
||||
secondary: 'bg-surface-2 text-fg hover:bg-surface-2/70',
|
||||
outline: 'border border-border bg-surface text-fg hover:bg-surface-2',
|
||||
ghost: 'text-fg hover:bg-surface-2',
|
||||
subtle: 'bg-accent-subtle text-accent-fg hover:bg-accent-subtle/70',
|
||||
destructive: 'bg-danger text-white shadow-sm hover:bg-danger/90',
|
||||
link: 'text-accent-fg underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
// `h-9` is 36px, which is fine for a mouse and too small for a thumb.
|
||||
// Anything a phone visitor taps gets `size="touch"` or the `.tap`
|
||||
// helper on top — see SiteHeader.
|
||||
sm: 'h-8 rounded-md px-3 text-xs [&_svg]:size-3.5',
|
||||
default: 'h-9 px-4 py-2 [&_svg]:size-4',
|
||||
lg: 'h-11 rounded-lg px-6 [&_svg]:size-4',
|
||||
touch: 'min-h-11 px-4 py-2 [&_svg]:size-4',
|
||||
icon: 'size-9 [&_svg]:size-4',
|
||||
'icon-touch': 'size-11 [&_svg]:size-[1.125rem]',
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: 'default', size: 'default' },
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
function Button({ className, variant, size, asChild = false, ...props }: ButtonProps) {
|
||||
const Comp = asChild ? Slot : 'button';
|
||||
return <Comp className={cn(buttonVariants({ variant, size }), className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Button, buttonVariants };
|
||||
@@ -0,0 +1,31 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/** `.card` is defined in src/index.css so the shell and the demos agree on
|
||||
* one surface treatment; this component is the React face of it. */
|
||||
function Card({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return <div className={cn('card text-fg', className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return <div className={cn('flex flex-col gap-1.5 p-5', className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<'h3'>) {
|
||||
return <h3 className={cn('font-semibold leading-tight tracking-tight', className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<'p'>) {
|
||||
return <p className={cn('text-sm text-muted', className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return <div className={cn('p-5 pt-0', className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return <div className={cn('flex items-center p-5 pt-0', className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter };
|
||||
@@ -0,0 +1,98 @@
|
||||
import * as React from 'react';
|
||||
import { Drawer as DrawerPrimitive } from 'vaul';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* vaul, not Radix Dialog: this is the sheet you drag, used where a phone
|
||||
* visitor expects to flick a panel away (the reward editor, step detail).
|
||||
* `shouldScaleBackground` is off — it transforms `body`, which breaks
|
||||
* `position: fixed` on the sticky header underneath it.
|
||||
*/
|
||||
function Drawer({
|
||||
shouldScaleBackground = false,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
|
||||
return <DrawerPrimitive.Root shouldScaleBackground={shouldScaleBackground} {...props} />;
|
||||
}
|
||||
|
||||
const DrawerTrigger = DrawerPrimitive.Trigger;
|
||||
const DrawerPortal = DrawerPrimitive.Portal;
|
||||
const DrawerClose = DrawerPrimitive.Close;
|
||||
|
||||
function DrawerOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
|
||||
return <DrawerPrimitive.Overlay className={cn('fixed inset-0 z-50 bg-fg/40', className)} {...props} />;
|
||||
}
|
||||
|
||||
function DrawerContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
|
||||
return (
|
||||
<DrawerPortal>
|
||||
<DrawerOverlay />
|
||||
<DrawerPrimitive.Content
|
||||
className={cn(
|
||||
'fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto max-h-[88svh] flex-col rounded-t-xl border border-border bg-surface pb-[var(--safe-bottom)]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{/* The grab handle is decorative; the drawer is also closable with
|
||||
Escape and by the close control the caller renders. */}
|
||||
<div className="mx-auto mt-3 h-1.5 w-12 shrink-0 rounded-full bg-border" aria-hidden="true" />
|
||||
{children}
|
||||
</DrawerPrimitive.Content>
|
||||
</DrawerPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return <div className={cn('grid gap-1 p-4 text-left', className)} {...props} />;
|
||||
}
|
||||
|
||||
function DrawerBody({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
className={cn('min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 pb-4', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return <div className={cn('mt-auto flex flex-col gap-2 p-4', className)} {...props} />;
|
||||
}
|
||||
|
||||
function DrawerTitle({ className, ...props }: React.ComponentProps<typeof DrawerPrimitive.Title>) {
|
||||
return (
|
||||
<DrawerPrimitive.Title className={cn('text-base font-semibold text-fg', className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
|
||||
return (
|
||||
<DrawerPrimitive.Description className={cn('text-sm text-muted', className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Drawer,
|
||||
DrawerTrigger,
|
||||
DrawerPortal,
|
||||
DrawerClose,
|
||||
DrawerOverlay,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerBody,
|
||||
DrawerFooter,
|
||||
DrawerTitle,
|
||||
DrawerDescription,
|
||||
};
|
||||
@@ -0,0 +1,156 @@
|
||||
import * as React from 'react';
|
||||
import * as NavigationMenuPrimitive from '@radix-ui/react-navigation-menu';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* Tailwind 3.4 port of shadcn's navigation-menu.
|
||||
*
|
||||
* The registry version on ui.shadcn.com now targets Tailwind 4 and leans on
|
||||
* v4-only pieces (`size-*` everywhere, `@theme` tokens, the CSS-first config).
|
||||
* Everything below is expressible in 3.4 with tailwindcss-animate, which is
|
||||
* already a plugin here. Three things break if you change them carelessly:
|
||||
*
|
||||
* 1. VIEWPORT POSITIONING. The viewport is not inside the trigger — Radix
|
||||
* hoists every open panel into one shared box. It only lands under the menu
|
||||
* because it sits in an `absolute left-0 top-full` wrapper that is a child
|
||||
* of the *Root*, and because the Root is `relative`. Move the wrapper out of
|
||||
* the Root, or drop `relative`, and the panel positions against the page.
|
||||
* 2. WIDTH. `--radix-navigation-menu-viewport-width` is the measured width of
|
||||
* the open panel. Without that binding the viewport shrink-wraps to nothing
|
||||
* on the first frame and the panel visibly snaps to size.
|
||||
* 3. Z-INDEX. The header that hosts this is `position: sticky` with a
|
||||
* `backdrop-filter`, which makes it a stacking context, so the viewport's
|
||||
* z-index competes only inside the header — but it must still clear the
|
||||
* header's own translucent background, hence z-50 rather than the z-10 the
|
||||
* upstream recipe uses.
|
||||
*/
|
||||
function NavigationMenu({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Root
|
||||
className={cn('relative z-50 flex max-w-max flex-1 items-center justify-center', className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<NavigationMenuViewport />
|
||||
</NavigationMenuPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function NavigationMenuList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.List>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.List
|
||||
className={cn('group flex flex-1 list-none items-center justify-center gap-1', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const NavigationMenuItem = NavigationMenuPrimitive.Item;
|
||||
|
||||
const navigationMenuTriggerStyle = cva(
|
||||
'group inline-flex h-9 w-max items-center justify-center gap-1 rounded-md px-3 py-2 text-sm font-medium text-fg transition-colors duration-1 ease-enter hover:bg-surface-2 disabled:pointer-events-none disabled:opacity-50 data-[state=open]:bg-surface-2',
|
||||
);
|
||||
|
||||
function NavigationMenuTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Trigger
|
||||
className={cn(navigationMenuTriggerStyle(), className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDown
|
||||
aria-hidden="true"
|
||||
className="relative top-px size-3.5 text-muted transition-transform duration-3 ease-enter group-data-[state=open]:rotate-180"
|
||||
/>
|
||||
</NavigationMenuPrimitive.Trigger>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `data-motion` is set by Radix when you move sideways from one open panel to
|
||||
* the next; the slide utilities below are what make that read as one surface
|
||||
* sliding rather than two panels blinking. The `md:absolute` flip is the
|
||||
* upstream trick that lets the content measure itself at full width before the
|
||||
* viewport adopts that width.
|
||||
*/
|
||||
function NavigationMenuContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Content
|
||||
className={cn(
|
||||
'left-0 top-0 w-full duration-3 ease-enter data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const NavigationMenuLink = NavigationMenuPrimitive.Link;
|
||||
|
||||
function NavigationMenuViewport({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {
|
||||
return (
|
||||
<div className="absolute left-0 top-full z-50 flex justify-center">
|
||||
<NavigationMenuPrimitive.Viewport
|
||||
className={cn(
|
||||
'relative mt-2 h-[var(--radix-navigation-menu-viewport-height)] w-full origin-top overflow-hidden rounded-xl border border-border bg-surface text-fg shadow-xl',
|
||||
'transition-[width,height] duration-3 ease-enter',
|
||||
'data-[state=closed]:animate-out data-[state=open]:animate-in data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
'md:w-[var(--radix-navigation-menu-viewport-width)]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NavigationMenuIndicator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Indicator>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Indicator
|
||||
className={cn(
|
||||
'top-full z-50 flex h-2 items-end justify-center overflow-hidden duration-3 ease-enter data-[state=hidden]:animate-out data-[state=visible]:animate-in data-[state=hidden]:fade-out data-[state=visible]:fade-in',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{/* Rotated square, half-clipped by the parent's overflow — a caret that
|
||||
inherits the panel's border and surface without a second SVG. */}
|
||||
<div className="relative top-[60%] size-2 rotate-45 rounded-tl-sm border-l border-t border-border bg-surface" />
|
||||
</NavigationMenuPrimitive.Indicator>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
NavigationMenu,
|
||||
NavigationMenuList,
|
||||
NavigationMenuItem,
|
||||
NavigationMenuContent,
|
||||
NavigationMenuTrigger,
|
||||
NavigationMenuLink,
|
||||
NavigationMenuIndicator,
|
||||
NavigationMenuViewport,
|
||||
navigationMenuTriggerStyle,
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
import * as React from 'react';
|
||||
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = 'vertical',
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'flex touch-none select-none transition-colors duration-1 ease-enter',
|
||||
orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent p-[1px]',
|
||||
orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent p-[1px]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
);
|
||||
}
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root className={cn('relative overflow-hidden', className)} {...props}>
|
||||
{/*
|
||||
`h-full w-full` on the viewport is load-bearing: Radix renders a
|
||||
`display:table` element inside it, which will happily grow past a
|
||||
max-height and leave you with a scroll area that never scrolls.
|
||||
*/}
|
||||
<ScrollAreaPrimitive.Viewport className="size-full rounded-[inherit]">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar };
|
||||
@@ -0,0 +1,26 @@
|
||||
import * as React from 'react';
|
||||
import * as SeparatorPrimitive from '@radix-ui/react-separator';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = 'horizontal',
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'shrink-0 bg-border',
|
||||
orientation === 'horizontal' ? 'h-px w-full' : 'h-full w-px',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Separator };
|
||||
@@ -0,0 +1,141 @@
|
||||
import * as React from 'react';
|
||||
import * as SheetPrimitive from '@radix-ui/react-dialog';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Sheet = SheetPrimitive.Root;
|
||||
const SheetTrigger = SheetPrimitive.Trigger;
|
||||
const SheetClose = SheetPrimitive.Close;
|
||||
const SheetPortal = SheetPrimitive.Portal;
|
||||
|
||||
function SheetOverlay({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||
return (
|
||||
<SheetPrimitive.Overlay
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-fg/40 backdrop-blur-[2px] duration-3 ease-enter data-[state=closed]:animate-out data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const sheetVariants = cva(
|
||||
'fixed z-50 flex flex-col gap-0 bg-surface shadow-xl duration-3 ease-enter data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||
{
|
||||
variants: {
|
||||
side: {
|
||||
top: 'inset-x-0 top-0 border-b border-border pt-[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 border-border pb-[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-[min(88vw,22rem)] border-r border-border pl-[var(--safe-left)] data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left',
|
||||
right:
|
||||
'inset-y-0 right-0 h-full w-[min(88vw,22rem)] border-l border-border pr-[var(--safe-right)] data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right',
|
||||
},
|
||||
},
|
||||
defaultVariants: { side: 'right' },
|
||||
},
|
||||
);
|
||||
|
||||
export interface SheetContentProps
|
||||
extends React.ComponentProps<typeof SheetPrimitive.Content>,
|
||||
VariantProps<typeof sheetVariants> {
|
||||
/** Set false when the sheet supplies its own close affordance. */
|
||||
showClose?: boolean;
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
side = 'right',
|
||||
className,
|
||||
children,
|
||||
showClose = true,
|
||||
...props
|
||||
}: SheetContentProps) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content className={cn(sheetVariants({ side }), className)} {...props}>
|
||||
{children}
|
||||
{showClose ? (
|
||||
<SheetPrimitive.Close
|
||||
className="tap absolute right-3 top-3 inline-flex items-center justify-center rounded-md p-2 text-muted transition-colors duration-1 ease-enter hover:bg-surface-2 hover:text-fg"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X aria-hidden="true" className="size-5" />
|
||||
</SheetPrimitive.Close>
|
||||
) : null}
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex shrink-0 flex-col gap-1 border-b border-border px-4 pb-3 pt-[max(1rem,var(--safe-top))]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The scrolling half of the sheet. Keeping the scroll on an inner element
|
||||
* rather than on the content root is what stops a long menu from clipping on a
|
||||
* short phone; `overscroll-contain` stops the flick from chaining through to
|
||||
* the page behind the overlay.
|
||||
*/
|
||||
function SheetBody({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
className={cn('min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-3', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex shrink-0 flex-col gap-2 border-t border-border px-4 pb-[max(1rem,var(--safe-bottom))] pt-3',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetTitle({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
className={cn('text-base font-semibold text-fg', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||
return <SheetPrimitive.Description className={cn('text-sm text-muted', className)} {...props} />;
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetPortal,
|
||||
SheetOverlay,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetBody,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* `bg-muted` would be wrong here: in this palette `muted` is the muted TEXT
|
||||
* colour, a mid grey that reads as a filled block rather than a placeholder.
|
||||
* The placeholder surface is `surface-2`.
|
||||
*/
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn('animate-pulse rounded-md bg-surface-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Skeleton };
|
||||
@@ -0,0 +1,38 @@
|
||||
import * as React from 'react';
|
||||
import * as SliderPrimitive from '@radix-ui/react-slider';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* One thumb per value, not one hard-coded thumb: the reward editor drives this
|
||||
* with a single weight today and a range tomorrow, and a single-thumb slider
|
||||
* fed a two-value array silently drops the second value.
|
||||
*/
|
||||
function Slider({
|
||||
className,
|
||||
value,
|
||||
defaultValue,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
|
||||
const thumbCount = (value ?? defaultValue ?? [0]).length;
|
||||
return (
|
||||
<SliderPrimitive.Root
|
||||
className={cn('relative flex w-full touch-none select-none items-center py-2', className)}
|
||||
{...(value === undefined ? {} : { value })}
|
||||
{...(defaultValue === undefined ? {} : { defaultValue })}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-surface-2">
|
||||
<SliderPrimitive.Range className="absolute h-full bg-brand" />
|
||||
</SliderPrimitive.Track>
|
||||
{Array.from({ length: thumbCount }, (_, i) => (
|
||||
<SliderPrimitive.Thumb
|
||||
key={i}
|
||||
className="block size-5 rounded-full border-2 border-brand bg-surface shadow-sm transition-colors duration-1 ease-enter disabled:pointer-events-none disabled:opacity-50"
|
||||
/>
|
||||
))}
|
||||
</SliderPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Slider };
|
||||
@@ -0,0 +1,36 @@
|
||||
import * as React from 'react';
|
||||
import * as TabsPrimitive from '@radix-ui/react-tabs';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Tabs = TabsPrimitive.Root;
|
||||
|
||||
function TabsList({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
className={cn(
|
||||
'inline-flex items-center justify-start gap-1 rounded-lg border border-border bg-surface-2 p-1 text-muted',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
className={cn(
|
||||
'tap inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium transition-colors duration-1 ease-enter disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-surface data-[state=active]:text-fg data-[state=active]:shadow-sm',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsContent({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return <TabsPrimitive.Content className={cn('mt-4', className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||
@@ -0,0 +1,33 @@
|
||||
import * as React from 'react';
|
||||
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider;
|
||||
const Tooltip = TooltipPrimitive.Root;
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 6,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
// z-50 and not z-10: the tooltip has to clear the sticky header,
|
||||
// which is itself z-50 and creates a stacking context of its own.
|
||||
'z-50 overflow-hidden rounded-md border border-border bg-surface px-2.5 py-1.5 text-xs text-fg shadow-md',
|
||||
'duration-1 ease-enter animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
|
||||
'data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</TooltipPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
Reference in New Issue
Block a user