Core UI kit for artcraft-website-next (#1914)
This commit is contained in:
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;
|
||||
Generated
+3
-1
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user