From b1f62519ec5c66fccb682b49d1875aeb88f81f7c Mon Sep 17 00:00:00 2001 From: yyyyaaa Date: Mon, 19 Oct 2026 21:34:29 +0700 Subject: [PATCH 1/8] fix(ui): keep tone badge corners clean --- packages/ui/src/components/workspace-kit/primitives.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/components/workspace-kit/primitives.tsx b/packages/ui/src/components/workspace-kit/primitives.tsx index f96f4bd..0efc8b2 100644 --- a/packages/ui/src/components/workspace-kit/primitives.tsx +++ b/packages/ui/src/components/workspace-kit/primitives.tsx @@ -102,9 +102,14 @@ type ToneBadgeProps = { /** * Tone fills mixed from `--tone`. On a white page the fill carries the colour and the * border only defines the edge; dark keeps a fainter fill under a stronger border. + * The light edge is translucent so it sits on any row tint, and where relative colours + * work its chroma is capped first: a translucent edge of a tone outside sRGB (the info + * blue) otherwise renders yellow at the rounded corners. */ const toneBadgeClass = cn( - 'border-[color-mix(in_oklab,var(--tone)_25%,transparent)] bg-[color-mix(in_oklab,var(--tone)_14%,transparent)]', + '[--tone-edge:color-mix(in_oklab,var(--tone)_25%,transparent)]', + 'supports-[color:oklch(from_red_l_c_h)]:[--tone-edge:color-mix(in_oklab,oklch(from_var(--tone)_l_min(c,0.12)_h)_25%,transparent)]', + 'border-(--tone-edge) bg-[color-mix(in_oklab,var(--tone)_14%,transparent)]', 'text-[color-mix(in_oklab,var(--tone),var(--foreground)_32%)]', 'dark:border-[color-mix(in_oklab,var(--tone)_35%,transparent)] dark:bg-[color-mix(in_oklab,var(--tone)_9%,transparent)]', ); From 71ff16a7a96dc74f7795f7a0c931d131ada005eb Mon Sep 17 00:00:00 2001 From: yyyyaaa Date: Mon, 19 Oct 2026 21:34:29 +0700 Subject: [PATCH 2/8] feat(ui): add resource-kit, auth-pages and dashboard-tiles resource-kit renders list, detail and form screens for an app's own tables from a resource definition and a host-supplied source, with relation pickers, many-to-many links and delete. auth-pages are the account panels with injected handlers, and dashboard-tiles are count and recent-record tiles over resource-kit sources. All three are framework- and backend-agnostic: routing and data come in through adapters. --- packages/ui/package.json | 1 + .../src/components/auth-pages/auth-card.tsx | 182 ++++++++ .../src/components/auth-pages/auth-layout.tsx | 61 +++ .../auth-pages/check-email-panel.tsx | 90 ++++ .../auth-pages/forgot-password-panel.tsx | 90 ++++ .../ui/src/components/auth-pages/index.ts | 34 ++ .../components/auth-pages/password-input.tsx | 50 +++ .../auth-pages/reset-password-panel.tsx | 111 +++++ .../components/auth-pages/sign-in-panel.tsx | 115 +++++ .../components/auth-pages/sign-up-panel.tsx | 96 ++++ .../ui/src/components/auth-pages/types.ts | 90 ++++ .../components/auth-pages/use-auth-form.ts | 140 ++++++ .../auth-pages/verify-email-panel.tsx | 102 +++++ .../dashboard-tiles/dashboard-grid.tsx | 19 + .../src/components/dashboard-tiles/index.ts | 7 + .../dashboard-tiles/recent-records.tsx | 104 +++++ .../dashboard-tiles/resource-count-tile.tsx | 42 ++ .../components/dashboard-tiles/stat-tile.tsx | 64 +++ .../src/components/resource-kit/context.tsx | 101 +++++ .../components/resource-kit/data-table.tsx | 287 ++++++++++++ .../components/resource-kit/delete-dialog.tsx | 84 ++++ .../ui/src/components/resource-kit/demo.ts | 246 ++++++++++ .../resource-kit/entity-form-sheet.tsx | 73 +++ .../components/resource-kit/entity-form.tsx | 244 ++++++++++ .../components/resource-kit/field-control.tsx | 135 ++++++ .../components/resource-kit/field-value.tsx | 205 +++++++++ .../ui/src/components/resource-kit/fields.ts | 106 +++++ .../components/resource-kit/form-values.ts | 234 ++++++++++ .../ui/src/components/resource-kit/format.ts | 105 +++++ .../ui/src/components/resource-kit/index.ts | 43 ++ .../ui/src/components/resource-kit/labels.ts | 177 ++++++++ .../components/resource-kit/list-controls.tsx | 99 ++++ .../components/resource-kit/memory-source.ts | 123 +++++ .../ui/src/components/resource-kit/queries.ts | 111 +++++ .../components/resource-kit/record-detail.tsx | 194 ++++++++ .../components/resource-kit/related-list.tsx | 93 ++++ .../resource-kit/relation-picker.tsx | 231 ++++++++++ .../components/resource-kit/resource-nav.tsx | 59 +++ .../components/resource-kit/resource-page.tsx | 192 ++++++++ .../components/resource-kit/save-failure.tsx | 21 + .../ui/src/components/resource-kit/types.ts | 123 +++++ packages/ui/src/stories/AuthPages.stories.tsx | 202 +++++++++ .../ui/src/stories/DashboardTiles.stories.tsx | 68 +++ .../ui/src/stories/ResourceKit.stories.tsx | 232 ++++++++++ .../stories/break-ui/AuthPages.stories.tsx | 110 +++++ .../break-ui/DashboardTiles.stories.tsx | 61 +++ .../stories/break-ui/ResourceKit.stories.tsx | 68 +++ .../src/stories/break-ui/auth-worst-case.ts | 77 ++++ .../stories/break-ui/resource-worst-case.ts | 73 +++ .../stories/resource-kit/demo-dashboard.tsx | 24 + .../ui/src/stories/resource-kit/story-app.tsx | 91 ++++ packages/ui/test/auth-pages.test.tsx | 216 +++++++++ packages/ui/test/resource-kit.test.tsx | 425 ++++++++++++++++++ pnpm-lock.yaml | 3 + 54 files changed, 6334 insertions(+) create mode 100644 packages/ui/src/components/auth-pages/auth-card.tsx create mode 100644 packages/ui/src/components/auth-pages/auth-layout.tsx create mode 100644 packages/ui/src/components/auth-pages/check-email-panel.tsx create mode 100644 packages/ui/src/components/auth-pages/forgot-password-panel.tsx create mode 100644 packages/ui/src/components/auth-pages/index.ts create mode 100644 packages/ui/src/components/auth-pages/password-input.tsx create mode 100644 packages/ui/src/components/auth-pages/reset-password-panel.tsx create mode 100644 packages/ui/src/components/auth-pages/sign-in-panel.tsx create mode 100644 packages/ui/src/components/auth-pages/sign-up-panel.tsx create mode 100644 packages/ui/src/components/auth-pages/types.ts create mode 100644 packages/ui/src/components/auth-pages/use-auth-form.ts create mode 100644 packages/ui/src/components/auth-pages/verify-email-panel.tsx create mode 100644 packages/ui/src/components/dashboard-tiles/dashboard-grid.tsx create mode 100644 packages/ui/src/components/dashboard-tiles/index.ts create mode 100644 packages/ui/src/components/dashboard-tiles/recent-records.tsx create mode 100644 packages/ui/src/components/dashboard-tiles/resource-count-tile.tsx create mode 100644 packages/ui/src/components/dashboard-tiles/stat-tile.tsx create mode 100644 packages/ui/src/components/resource-kit/context.tsx create mode 100644 packages/ui/src/components/resource-kit/data-table.tsx create mode 100644 packages/ui/src/components/resource-kit/delete-dialog.tsx create mode 100644 packages/ui/src/components/resource-kit/demo.ts create mode 100644 packages/ui/src/components/resource-kit/entity-form-sheet.tsx create mode 100644 packages/ui/src/components/resource-kit/entity-form.tsx create mode 100644 packages/ui/src/components/resource-kit/field-control.tsx create mode 100644 packages/ui/src/components/resource-kit/field-value.tsx create mode 100644 packages/ui/src/components/resource-kit/fields.ts create mode 100644 packages/ui/src/components/resource-kit/form-values.ts create mode 100644 packages/ui/src/components/resource-kit/format.ts create mode 100644 packages/ui/src/components/resource-kit/index.ts create mode 100644 packages/ui/src/components/resource-kit/labels.ts create mode 100644 packages/ui/src/components/resource-kit/list-controls.tsx create mode 100644 packages/ui/src/components/resource-kit/memory-source.ts create mode 100644 packages/ui/src/components/resource-kit/queries.ts create mode 100644 packages/ui/src/components/resource-kit/record-detail.tsx create mode 100644 packages/ui/src/components/resource-kit/related-list.tsx create mode 100644 packages/ui/src/components/resource-kit/relation-picker.tsx create mode 100644 packages/ui/src/components/resource-kit/resource-nav.tsx create mode 100644 packages/ui/src/components/resource-kit/resource-page.tsx create mode 100644 packages/ui/src/components/resource-kit/save-failure.tsx create mode 100644 packages/ui/src/components/resource-kit/types.ts create mode 100644 packages/ui/src/stories/AuthPages.stories.tsx create mode 100644 packages/ui/src/stories/DashboardTiles.stories.tsx create mode 100644 packages/ui/src/stories/ResourceKit.stories.tsx create mode 100644 packages/ui/src/stories/break-ui/AuthPages.stories.tsx create mode 100644 packages/ui/src/stories/break-ui/DashboardTiles.stories.tsx create mode 100644 packages/ui/src/stories/break-ui/ResourceKit.stories.tsx create mode 100644 packages/ui/src/stories/break-ui/auth-worst-case.ts create mode 100644 packages/ui/src/stories/break-ui/resource-worst-case.ts create mode 100644 packages/ui/src/stories/resource-kit/demo-dashboard.tsx create mode 100644 packages/ui/src/stories/resource-kit/story-app.tsx create mode 100644 packages/ui/test/auth-pages.test.tsx create mode 100644 packages/ui/test/resource-kit.test.tsx diff --git a/packages/ui/package.json b/packages/ui/package.json index f0cef7d..05c63c5 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -849,6 +849,7 @@ "@storybook/addon-a11y": "^10.1.11", "@storybook/react-vite": "^10.1.11", "@tailwindcss/postcss": "^4.1.18", + "@tanstack/react-query": "^5.90.16", "@types/node": "^20.19.27", "@types/react": "^19.3.0", "@types/react-dom": "^19.2.3", diff --git a/packages/ui/src/components/auth-pages/auth-card.tsx b/packages/ui/src/components/auth-pages/auth-card.tsx new file mode 100644 index 0000000..4a1c83e --- /dev/null +++ b/packages/ui/src/components/auth-pages/auth-card.tsx @@ -0,0 +1,182 @@ +'use client'; + +import * as React from 'react'; +import { ArrowLeftIcon, type LucideIcon } from 'lucide-react'; + +import { cn } from '../../lib/utils'; +import { Alert, AlertDescription } from '../alert'; +import { Button, buttonVariants } from '../button'; +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '../card'; +import { Spinner } from '../spinner'; +import { focusRingClass } from '../workspace-kit/primitives'; +import type { AuthLinkComponent, AuthNotice } from './types'; + +/** The link component an `AuthLayout` hands down to the panels inside it. */ +export const AuthLinkContext = React.createContext(undefined); + +export function AuthLink({ + href, + linkComponent, + className, + children, +}: { + href: string; + linkComponent?: AuthLinkComponent; + className?: string; + children: React.ReactNode; +}) { + const inherited = React.useContext(AuthLinkContext); + const LinkComponent = linkComponent ?? inherited; + if (LinkComponent) { + return ( + + {children} + + ); + } + return ( + + {children} + + ); +} + +/** A quiet footer link, such as "Forgot password?". */ +export const mutedLinkClass = cn('cursor-pointer rounded text-muted-foreground hover:text-foreground', focusRingClass); +/** The footer's call to action, such as "Create account". */ +export const primaryLinkClass = cn('cursor-pointer rounded font-medium text-primary hover:underline', focusRingClass); +/** A footer sentence with a link in it; long translations wrap instead of widening the card. */ +export const footerTextClass = 'text-muted-foreground min-w-0 [overflow-wrap:anywhere]'; + +const TONE_TILE = { + primary: 'bg-primary/10 text-primary ring-primary/20', + success: 'bg-success/10 text-success ring-success/20', + destructive: 'bg-destructive/10 text-destructive ring-destructive/20', +} as const; + +const NOTICE_VARIANT = { info: 'info', success: 'success', error: 'destructive' } as const; + +type AuthCardProps = { + icon: LucideIcon | 'spinner'; + tone?: keyof typeof TONE_TILE; + title: React.ReactNode; + description?: React.ReactNode; + notice?: AuthNotice; + footer?: React.ReactNode; + /** Moves focus to the heading on mount, for a state that replaces the one the person was using. */ + focusTitle?: boolean; + className?: string; + children?: React.ReactNode; +}; + +/** The card every auth panel shares: icon tile, heading, description, notice, body, and dashed footer. */ +export function AuthCard({ + icon: Icon, + tone = 'primary', + title, + description, + notice, + footer, + focusTitle = false, + className, + children, +}: AuthCardProps) { + const titleRef = React.useRef(null); + const hasBody = Boolean(notice) || React.Children.toArray(children).length > 0; + + React.useEffect(() => { + if (focusTitle) titleRef.current?.focus(); + }, [focusTitle]); + + return ( + + +
+ {Icon === 'spinner' ? :
+ +

+ {title} +

+
+ {description ? {description} : null} +
+ {hasBody ? ( + + {notice ? ( + + {notice.message} + + ) : null} + {children} + + ) : null} + {footer ? ( + + {footer} + + ) : null} +
+ ); +} + +/** The failure a handler answered with. */ +export function AuthFailure({ message }: { message?: string }) { + if (!message) return null; + return ( + + {message} + + ); +} + +/** The full-width submit button, disabled with its pending label while the handler runs. */ +export function AuthSubmit({ + pending, + label, + pendingLabel, + disabled = false, + type = 'submit', + variant, + onClick, +}: { + pending: boolean; + label: React.ReactNode; + pendingLabel: React.ReactNode; + disabled?: boolean; + type?: 'submit' | 'button'; + variant?: 'default' | 'outline'; + onClick?: () => void; +}) { + return ( + + ); +} + +/** A primary call to action that navigates, such as "Sign in" after a reset. */ +export function AuthLinkButton({ href, linkComponent, children }: { href: string; linkComponent?: AuthLinkComponent; children: React.ReactNode }) { + return ( + + {children} + + ); +} + +/** The footer of every panel except sign-in: a ghost link back to sign in. */ +export function BackToSignIn({ href, linkComponent }: { href?: string; linkComponent?: AuthLinkComponent }) { + if (!href) return null; + return ( + + + ); +} + +/** The address an email went to, kept left to right inside any sentence. */ +export function EmailAddress({ email }: { email: string }) { + return {email}; +} diff --git a/packages/ui/src/components/auth-pages/auth-layout.tsx b/packages/ui/src/components/auth-pages/auth-layout.tsx new file mode 100644 index 0000000..7b47951 --- /dev/null +++ b/packages/ui/src/components/auth-pages/auth-layout.tsx @@ -0,0 +1,61 @@ +'use client'; + +import * as React from 'react'; + +import { cn } from '../../lib/utils'; +import { focusRingClass } from '../workspace-kit/primitives'; +import { AuthLink, AuthLinkContext } from './auth-card'; +import type { AuthBrand, AuthLayoutProps } from './types'; + +const brandClass = 'flex min-w-0 max-w-full items-center justify-center gap-2.5 text-foreground'; + +function BrandMark({ brand }: { brand: AuthBrand }) { + const content = ( + <> + {brand.logo ? ( + + ) : null} + + {brand.name} + + + ); + + if (!brand.href) return
{content}
; + return ( + + {content} + + ); +} + +/** + * The page around an auth panel: a centred column on the page background with + * the brand above the card and a small muted footer below. It adds no + * landmarks, so it can sit inside the host's own `main`. + */ +export function AuthLayout({ children, brand, footer, linkComponent, className }: AuthLayoutProps) { + return ( + +
+
+ {brand ? : null} + {children} + {footer ? ( +
+ {footer} +
+ ) : null} +
+
+
+ ); +} diff --git a/packages/ui/src/components/auth-pages/check-email-panel.tsx b/packages/ui/src/components/auth-pages/check-email-panel.tsx new file mode 100644 index 0000000..2dcf267 --- /dev/null +++ b/packages/ui/src/components/auth-pages/check-email-panel.tsx @@ -0,0 +1,90 @@ +'use client'; + +import * as React from 'react'; +import { MailCheckIcon } from 'lucide-react'; + +import { Alert, AlertDescription } from '../alert'; +import { AuthCard, AuthFailure, AuthSubmit, BackToSignIn, EmailAddress } from './auth-card'; +import type { CheckEmailPanelProps } from './types'; +import { useAuthForm } from './use-auth-form'; + +const NO_FIELDS = [] as const; + +export function checkEmailDescription(email?: string): React.ReactNode { + if (!email) return 'We sent you a confirmation link. Open it to confirm your address, then sign in with your password.'; + return ( + <> + We sent a confirmation link to . Open it to confirm your address, then sign in with your + password. + + ); +} + +/** + * The check-email state. Sign-up renders it in place after `{ ok: true }` with + * `focusTitle`, so the new heading is announced where the form was. + */ +export function CheckEmailCard({ + email, + onResend, + links, + title = 'Check your email', + description, + notice, + linkComponent, + className, + focusTitle = false, +}: CheckEmailPanelProps & { focusTitle?: boolean }) { + const form = useAuthForm(NO_FIELDS); + const [resent, setResent] = React.useState(false); + const trimmed = email?.trim() || undefined; + + return ( + : null} + icon={MailCheckIcon} + notice={notice} + title={title} + > +

+ The link can take a minute to arrive. If you do not see it, check your spam folder + {onResend ? ' or send it again.' : '.'} +

+ {resent ? ( + + + {trimmed ? ( + <> + We sent a new link to . + + ) : ( + 'We sent a new link.' + )} + + + ) : null} + + {onResend ? ( + { + setResent(false); + void form.submit({ run: onResend, onSuccess: () => setResent(true) }); + }} + pending={form.pending} + pendingLabel="Sending…" + type="button" + variant="outline" + /> + ) : null} +
+ ); +} + +/** Tells a new account to confirm its address, then sign in with its password. */ +export function CheckEmailPanel(props: CheckEmailPanelProps) { + return ; +} diff --git a/packages/ui/src/components/auth-pages/forgot-password-panel.tsx b/packages/ui/src/components/auth-pages/forgot-password-panel.tsx new file mode 100644 index 0000000..20dd938 --- /dev/null +++ b/packages/ui/src/components/auth-pages/forgot-password-panel.tsx @@ -0,0 +1,90 @@ +'use client'; + +import * as React from 'react'; +import { MailCheckIcon, RotateCcwKeyIcon } from 'lucide-react'; + +import { Button } from '../button'; +import { Field, FieldGroup } from '../field'; +import { AuthCard, AuthFailure, AuthSubmit, BackToSignIn, EmailAddress } from './auth-card'; +import { EmailInput } from './password-input'; +import type { ForgotPasswordPanelProps } from './types'; +import { emailError, useAuthForm } from './use-auth-form'; + +const FIELDS = ['email'] as const; + +/** + * Asks for a password reset link. On `{ ok: true }` it says a link is on its + * way if an account exists, so the page never reveals which addresses have one. + */ +export function ForgotPasswordPanel({ + onSubmit, + links, + title = 'Reset your password', + description = 'Enter the email address for your account and we will send you a link to choose a new password.', + notice, + linkComponent, + className, +}: ForgotPasswordPanelProps) { + const [email, setEmail] = React.useState(''); + const [sentTo, setSentTo] = React.useState(); + const form = useAuthForm(FIELDS); + const footer = links?.signIn ? : null; + + if (sentTo !== undefined) { + return ( + + If an account exists for , a reset link is on its way. + + } + focusTitle + footer={footer} + icon={MailCheckIcon} + title="Check your email" + > +

+ The link can take a minute to arrive. If you do not see it, check your spam folder. +

+ +
+ ); + } + + const submit = (event: React.FormEvent) => { + event.preventDefault(); + const trimmed = email.trim(); + void form.submit({ + validate: () => ({ email: emailError(trimmed) }), + run: () => onSubmit({ email: trimmed }), + onSuccess: () => setSentTo(trimmed), + focusOnFailure: 'email', + }); + }; + + return ( + +
+ + + { + setEmail(event.currentTarget.value); + form.clearError('email'); + }} + required + value={email} + /> + + + + + +
+ ); +} diff --git a/packages/ui/src/components/auth-pages/index.ts b/packages/ui/src/components/auth-pages/index.ts new file mode 100644 index 0000000..696cccf --- /dev/null +++ b/packages/ui/src/components/auth-pages/index.ts @@ -0,0 +1,34 @@ +/** + * Auth Pages: sign-in, sign-up, check-email, verify-email, forgot-password, and + * reset-password panels with a page layout. Every panel takes injected handlers + * that answer `{ ok: true }` or `{ ok: false, message }`; nothing is fetched, + * routed, or stored here. + */ +export type { + AuthBrand, + AuthHandlerResult, + AuthLayoutProps, + AuthLinkComponent, + AuthNotice, + AuthPanelBase, + AuthResult, + CheckEmailPanelProps, + ForgotPasswordPanelProps, + ForgotPasswordValues, + ResetPasswordPanelProps, + ResetPasswordValues, + SignInPanelProps, + SignInValues, + SignUpPanelProps, + SignUpValues, + VerifyEmailPanelProps, +} from './types'; + +export { AuthLayout } from './auth-layout'; +export { SignInPanel } from './sign-in-panel'; +export { SignUpPanel } from './sign-up-panel'; +export { CheckEmailPanel } from './check-email-panel'; +export { VerifyEmailPanel } from './verify-email-panel'; +export { ForgotPasswordPanel } from './forgot-password-panel'; +export { ResetPasswordPanel } from './reset-password-panel'; +export { EmailInput, PasswordInput, type EmailInputProps, type PasswordInputProps } from './password-input'; diff --git a/packages/ui/src/components/auth-pages/password-input.tsx b/packages/ui/src/components/auth-pages/password-input.tsx new file mode 100644 index 0000000..bc31675 --- /dev/null +++ b/packages/ui/src/components/auth-pages/password-input.tsx @@ -0,0 +1,50 @@ +'use client'; + +import * as React from 'react'; +import { EyeIcon, EyeOffIcon, LockKeyholeIcon, MailIcon } from 'lucide-react'; + +import { Button } from '../button'; +import type { InputProps } from '../input'; +import { InputGroup, InputGroupAddon, InputGroupInput } from '../input-group'; + +export type PasswordInputProps = Omit; + +/** A password input with a lock icon and a show/hide toggle. */ +export const PasswordInput = React.forwardRef(function PasswordInput(props, ref) { + const [visible, setVisible] = React.useState(false); + return ( + + + + + + + + + ); +}); + +export type EmailInputProps = Omit; + +/** An email input with a mail icon; the password input's sibling on every form. */ +export const EmailInput = React.forwardRef(function EmailInput(props, ref) { + return ( + + + + + + ); +}); diff --git a/packages/ui/src/components/auth-pages/reset-password-panel.tsx b/packages/ui/src/components/auth-pages/reset-password-panel.tsx new file mode 100644 index 0000000..4a1ce60 --- /dev/null +++ b/packages/ui/src/components/auth-pages/reset-password-panel.tsx @@ -0,0 +1,111 @@ +'use client'; + +import * as React from 'react'; +import { CircleCheckIcon, LockKeyholeIcon } from 'lucide-react'; + +import { Field, FieldGroup } from '../field'; +import { AuthCard, AuthFailure, AuthLinkButton, AuthSubmit, BackToSignIn } from './auth-card'; +import { PasswordInput } from './password-input'; +import type { ResetPasswordPanelProps } from './types'; +import { passwordError, passwordMinLength, useAuthForm } from './use-auth-form'; + +const FIELDS = ['password', 'confirm'] as const; + +function confirmError(password: string, confirm: string): string | undefined { + if (!confirm) return 'Enter the new password again.'; + if (confirm !== password) return 'The passwords do not match.'; + return undefined; +} + +/** Sets a new password from a reset link: the password twice, then a way to sign in. */ +export function ResetPasswordPanel({ + onSubmit, + links, + passwordMinLength: minLengthProp, + title = 'Choose a new password', + description = 'Set a new password to finish recovering your account.', + notice, + linkComponent, + className, +}: ResetPasswordPanelProps) { + const [password, setPassword] = React.useState(''); + const [confirm, setConfirm] = React.useState(''); + const [done, setDone] = React.useState(false); + const form = useAuthForm(FIELDS); + const minLength = passwordMinLength(minLengthProp); + const hint = minLength > 1 ? `Use at least ${minLength} characters.` : undefined; + + if (done) { + return ( + + {links?.signIn ? ( + + Sign in + + ) : null} + + ); + } + + const submit = (event: React.FormEvent) => { + event.preventDefault(); + void form.submit({ + validate: () => ({ password: passwordError(password, minLength), confirm: confirmError(password, confirm) }), + run: () => onSubmit({ password }), + onSuccess: () => setDone(true), + focusOnFailure: 'password', + }); + }; + + return ( + : null} + icon={LockKeyholeIcon} + notice={notice} + title={title} + > +
+ + + { + setPassword(event.currentTarget.value); + form.clearError('password'); + }} + required + value={password} + /> + + + { + setConfirm(event.currentTarget.value); + form.clearError('confirm'); + }} + required + value={confirm} + /> + + + + + +
+ ); +} diff --git a/packages/ui/src/components/auth-pages/sign-in-panel.tsx b/packages/ui/src/components/auth-pages/sign-in-panel.tsx new file mode 100644 index 0000000..130b491 --- /dev/null +++ b/packages/ui/src/components/auth-pages/sign-in-panel.tsx @@ -0,0 +1,115 @@ +'use client'; + +import * as React from 'react'; +import { KeyRoundIcon } from 'lucide-react'; + +import { Checkbox } from '../checkbox'; +import { Field, FieldGroup, FieldLabel } from '../field'; +import { AuthCard, AuthFailure, AuthLink, AuthSubmit, footerTextClass, mutedLinkClass, primaryLinkClass } from './auth-card'; +import { EmailInput, PasswordInput } from './password-input'; +import type { SignInPanelProps } from './types'; +import { emailError, passwordError, useAuthForm } from './use-auth-form'; + +const FIELDS = ['email', 'password'] as const; + +/** Email and password sign-in with a remember-me choice. The host navigates on `{ ok: true }`. */ +export function SignInPanel({ + onSubmit, + links, + defaultEmail = '', + title = 'Sign in', + description = 'Use your account to continue to this application.', + notice, + linkComponent, + className, +}: SignInPanelProps) { + const [email, setEmail] = React.useState(defaultEmail); + const [password, setPassword] = React.useState(''); + const [rememberMe, setRememberMe] = React.useState(false); + // After `{ ok: true }` the host is navigating away; keep the button down so a second click cannot sign in twice. + const [signedIn, setSignedIn] = React.useState(false); + const form = useAuthForm(FIELDS); + const rememberId = React.useId(); + + const submit = (event: React.FormEvent) => { + event.preventDefault(); + const trimmed = email.trim(); + void form.submit({ + validate: () => ({ email: emailError(trimmed), password: passwordError(password) }), + run: () => onSubmit({ email: trimmed, password, rememberMe }), + onSuccess: () => setSignedIn(true), + focusOnFailure: 'password', + }); + }; + + const footer = + links?.forgotPassword || links?.signUp ? ( + <> + {links.forgotPassword ? ( + + Forgot password? + + ) : ( + + )} + {links.signUp ? ( + + New here?{' '} + + Create account + + + ) : null} + + ) : null; + + return ( + +
+ + + { + setEmail(event.currentTarget.value); + form.clearError('email'); + }} + required + value={email} + /> + + + { + setPassword(event.currentTarget.value); + form.clearError('password'); + }} + required + value={password} + /> + + + setRememberMe(checked === true)} + /> + Keep me signed in on this device + + + + + +
+ ); +} diff --git a/packages/ui/src/components/auth-pages/sign-up-panel.tsx b/packages/ui/src/components/auth-pages/sign-up-panel.tsx new file mode 100644 index 0000000..8453ad8 --- /dev/null +++ b/packages/ui/src/components/auth-pages/sign-up-panel.tsx @@ -0,0 +1,96 @@ +'use client'; + +import * as React from 'react'; +import { UserRoundPlusIcon } from 'lucide-react'; + +import { Field, FieldGroup } from '../field'; +import { AuthCard, AuthFailure, AuthLink, AuthSubmit, footerTextClass, primaryLinkClass } from './auth-card'; +import { CheckEmailCard } from './check-email-panel'; +import { EmailInput, PasswordInput } from './password-input'; +import type { SignUpPanelProps } from './types'; +import { emailError, passwordError, passwordMinLength, useAuthForm } from './use-auth-form'; + +const FIELDS = ['email', 'password'] as const; + +/** + * Email and password sign-up. It never expects a session: on `{ ok: true }` it + * shows the check-email state for the address in place of the form. + */ +export function SignUpPanel({ + onSubmit, + links, + passwordMinLength: minLengthProp, + title = 'Create an account', + description = 'Sign up with your email address and a password.', + notice, + linkComponent, + className, +}: SignUpPanelProps) { + const [email, setEmail] = React.useState(''); + const [password, setPassword] = React.useState(''); + const [sentTo, setSentTo] = React.useState(); + const form = useAuthForm(FIELDS); + const minLength = passwordMinLength(minLengthProp); + const hint = minLength > 1 ? `Use at least ${minLength} characters.` : undefined; + + if (sentTo !== undefined) { + return ; + } + + const submit = (event: React.FormEvent) => { + event.preventDefault(); + const trimmed = email.trim(); + void form.submit({ + validate: () => ({ email: emailError(trimmed), password: passwordError(password, minLength) }), + run: () => onSubmit({ email: trimmed, password }), + onSuccess: () => setSentTo(trimmed), + focusOnFailure: 'email', + }); + }; + + const footer = links?.signIn ? ( + + Already have an account?{' '} + + Sign in + + + ) : null; + + return ( + +
+ + + { + setEmail(event.currentTarget.value); + form.clearError('email'); + }} + required + value={email} + /> + + + { + setPassword(event.currentTarget.value); + form.clearError('password'); + }} + required + value={password} + /> + + + + + +
+ ); +} diff --git a/packages/ui/src/components/auth-pages/types.ts b/packages/ui/src/components/auth-pages/types.ts new file mode 100644 index 0000000..94e6e87 --- /dev/null +++ b/packages/ui/src/components/auth-pages/types.ts @@ -0,0 +1,90 @@ +import type * as React from 'react'; + +/** What every auth handler answers. A failure message is shown to the person as written. */ +export type AuthResult = { ok: true } | { ok: false; message: string }; + +/** Handlers may answer synchronously or with a promise. */ +export type AuthHandlerResult = AuthResult | Promise; + +/** + * The link a host renders for in-app navigation. Same shape as resource-kit's + * `ResourceRouter.Link`, so one router link can be passed to both. + */ +export type AuthLinkComponent = React.ComponentType<{ + href: string; + className?: string; + children: React.ReactNode; +}>; + +/** A message the host shows above a panel, such as "Your session expired". */ +export type AuthNotice = { tone: 'info' | 'success' | 'error'; message: React.ReactNode }; + +export type AuthPanelBase = { + /** Replaces the panel heading (the first state of the panel). */ + title?: React.ReactNode; + /** Replaces the line under the heading (the first state of the panel). */ + description?: React.ReactNode; + notice?: AuthNotice; + /** Renders the panel's links. Falls back to the `AuthLayout` link, then to a plain ``. */ + linkComponent?: AuthLinkComponent; + className?: string; +}; + +export type SignInValues = { email: string; password: string; rememberMe: boolean }; + +export type SignInPanelProps = AuthPanelBase & { + onSubmit(values: SignInValues): AuthHandlerResult; + links?: { signUp?: string; forgotPassword?: string }; + defaultEmail?: string; +}; + +export type SignUpValues = { email: string; password: string }; + +export type SignUpPanelProps = AuthPanelBase & { + /** On `{ ok: true }` the panel shows the check-email state for the address, unless the host navigated away. */ + onSubmit(values: SignUpValues): AuthHandlerResult; + links?: { signIn?: string }; + /** Defaults to 8. */ + passwordMinLength?: number; +}; + +export type CheckEmailPanelProps = AuthPanelBase & { + email?: string; + /** Shows a resend button when given. */ + onResend?(): AuthHandlerResult; + links?: { signIn?: string }; +}; + +export type VerifyEmailPanelProps = AuthPanelBase & { + /** Called once when the panel mounts, and again from "Try again" after a failure. */ + onVerify(): AuthHandlerResult; + links?: { signIn?: string; continue?: string }; +}; + +export type ForgotPasswordValues = { email: string }; + +export type ForgotPasswordPanelProps = AuthPanelBase & { + /** On `{ ok: true }` the panel shows a neutral message that never says whether the account exists. */ + onSubmit(values: ForgotPasswordValues): AuthHandlerResult; + links?: { signIn?: string }; +}; + +export type ResetPasswordValues = { password: string }; + +export type ResetPasswordPanelProps = AuthPanelBase & { + onSubmit(values: ResetPasswordValues): AuthHandlerResult; + links?: { signIn?: string }; + /** Defaults to 8. */ + passwordMinLength?: number; +}; + +export type AuthBrand = { name: React.ReactNode; logo?: React.ReactNode; href?: string }; + +export type AuthLayoutProps = { + children: React.ReactNode; + brand?: AuthBrand; + footer?: React.ReactNode; + /** Used for the brand link and inherited by every panel inside the layout. */ + linkComponent?: AuthLinkComponent; + className?: string; +}; diff --git a/packages/ui/src/components/auth-pages/use-auth-form.ts b/packages/ui/src/components/auth-pages/use-auth-form.ts new file mode 100644 index 0000000..b21f6cc --- /dev/null +++ b/packages/ui/src/components/auth-pages/use-auth-form.ts @@ -0,0 +1,140 @@ +'use client'; + +import * as React from 'react'; + +import type { AuthHandlerResult, AuthResult } from './types'; + +/** Shown for a handler that throws or answers with something that is not an `AuthResult`. */ +export const AUTH_GENERIC_ERROR = 'Something went wrong. Try again.'; + +export const DEFAULT_PASSWORD_MIN_LENGTH = 8; + +// A shape check only: it catches a missing @ or a stray space, and leaves the +// real decision to the server, so `name@localhost` still gets through. +const EMAIL_SHAPE = /^[^\s@]+@[^\s@]+$/; + +export type AuthFieldErrors = Partial>; + +export function emailError(email: string): string | undefined { + if (!email) return 'Enter an email address.'; + if (!EMAIL_SHAPE.test(email)) return 'Enter a valid email address, like name@example.com.'; + return undefined; +} + +export function passwordError(password: string, minLength = 1): string | undefined { + if (!password) return 'Enter a password.'; + if (password.length < minLength) return `Password must be at least ${minLength} characters.`; + return undefined; +} + +export function passwordMinLength(value: number | undefined): number { + if (value === undefined || !Number.isFinite(value)) return DEFAULT_PASSWORD_MIN_LENGTH; + return Math.max(1, Math.floor(value)); +} + +function failureMessage(result: unknown): string | undefined { + if (!result || typeof result !== 'object') return AUTH_GENERIC_ERROR; + const { ok, message } = result as Partial<{ ok: boolean; message: unknown }>; + if (ok === true) return undefined; + return ok === false && typeof message === 'string' && message.trim() ? message : AUTH_GENERIC_ERROR; +} + +type SubmitOptions = { + /** Field errors to show; the handler only runs when this returns none. */ + validate?: () => AuthFieldErrors; + run: () => AuthHandlerResult; + /** Runs on `{ ok: true }`, only while the panel is still mounted. */ + onSuccess?: () => void; + /** The field that takes focus when the handler fails, so the alert is read next to it. */ + focusOnFailure?: K; +}; + +/** + * Submit, validation, and pending state shared by every auth panel: field + * errors wired to `aria-invalid`/`aria-describedby` with focus on the first + * invalid field, one call at a time, and a failure message for the alert. + */ +export function useAuthForm(fields: readonly K[]) { + const baseId = React.useId(); + const [errors, setErrors] = React.useState>({}); + const [pending, setPending] = React.useState(false); + const [failure, setFailure] = React.useState(); + const inputs = React.useRef>>({}); + const running = React.useRef(false); + const mounted = React.useRef(false); + + React.useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + + const ids = (name: K) => ({ + input: `${baseId}-${name}`, + error: `${baseId}-${name}-error`, + description: `${baseId}-${name}-description`, + }); + + /** Props for the `Field` around an input. */ + const field = (name: K, description?: string) => ({ + htmlFor: ids(name).input, + error: errors[name], + errorId: ids(name).error, + description, + descriptionId: description ? ids(name).description : undefined, + }); + + /** Props for the input itself. Pass `described` when its `Field` has a description. */ + const input = (name: K, described = false) => { + const describedBy = [described && ids(name).description, errors[name] && ids(name).error].filter(Boolean).join(' '); + return { + id: ids(name).input, + ref: (node: HTMLInputElement | null) => { + inputs.current[name] = node; + }, + 'aria-invalid': errors[name] ? true : undefined, + 'aria-describedby': describedBy || undefined, + }; + }; + + const clearError = (name: K) => setErrors((current) => (current[name] ? { ...current, [name]: undefined } : current)); + + const submit = async ({ validate, run, onSuccess, focusOnFailure }: SubmitOptions) => { + if (running.current) return; + const next: AuthFieldErrors = validate?.() ?? {}; + setErrors(next); + setFailure(undefined); + const firstInvalid = fields.find((name) => next[name]); + if (firstInvalid) { + inputs.current[firstInvalid]?.focus(); + return; + } + + running.current = true; + setPending(true); + let result: AuthResult | undefined; + try { + result = await run(); + } catch { + result = undefined; + } + running.current = false; + if (!mounted.current) return; + setPending(false); + const message = failureMessage(result); + if (message === undefined) { + onSuccess?.(); + return; + } + setFailure(message); + if (focusOnFailure) inputs.current[focusOnFailure]?.focus(); + }; + + const reset = () => { + setErrors({}); + setFailure(undefined); + }; + + return { pending, failure, field, input, clearError, submit, reset }; +} diff --git a/packages/ui/src/components/auth-pages/verify-email-panel.tsx b/packages/ui/src/components/auth-pages/verify-email-panel.tsx new file mode 100644 index 0000000..7a668a3 --- /dev/null +++ b/packages/ui/src/components/auth-pages/verify-email-panel.tsx @@ -0,0 +1,102 @@ +'use client'; + +import * as React from 'react'; +import { CircleCheckIcon, MailWarningIcon, RotateCcwIcon } from 'lucide-react'; + +import { AuthCard, AuthFailure, AuthLinkButton, AuthSubmit, BackToSignIn } from './auth-card'; +import type { VerifyEmailPanelProps } from './types'; +import { useAuthForm } from './use-auth-form'; + +const NO_FIELDS = [] as const; + +/** + * Confirms an email address from a verification link. It calls `onVerify` once + * on mount (StrictMode's second effect run does not call it again), then shows + * the result: a link onward, or the failure with "Try again". + */ +export function VerifyEmailPanel({ + onVerify, + links, + title = 'Verifying your email', + description = 'This takes a moment. Keep this page open.', + notice, + linkComponent, + className, +}: VerifyEmailPanelProps) { + const form = useAuthForm(NO_FIELDS); + const [verified, setVerified] = React.useState(false); + const started = React.useRef(false); + const latestVerify = React.useRef(onVerify); + latestVerify.current = onVerify; + const latestSubmit = React.useRef(form.submit); + latestSubmit.current = form.submit; + + const verify = React.useCallback(() => { + void latestSubmit.current({ run: () => latestVerify.current(), onSuccess: () => setVerified(true) }); + }, []); + + React.useEffect(() => { + if (started.current) return; + started.current = true; + verify(); + }, [verify]); + + const footer = links?.signIn ? : null; + const onward = links?.continue ?? links?.signIn; + + // Each state is its own card (`key`), so the result heading takes focus and is announced when it replaces the spinner. + if (verified) { + return ( + + {onward ? ( + + {links?.continue ? 'Continue' : 'Sign in'} + + ) : null} + + ); + } + + if (form.failure) { + return ( + + + + + ); + } + + return ( + + ); +} diff --git a/packages/ui/src/components/dashboard-tiles/dashboard-grid.tsx b/packages/ui/src/components/dashboard-tiles/dashboard-grid.tsx new file mode 100644 index 0000000..642aa2d --- /dev/null +++ b/packages/ui/src/components/dashboard-tiles/dashboard-grid.tsx @@ -0,0 +1,19 @@ +import * as React from 'react'; + +import { cn } from '../../lib/utils'; + +/** + * The dashboard's layout: two tiles across on a phone or narrow pane, four + * from a 48rem container. Count tiles take one cell; `RecentRecords` takes two. + * Sized by its own width, so it works in a split pane as well as a page. + */ +export function DashboardGrid({ className, children, ...props }: React.ComponentProps<'div'>) { + return ( +
+ {/* Items keep their own height, so a count tile beside a tall list does not stretch to match it. */} +
+ {children} +
+
+ ); +} diff --git a/packages/ui/src/components/dashboard-tiles/index.ts b/packages/ui/src/components/dashboard-tiles/index.ts new file mode 100644 index 0000000..1780619 --- /dev/null +++ b/packages/ui/src/components/dashboard-tiles/index.ts @@ -0,0 +1,7 @@ +export { DashboardGrid } from './dashboard-grid'; +export { RecentRecords } from './recent-records'; +export type { RecentRecordsProps } from './recent-records'; +export { ResourceCountTile } from './resource-count-tile'; +export type { ResourceCountTileProps } from './resource-count-tile'; +export { StatTile, tileClass } from './stat-tile'; +export type { StatTileProps } from './stat-tile'; diff --git a/packages/ui/src/components/dashboard-tiles/recent-records.tsx b/packages/ui/src/components/dashboard-tiles/recent-records.tsx new file mode 100644 index 0000000..2ed78b1 --- /dev/null +++ b/packages/ui/src/components/dashboard-tiles/recent-records.tsx @@ -0,0 +1,104 @@ +'use client'; + +import * as React from 'react'; + +import { cn } from '../../lib/utils'; +import { useResourceDefinition, useResourceIcon, useResourceKit } from '../resource-kit/context'; +import { FieldValue, linkClass } from '../resource-kit/field-value'; +import { fieldFormat, inSentence, recordId, visibleFields } from '../resource-kit/fields'; +import { errorMessage } from '../resource-kit/form-values'; +import { formatFieldText } from '../resource-kit/format'; +import { useRelationLabels, useRowTitles } from '../resource-kit/labels'; +import { useResourceList } from '../resource-kit/queries'; +import type { ListQuery, ResourceRef } from '../resource-kit/types'; +import { dashedRule, EmptyState, Panel } from '../workspace-kit/surface'; + +export type RecentRecordsProps = { + resource: ResourceRef; + /** Rows to show. Defaults to 5. */ + limit?: number; + /** Defaults to the resource's default sort, else newest first by `createdAt` when the resource has that field. */ + sort?: ListQuery['sort']; + /** Equality filters, e.g. `{ status: 'open' }`. */ + filters?: Record; + /** Defaults to "Recent
+ {content} + + ); +} diff --git a/packages/ui/src/components/resource-kit/context.tsx b/packages/ui/src/components/resource-kit/context.tsx new file mode 100644 index 0000000..960e662 --- /dev/null +++ b/packages/ui/src/components/resource-kit/context.tsx @@ -0,0 +1,101 @@ +'use client'; + +import { QueryClient, QueryClientContext, QueryClientProvider } from '@tanstack/react-query'; +import { type LucideIcon, Rows3 } from 'lucide-react'; +import * as React from 'react'; + +import type { AnyResource, ResourceRef, ResourceRouter } from './types'; + +type ResourceKitContextValue = { + router: ResourceRouter; + resources: readonly AnyResource[]; + byName: ReadonlyMap; + locale: string; + currency: string; + /** Fixed clock for relative dates, in epoch milliseconds; undefined reads the system clock. */ + now: number | undefined; + icons: Readonly>; +}; + +const ResourceKitContext = React.createContext(null); + +export type ResourceKitProviderProps = { + router: ResourceRouter; + resources: readonly AnyResource[]; + /** Reuses the host's client. Without one the provider uses the enclosing client, or makes its own. */ + queryClient?: QueryClient; + /** Number and date formatting. Defaults to en-US. */ + locale?: string; + /** ISO code for `currency` fields. Defaults to USD. */ + currency?: string; + /** A fixed clock for relative dates ("3 days ago"), e.g. from the server; defaults to the reader's clock. */ + now?: string | number | Date; + /** Icon components for the names resources use in `icon`, e.g. `{ calendar: Calendar }`. */ + icons?: Readonly>; + children: React.ReactNode; +}; + +/** `CalendarDays`, `calendar_days`, and `calendar-days` all name the same icon. */ +const iconKey = (name: string) => name.replace(/([a-z0-9])([A-Z])/g, '$1-$2').replace(/[\s_]+/g, '-').toLowerCase(); + +function createQueryClient() { + return new QueryClient({ defaultOptions: { queries: { staleTime: 30_000, retry: 1, refetchOnWindowFocus: false } } }); +} + +/** + * Holds the resources, the host router, and the query cache every resource + * screen reads through. Relation targets resolve here by name. + */ +export function ResourceKitProvider({ router, resources, queryClient, locale = 'en-US', currency = 'USD', now, icons, children }: ResourceKitProviderProps) { + const outerClient = React.useContext(QueryClientContext); + const [ownClient] = React.useState(() => (queryClient || outerClient ? null : createQueryClient())); + const value = React.useMemo( + () => ({ + router, + resources, + byName: new Map(resources.map((resource) => [resource.name, resource])), + locale, + currency, + now: now === undefined ? undefined : new Date(now).getTime(), + icons: Object.fromEntries(Object.entries(icons ?? {}).map(([name, icon]) => [iconKey(name), icon])), + }), + // A Date prop is a new object each render; its time is what matters. + [router, resources, locale, currency, now === undefined ? undefined : new Date(now).getTime(), icons], + ); + const content = {children}; + const client = queryClient ?? ownClient; + return client ? {content} : content; +} + +export function useResourceKit(): ResourceKitContextValue { + const context = React.useContext(ResourceKitContext); + if (!context) throw new Error('Resource Kit components must render inside .'); + return context; +} + +/** The kit context when one encloses the caller, for components that also work alone. */ +export function useOptionalResourceKit(): ResourceKitContextValue | null { + return React.useContext(ResourceKitContext); +} + +/** A resource by name or definition. */ +export function useResourceDefinition(resource: ResourceRef): AnyResource { + const { byName } = useResourceKit(); + if (typeof resource !== 'string') return resource; + const definition = byName.get(resource); + if (!definition) throw new Error(`Unknown resource "${resource}". Pass it to .`); + return definition; +} + +/** The icon a resource names, from the provider's `icons` map, or a generic table glyph. */ +export function useResourceIcon(resource: AnyResource): LucideIcon { + const { icons } = useResourceKit(); + if (!resource.icon) return Rows3; + return icons[iconKey(resource.icon)] ?? Rows3; +} + +/** A resource's icon, sized for a row or heading. */ +export function ResourceIcon({ resource, className }: { resource: ResourceRef; className?: string }) { + const Icon = useResourceIcon(useResourceDefinition(resource)); + return