Core UI kit for artcraft-website-next (#1914)

This commit is contained in:
BFlat authored and GitHub committed 2026-08-21 13:05:39 +07:00
1 parent 14fde3bf30
commit d2ad5820e0
21 files changed
+1054 -72

No files matched your search

@@ -92,9 +92,9 @@
--color-invert-fg: var(--invert-fg);
--color-danger: var(--danger);
--font-display: var(--font-outfit), system-ui, sans-serif;
--font-display: var(--font-bricolage), system-ui, sans-serif;
--font-serif: var(--font-instrument), Georgia, serif;
--font-body: var(--font-source-sans), system-ui, sans-serif;
--font-body: var(--font-inter), system-ui, sans-serif;
--font-mono: var(--font-geist-mono), ui-monospace, SFMono-Regular, Menlo, monospace;
/* No rounded corners anywhere in this design language. */
@@ -1,5 +1,5 @@
import type { Metadata, Viewport } from "next";
import { Outfit, Instrument_Serif, Source_Sans_3, Geist_Mono } from "next/font/google";
import { Bricolage_Grotesque, Instrument_Serif, Inter, Geist_Mono } from "next/font/google";
import SiteNav from "@/components/site-nav";
import SiteFooter from "@/components/site-footer";
import MotionProvider from "@/components/motion-provider";
@@ -8,9 +8,9 @@ import "./globals.css";
const SITE_URL = "https://getartcraft.com";
const outfit = Outfit({
const bricolage = Bricolage_Grotesque({
subsets: ["latin"],
variable: "--font-outfit",
variable: "--font-bricolage",
display: "swap",
});
@@ -22,9 +22,9 @@ const instrumentSerif = Instrument_Serif({
display: "swap",
});
const sourceSans = Source_Sans_3({
const inter = Inter({
subsets: ["latin"],
variable: "--font-source-sans",
variable: "--font-inter",
display: "swap",
});
@@ -88,13 +88,15 @@ export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="en" suppressHydrationWarning>
<html
lang="en"
suppressHydrationWarning
className={`${bricolage.variable} ${instrumentSerif.variable} ${inter.variable} ${geistMono.variable}`}
>
<head>
<script dangerouslySetInnerHTML={{ __html: THEME_SCRIPT }} />
</head>
<body
className={`${outfit.variable} ${instrumentSerif.variable} ${sourceSans.variable} ${geistMono.variable}`}
>
<body>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(JSON_LD) }}
@@ -6,6 +6,7 @@ import {
tunerSnapshot,
type TunableGroup,
} from "@/lib/tuner";
import { Button } from "@/components/ui";
const UI_KEY = "artcraft-tuner-ui";
@@ -138,14 +139,15 @@ function Panel() {
function HeaderButton({ onClick, label }: { onClick: () => void; label: string }) {
return (
<button
type="button"
<Button
variant="ghost"
size="sm"
onClick={onClick}
onPointerDown={(e) => e.stopPropagation()}
className="hud-label min-w-[44px] border border-line px-1.5 py-0.5 text-[10px] text-muted hover:bg-invert-bg hover:text-invert-fg"
className="min-w-[44px] border border-line px-1.5 py-0.5 text-[10px] font-medium"
>
{label}
</button>
</Button>
);
}
@@ -1,37 +0,0 @@
import type { ReactNode } from "react";
import Link from "next/link";
const BASE =
"hud-label inline-flex h-12 items-center justify-center gap-2 px-6 font-bold transition-opacity";
const VARIANTS = {
solid: "bg-invert-bg text-invert-fg hover:opacity-80",
outline:
"invert-block border border-line-strong text-ink hover:border-transparent",
} as const;
export default function CtaLink({
href,
variant = "solid",
external = false,
children,
}: {
href: string;
variant?: keyof typeof VARIANTS;
external?: boolean;
children: ReactNode;
}) {
const className = `${BASE} ${VARIANTS[variant]}`;
if (external || /^https?:\/\//.test(href)) {
return (
<a href={href} className={className}>
{children}
</a>
);
}
return (
<Link href={href} className={className}>
{children}
</Link>
);
}
@@ -1,6 +1,6 @@
import { WEBAPP_URL } from "@/lib/links";
import { Button } from "@/components/ui";
import { SectionShell } from "./section-shell";
import CtaLink from "./cta-link";
export default function FinalCta() {
return (
@@ -19,10 +19,12 @@ export default function FinalCta() {
are minutes away.
</p>
<div className="mt-10 flex flex-wrap items-center justify-center gap-3">
<CtaLink href="/download">Download for desktop</CtaLink>
<CtaLink href={WEBAPP_URL} variant="outline">
<Button href="/download" size="lg">
Download for desktop
</Button>
<Button href={WEBAPP_URL} variant="secondary" size="lg">
Launch in browser
</CtaLink>
</Button>
</div>
</div>
</SectionShell>
@@ -2,9 +2,9 @@ import { AppleIcon, MonitorIcon } from "lucide-react";
import { SOCIAL_LINKS, WEBAPP_URL } from "@/lib/links";
import { HERO_VIDEO_URL } from "@/lib/landing-data";
import { GitHubIcon } from "@/components/icons";
import { Button } from "@/components/ui";
import HeroWordmark from "./hero-wordmark";
import HeroViewport from "./hero-viewport";
import CtaLink from "./cta-link";
export default function Hero() {
return (
@@ -42,14 +42,14 @@ export default function Hero() {
render with AI.
</p>
<div data-reveal className="flex flex-wrap items-center gap-3">
<CtaLink href="/download">
<Button href="/download" size="lg">
<AppleIcon aria-hidden className="h-4 w-4" />
<MonitorIcon aria-hidden className="h-4 w-4" />
Download free
</CtaLink>
<CtaLink href={WEBAPP_URL} variant="outline">
</Button>
<Button href={WEBAPP_URL} variant="secondary" size="lg">
Use on web
</CtaLink>
</Button>
</div>
</div>
@@ -1,4 +1,5 @@
import { GitHubIcon } from "@/components/icons";
import { Button } from "@/components/ui";
import { SOCIAL_LINKS } from "@/lib/links";
import { SectionShell, SectionEyebrow } from "./section-shell";
@@ -49,15 +50,17 @@ export default function Ownership() {
</h3>
<p className="mt-2 leading-relaxed text-muted">{pillar.body}</p>
{pillar.href && (
<a
<Button
href={pillar.href}
variant="secondary"
size="sm"
target="_blank"
rel="noopener noreferrer"
className="invert-block hud-label mt-6 inline-flex items-center gap-1.5 border border-line-strong px-3 py-1.5 text-ink hover:border-transparent"
className="mt-6 gap-1.5 font-medium"
>
<GitHubIcon className="h-3.5 w-3.5" />
View source
</a>
</Button>
)}
</article>
))}
@@ -7,6 +7,7 @@ import * as NavigationMenu from "@radix-ui/react-navigation-menu";
import { ChevronDownIcon, MenuIcon, XIcon } from "lucide-react";
import { twMerge } from "tailwind-merge";
import { SOCIAL_LINKS, WEBAPP_URL, webappUrl } from "@/lib/links";
import { Button } from "@/components/ui";
import ThemeToggle from "./theme-toggle";
type NavLeaf = { name: string; href: string };
@@ -38,8 +39,6 @@ const NAV_LABEL_CLASSES =
const NAV_LABEL_ACTIVE_CLASSES = "bg-invert-bg text-invert-fg";
const DROPDOWN_ITEM_CLASSES =
"group flex px-3 py-2.5 hud-label whitespace-nowrap";
const SOLID_CTA_CLASSES =
"flex h-full items-center border-l border-line bg-invert-bg px-5 hud-label font-bold text-invert-fg hover:opacity-80";
const isExternalHref = (href: string) => /^https?:\/\//.test(href);
@@ -193,9 +192,12 @@ export default function SiteNav() {
<span className={NAV_LABEL_CLASSES}>Pricing</span>
</Link>
<ThemeToggle className="w-12 border-l border-line" />
<a href={WEBAPP_URL} className={SOLID_CTA_CLASSES}>
<Button
href={WEBAPP_URL}
className="h-full border-l border-line px-5"
>
Launch App
</a>
</Button>
</div>
{/* Mobile: theme + hamburger */}
@@ -305,12 +307,12 @@ export default function SiteNav() {
<div className="border-t border-line" />
<a
<Button
href={WEBAPP_URL}
className="flex h-11 items-center justify-center bg-invert-bg px-4 hud-label font-bold text-invert-fg active:opacity-80"
className="h-11 w-full active:opacity-80"
>
Launch App
</a>
</Button>
<div className="flex items-stretch border-t border-line">
<Link
href="/pricing"
@@ -0,0 +1,26 @@
import type { ReactNode } from "react";
import { twMerge } from "tailwind-merge";
// Ported from @storyteller/ui-badge: mono micro-label chip on a sunken
// hairline-bordered surface, theme-token colors for light/dark.
export interface BadgeProps {
label: string;
className?: string;
icon?: ReactNode;
}
export function Badge({ label, className, icon }: BadgeProps) {
return (
<div
className={twMerge(
"flex w-fit items-center gap-1 rounded-none border border-line bg-bg-sunken px-1.5 py-px font-mono text-[10px] font-medium uppercase tracking-[0.1em] text-muted",
className,
)}
>
{icon}
{label}
</div>
);
}
export default Badge;
@@ -0,0 +1,116 @@
import type {
AnchorHTMLAttributes,
ButtonHTMLAttributes,
ReactNode,
} from "react";
import Link from "next/link";
import { LoaderCircleIcon } from "lucide-react";
import { twMerge } from "tailwind-merge";
/*
* Core button, ported from @storyteller/ui-button and re-tokened for the
* marketing site's theme variables so both light and dark render correctly.
* Mono uppercase label, hard edges, no radius. "primary" is the solid
* invert-block CTA (navbar "Launch App" style); "secondary" is the hairline
* outline that inverts on hover.
*/
type ButtonVariant =
| "primary"
| "secondary"
| "action"
| "destructive"
| "ghost";
type ButtonSize = "sm" | "md" | "lg";
const BASE_CLASSES =
"inline-flex w-fit items-center justify-center gap-2 rounded-none font-mono font-bold uppercase tracking-[0.15em] whitespace-nowrap transition-colors duration-150";
const VARIANT_CLASSES: Record<ButtonVariant, string> = {
primary: "bg-invert-bg text-invert-fg transition-opacity hover:opacity-80",
secondary:
"invert-block border border-line-strong text-ink hover:border-transparent",
action: "border border-line bg-bg-raised text-ink hover:border-line-strong",
destructive: "bg-danger text-white transition-opacity hover:opacity-80",
ghost: "text-muted hover:bg-invert-bg hover:text-invert-fg",
};
const SIZE_CLASSES: Record<ButtonSize, string> = {
sm: "px-3 py-1.5 text-[11px]",
md: "px-4 py-2 text-[11px]",
lg: "h-12 px-6 text-[11px]",
};
export interface ButtonProps
extends ButtonHTMLAttributes<HTMLButtonElement>,
Pick<AnchorHTMLAttributes<HTMLAnchorElement>, "target" | "rel"> {
variant?: ButtonVariant;
size?: ButtonSize;
/** Renders an anchor (next/link for internal paths) instead of a button. */
href?: string;
/** Forces a plain <a> even for internal-looking hrefs. */
external?: boolean;
loading?: boolean;
}
export function Button({
variant = "primary",
size = "md",
href,
external = false,
loading = false,
disabled,
className,
children,
target,
rel,
type,
...rest
}: ButtonProps) {
const classes = twMerge(
BASE_CLASSES,
VARIANT_CLASSES[variant],
SIZE_CLASSES[size],
(disabled || loading) && "pointer-events-none opacity-50",
className,
);
const content: ReactNode = (
<>
{loading && (
<LoaderCircleIcon aria-hidden className="h-4 w-4 animate-spin" />
)}
{children}
</>
);
if (href) {
const anchorProps = rest as unknown as AnchorHTMLAttributes<HTMLAnchorElement>;
if (external || /^(https?:\/\/|mailto:)/.test(href)) {
return (
<a href={href} target={target} rel={rel} className={classes} {...anchorProps}>
{content}
</a>
);
}
return (
<Link href={href} className={classes} {...anchorProps}>
{content}
</Link>
);
}
return (
<button
type={type ?? "button"}
className={classes}
disabled={disabled || loading}
{...rest}
>
{content}
</button>
);
}
export default Button;
@@ -0,0 +1,139 @@
"use client";
import {
useCallback,
useId,
useState,
type ChangeEvent,
type InputHTMLAttributes,
type ReactNode,
type Ref,
} from "react";
import { twMerge } from "tailwind-merge";
/*
* Ported from @storyteller/ui-checkbox: square box, hairline border, checked
* state inverts to the theme's invert block (white-on-black in light,
* black-on-white in dark).
*/
const CheckIcon = ({ className }: { className?: string }) => (
<svg
aria-hidden
viewBox="0 0 10 8"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={twMerge("block", className)}
>
<path
d="M1 4L3.6 6.6L9 1"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
type NativeInputProps = Omit<
InputHTMLAttributes<HTMLInputElement>,
"type" | "size"
>;
export interface CheckboxProps extends NativeInputProps {
label?: ReactNode;
size?: "sm" | "md";
/** Overrides the visual box styling. */
checkboxClassName?: string;
/** Overrides the label text styling. */
labelClassName?: string;
ref?: Ref<HTMLInputElement>;
}
export function Checkbox({
label,
size = "md",
className,
checkboxClassName,
labelClassName,
id,
disabled,
checked,
defaultChecked,
onChange,
ref,
...inputProps
}: CheckboxProps) {
const generatedId = useId();
const inputId = id ?? generatedId;
const isControlled = checked !== undefined;
const [internal, setInternal] = useState(!!defaultChecked);
const isChecked = isControlled ? checked : internal;
const handleChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
if (!isControlled) setInternal(e.target.checked);
onChange?.(e);
},
[isControlled, onChange],
);
const boxDims = size === "sm" ? "h-3.5 w-3.5" : "h-4 w-4";
const iconDims = size === "sm" ? "h-2 w-2" : "h-2.5 w-2.5";
return (
<label
htmlFor={inputId}
className={twMerge(
"inline-flex select-none items-center gap-2 leading-none",
disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer",
className,
)}
>
<input
ref={ref}
id={inputId}
type="checkbox"
disabled={disabled}
checked={isChecked}
onChange={handleChange}
{...inputProps}
className="peer sr-only"
/>
<span
aria-hidden
className={twMerge(
"relative inline-block shrink-0 rounded-none border transition-colors",
boxDims,
isChecked
? "border-invert-bg bg-invert-bg text-invert-fg"
: "border-line bg-transparent hover:border-line-strong",
"peer-focus-visible:outline peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-accent-ink",
checkboxClassName,
)}
>
{isChecked && (
<CheckIcon
className={twMerge(
"absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2",
iconDims,
)}
/>
)}
</span>
{label != null && (
<span
className={twMerge(
"text-sm text-muted",
disabled && "text-faint",
labelClassName,
)}
>
{label}
</span>
)}
</label>
);
}
export default Checkbox;
@@ -0,0 +1,39 @@
import { XIcon } from "lucide-react";
import { twMerge } from "tailwind-merge";
// Ported from @storyteller/ui-close-button: square icon button that inverts
// on hover.
export interface CloseButtonProps {
onClick: () => void;
className?: string;
size?: "sm" | "md" | "lg";
}
export function CloseButton({
onClick,
className,
size = "md",
}: CloseButtonProps) {
const sizeClasses = {
sm: "h-5 w-5",
md: "h-7 w-7",
lg: "h-9 w-9",
};
return (
<button
type="button"
aria-label="Close"
onClick={onClick}
className={twMerge(
"flex items-center justify-center rounded-none text-muted transition-colors hover:bg-invert-bg hover:text-invert-fg",
sizeClasses[size],
className,
)}
>
<XIcon className="h-[70%] w-[70%]" />
</button>
);
}
export default CloseButton;
@@ -0,0 +1,19 @@
// Core UI primitives ported from frontend/libs/components (the brutalist
// system landed in PR #1913), re-tokened onto this app's theme variables so
// every control renders correctly in both light and dark.
export { Badge, type BadgeProps } from "./badge";
export { Button, type ButtonProps } from "./button";
export { Checkbox, type CheckboxProps } from "./checkbox";
export { CloseButton, type CloseButtonProps } from "./close-button";
export { Input, type InputProps } from "./input";
export { Label, type LabelProps } from "./label";
export { Modal, type ModalProps } from "./modal";
export {
Select,
type SelectOption,
type SelectProps,
type SelectValue,
} from "./select";
export { Switch, type SwitchProps } from "./switch";
export { TabSelector, type TabItem, type TabSelectorProps } from "./tab-selector";
export { Tooltip, type TooltipProps } from "./tooltip";
@@ -0,0 +1,64 @@
import type { InputHTMLAttributes, ReactNode, Ref } from "react";
import { twMerge } from "tailwind-merge";
import { Label } from "./label";
/*
* Ported from @storyteller/ui-input: squared text field on a raised surface
* with hairline border that sharpens on hover and goes full-ink on focus.
*/
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
inputClassName?: string;
label?: string;
/** Rendered inside the field's left edge (sized by the caller). */
icon?: ReactNode;
isError?: boolean;
errorMessage?: string;
ref?: Ref<HTMLInputElement>;
}
export function Input({
label,
icon,
inputClassName,
className,
id,
isError,
errorMessage,
ref,
...rest
}: InputProps) {
const inputId = id ?? label;
return (
<div className={twMerge("flex flex-col", className)}>
{label && <Label htmlFor={inputId}>{label}</Label>}
<div className="relative w-full">
{icon && (
<span className="pointer-events-none absolute inset-y-0 left-3 flex items-center text-muted">
{icon}
</span>
)}
<input
ref={ref}
id={inputId}
className={twMerge(
"h-10 w-full rounded-none border border-line bg-bg-raised px-3 py-2.5 text-ink outline-none placeholder:text-faint",
"transition-colors duration-150 ease-in-out hover:border-line-strong focus:border-ink focus:outline-none",
"disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:border-line",
icon && "pl-10",
isError && "border-danger focus:border-danger",
inputClassName,
)}
{...rest}
/>
{errorMessage && (
<p className="mt-1 font-mono text-[10px] uppercase tracking-[0.1em] text-danger">
{errorMessage}
</p>
)}
</div>
</div>
);
}
export default Input;
@@ -0,0 +1,25 @@
import type { LabelHTMLAttributes, ReactNode } from "react";
import { twMerge } from "tailwind-merge";
// Ported from @storyteller/ui-label: mono uppercase field label.
export interface LabelProps extends LabelHTMLAttributes<HTMLLabelElement> {
children: ReactNode;
required?: boolean;
}
export function Label({ className, children, required, ...rest }: LabelProps) {
return (
<label
className={twMerge(
"mb-1.5 font-mono text-[10px] font-semibold uppercase tracking-[0.25em] text-muted",
className,
)}
{...rest}
>
{children}
{required && <span className="ml-0.5 text-danger">*</span>}
</label>
);
}
export default Label;
@@ -0,0 +1,126 @@
"use client";
import { useEffect, useId, useState, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { twMerge } from "tailwind-merge";
import { CloseButton } from "./close-button";
/*
* Ported from @storyteller/ui-modal, simplified for the marketing site (no
* dragging/resizing/stacking): centered squared panel on a raised surface
* with a hairline border, dim backdrop, Escape and outside-click dismissal.
*/
export interface ModalProps {
isOpen: boolean;
onClose: () => void;
children: ReactNode;
title?: ReactNode;
className?: string;
backdropClassName?: string;
childPadding?: boolean;
showClose?: boolean;
closeOnOutsideClick?: boolean;
closeOnEsc?: boolean;
/** Title for assistive tech when no visible title is rendered. */
accessibleTitle?: string;
}
export function Modal({
isOpen,
onClose,
children,
title,
className,
backdropClassName,
childPadding = true,
showClose = true,
closeOnOutsideClick = true,
closeOnEsc = true,
accessibleTitle,
}: ModalProps) {
const [mounted, setMounted] = useState(false);
const [entered, setEntered] = useState(false);
const titleId = useId();
useEffect(() => setMounted(true), []);
// Two-frame enter so the opening transition actually plays.
useEffect(() => {
if (!isOpen) {
setEntered(false);
return;
}
const frame = requestAnimationFrame(() =>
requestAnimationFrame(() => setEntered(true)),
);
return () => cancelAnimationFrame(frame);
}, [isOpen]);
useEffect(() => {
if (!isOpen || !closeOnEsc) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [isOpen, closeOnEsc, onClose]);
// Scroll lock while open.
useEffect(() => {
if (!isOpen) return;
const previous = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = previous;
};
}, [isOpen]);
if (!mounted || !isOpen) return null;
return createPortal(
<div className="fixed inset-0 z-[70]">
<div
aria-hidden
onClick={() => closeOnOutsideClick && onClose()}
className={twMerge(
"fixed inset-0 bg-black/70 transition-opacity duration-200",
entered ? "opacity-100" : "opacity-0",
backdropClassName,
)}
/>
<div className="pointer-events-none flex min-h-full items-center justify-center p-0 sm:p-4">
<div
role="dialog"
aria-modal="true"
aria-labelledby={title != null ? titleId : undefined}
aria-label={title == null ? (accessibleTitle ?? "Dialog") : undefined}
className={twMerge(
"pointer-events-auto relative w-full max-w-lg rounded-none border border-line bg-bg-raised text-left align-middle text-ink",
childPadding && "p-4",
"transition-[opacity,transform] duration-200",
entered
? "translate-y-0 scale-100 opacity-100"
: "-translate-y-2.5 scale-95 opacity-0",
className,
)}
>
{title != null && (
<h2
id={titleId}
className="mb-4 flex items-center gap-3 pr-10 font-display text-xl font-medium text-ink-strong"
>
{title}
</h2>
)}
{children}
{showClose && (
<CloseButton onClick={onClose} className="absolute right-2.5 top-2.5" />
)}
</div>
</div>
</div>,
document.body,
);
}
export default Modal;
@@ -0,0 +1,172 @@
"use client";
import { useEffect, useId, useRef, useState } from "react";
import { CheckIcon, ChevronDownIcon } from "lucide-react";
import { twMerge } from "tailwind-merge";
/*
* Ported from @storyteller/ui-select, rebuilt as a native listbox (no
* headlessui). Raised squared surface; the focused option inverts to the
* theme's invert block.
*/
export type SelectValue = string | number;
export type SelectOption = { label: string; value: SelectValue };
export interface SelectProps {
options: SelectOption[];
onChange: (val: SelectValue) => void;
placeholder?: string;
value?: SelectValue;
id?: string;
className?: string;
disabled?: boolean;
}
export function Select({
onChange,
options,
placeholder,
value,
id,
className,
disabled,
}: SelectProps) {
const [open, setOpen] = useState(false);
const selectedIndex = options.findIndex((option) => option.value === value);
const [activeIndex, setActiveIndex] = useState(selectedIndex);
const rootRef = useRef<HTMLDivElement>(null);
const listboxId = useId();
const selectedOption =
selectedIndex >= 0
? options[selectedIndex]
: { label: placeholder ?? "", value: "" };
const openList = () => {
setActiveIndex(selectedIndex >= 0 ? selectedIndex : 0);
setOpen(true);
};
const selectIndex = (index: number) => {
const option = options[index];
if (option) onChange(option.value);
setOpen(false);
};
useEffect(() => {
if (!open) return;
const onPointerDown = (e: PointerEvent) => {
if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("pointerdown", onPointerDown);
return () => document.removeEventListener("pointerdown", onPointerDown);
}, [open]);
const onKeyDown = (e: React.KeyboardEvent) => {
switch (e.key) {
case "ArrowDown":
e.preventDefault();
if (!open) openList();
else setActiveIndex((i) => Math.min(i + 1, options.length - 1));
break;
case "ArrowUp":
e.preventDefault();
if (!open) openList();
else setActiveIndex((i) => Math.max(i - 1, 0));
break;
case "Home":
if (open) {
e.preventDefault();
setActiveIndex(0);
}
break;
case "End":
if (open) {
e.preventDefault();
setActiveIndex(options.length - 1);
}
break;
case "Enter":
case " ":
e.preventDefault();
if (!open) openList();
else if (activeIndex >= 0) selectIndex(activeIndex);
break;
case "Escape":
if (open) {
e.preventDefault();
setOpen(false);
}
break;
}
};
return (
<div ref={rootRef} className={twMerge("relative", className)}>
<button
type="button"
id={id}
disabled={disabled}
aria-haspopup="listbox"
aria-expanded={open}
aria-controls={open ? listboxId : undefined}
onClick={() => (open ? setOpen(false) : openList())}
onKeyDown={onKeyDown}
className={twMerge(
"relative h-10 w-full cursor-pointer rounded-none border border-line bg-bg-raised py-2 pl-3 pr-10 text-left text-sm text-ink",
"transition-colors duration-150 ease-in-out hover:border-line-strong",
disabled && "cursor-not-allowed opacity-60 hover:border-line",
)}
>
<span
className={twMerge("block truncate", selectedIndex < 0 && "text-faint")}
>
{selectedOption.label}
</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2.5 text-muted">
<ChevronDownIcon
aria-hidden
className={twMerge("h-4 w-4 transition-transform", open && "rotate-180")}
/>
</span>
</button>
{open && (
<ul
id={listboxId}
role="listbox"
className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-none border border-line bg-bg-raised text-sm"
>
{options.map((option, index) => {
const selected = index === selectedIndex;
const active = index === activeIndex;
return (
<li
key={`${option.value}-${index}`}
role="option"
aria-selected={selected}
onMouseEnter={() => setActiveIndex(index)}
onClick={() => selectIndex(index)}
className={twMerge(
"relative cursor-pointer select-none py-2 pl-7 pr-2 transition-colors duration-150 ease-in-out",
selected ? "bg-ink/10 font-medium text-ink" : "text-ink/90",
active && "bg-invert-bg text-invert-fg",
)}
>
<span className="block truncate">{option.label}</span>
{selected && (
<span className="absolute inset-y-0 left-0 flex items-center pl-2">
<CheckIcon aria-hidden className="h-3 w-3" />
</span>
)}
</li>
);
})}
</ul>
)}
</div>
);
}
export default Select;
@@ -0,0 +1,54 @@
"use client";
import { twMerge } from "tailwind-merge";
/*
* Ported from @storyteller/ui-switch, rebuilt on a native button (no
* headlessui). Square track and knob; on-state inverts to the theme's
* invert block.
*/
export interface SwitchProps {
enabled: boolean;
setEnabled: (enabled: boolean) => void;
disabled?: boolean;
className?: string;
offClassName?: string;
"aria-label"?: string;
}
export function Switch({
enabled,
setEnabled,
disabled,
className,
offClassName,
"aria-label": ariaLabel,
}: SwitchProps) {
return (
<button
type="button"
role="switch"
aria-checked={enabled}
aria-label={ariaLabel}
disabled={disabled}
onClick={() => setEnabled(!enabled)}
className={twMerge(
"inline-flex h-6 w-11 items-center rounded-none border transition-colors",
enabled
? "border-invert-bg bg-invert-bg"
: (offClassName ?? "border-line bg-transparent hover:border-line-strong"),
disabled && "cursor-not-allowed opacity-50",
className,
)}
>
<span
className={twMerge(
"size-4 rounded-none transition-transform",
enabled ? "translate-x-[22px] bg-invert-fg" : "translate-x-1 bg-ink/70",
)}
/>
</button>
);
}
export default Switch;
@@ -0,0 +1,124 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { twMerge } from "tailwind-merge";
import { Tooltip } from "./tooltip";
/*
* Ported from @storyteller/ui-tab-selector, rebuilt on native buttons (no
* headlessui). Squared segmented control: a sliding invert-block indicator
* behind mono labels.
*/
export interface TabItem {
id: string;
label: string;
}
export interface TabSelectorProps {
tabs: TabItem[];
activeTab: string;
onTabChange: (tabId: string) => void;
className?: string;
disabled?: boolean;
disabledMessage?: string;
tabClassName?: string;
indicatorClassName?: string;
selectedTabClassName?: string;
listClassName?: string;
}
export function TabSelector({
tabs,
activeTab,
onTabChange,
className,
disabled,
disabledMessage,
tabClassName,
indicatorClassName,
selectedTabClassName,
listClassName,
}: TabSelectorProps) {
const selectedIndex = tabs.findIndex((tab) => tab.id === activeTab);
const tabsRef = useRef<(HTMLButtonElement | null)[]>([]);
const [indicatorStyle, setIndicatorStyle] = useState({ left: 0, width: 0 });
useEffect(() => {
const currentTab = selectedIndex >= 0 ? tabsRef.current[selectedIndex] : null;
if (currentTab) {
setIndicatorStyle({
left: currentTab.offsetLeft,
width: currentTab.offsetWidth,
});
}
}, [selectedIndex, tabs]);
const tabGroup = (
<div
className={twMerge(
"w-fit",
className,
disabled && "cursor-not-allowed opacity-60",
)}
>
<div
role="tablist"
className={twMerge(
"relative inline-flex min-w-fit overflow-x-auto rounded-none border border-line bg-bg-raised p-0.5 py-1",
listClassName,
)}
>
<div
aria-hidden
className={twMerge(
"absolute top-1 z-10 h-[calc(100%-8px)] rounded-none bg-invert-bg transition-all duration-200 ease-in-out",
indicatorClassName,
)}
style={{ left: indicatorStyle.left, width: indicatorStyle.width }}
/>
{tabs.map((tab, index) => {
const selected = index === selectedIndex;
return (
<button
key={tab.id}
ref={(el) => {
tabsRef.current[index] = el;
}}
type="button"
role="tab"
aria-selected={selected}
disabled={disabled}
onClick={() => onTabChange(tab.id)}
className={twMerge(
"relative z-20 mx-0.5 min-w-max rounded-none px-4 py-0.5 text-center font-mono text-[11px] font-semibold uppercase tracking-[0.1em] transition-colors duration-200 ease-in-out",
selected
? twMerge("text-invert-fg", selectedTabClassName)
: "text-muted hover:text-ink",
disabled && "cursor-not-allowed",
tabClassName,
)}
>
{tab.label}
</button>
);
})}
</div>
</div>
);
if (disabled) {
return (
<Tooltip
content={disabledMessage ?? "Cannot change tab right now."}
position="bottom"
>
{tabGroup}
</Tooltip>
);
}
return tabGroup;
}
export default TabSelector;
@@ -0,0 +1,102 @@
"use client";
import {
useEffect,
useRef,
useState,
type ReactElement,
type ReactNode,
} from "react";
import { twMerge } from "tailwind-merge";
/*
* Ported from @storyteller/ui-tooltip, simplified for the marketing site:
* hover/focus label positioned off the trigger, mono uppercase micro-copy on
* a raised hairline surface.
*/
const POSITION_CLASSES = {
top: "bottom-full left-1/2 mb-2 -translate-x-1/2",
bottom: "top-full left-1/2 mt-2 -translate-x-1/2",
left: "right-full top-1/2 mr-2 -translate-y-1/2",
right: "left-full top-1/2 ml-2 -translate-y-1/2",
} as const;
export interface TooltipProps {
children: ReactElement;
content: ReactNode;
position?: keyof typeof POSITION_CLASSES;
className?: string;
delay?: number;
/** Optional body copy shown under the mono label. */
description?: string;
disabled?: boolean;
}
export function Tooltip({
children,
content,
position = "top",
className,
delay = 300,
description,
disabled = false,
}: TooltipProps) {
const [showing, setShowing] = useState(false);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const show = () => {
if (disabled) return;
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => setShowing(true), delay);
};
const hide = () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
setShowing(false);
};
useEffect(() => {
if (disabled) setShowing(false);
}, [disabled]);
useEffect(() => {
return () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
};
}, []);
return (
<div
className="relative inline-block"
onPointerEnter={show}
onPointerLeave={hide}
onPointerCancel={hide}
onFocus={show}
onBlur={hide}
>
{children}
<div
role="tooltip"
aria-hidden={!showing}
className={twMerge(
"pointer-events-none absolute z-50 w-max rounded-none border border-line bg-bg-raised px-2.5 py-1.5",
"font-mono text-[11px] font-medium uppercase tracking-[0.1em] text-ink",
"transition-opacity duration-150",
POSITION_CLASSES[position],
showing ? "opacity-100" : "opacity-0",
className,
)}
>
{content}
{description && (
<p className="mt-1 max-w-[240px] whitespace-normal font-body text-xs font-normal normal-case tracking-normal text-muted">
{description}
</p>
)}
</div>
</div>
);
}
export default Tooltip;
+3 -1
View File
@@ -12,7 +12,9 @@
"apps/**/*",
"libs/**/*",
"!apps/artcraft-dashboard",
"!apps/artcraft-dashboard/**"
"!apps/artcraft-dashboard/**",
"!apps/artcraft-website-next",
"!apps/artcraft-website-next/**"
],
"dependencies": {
"@dnd-kit/core": "^6.3.1",