initial
Publish npm package / publish (push) Successful in 19s

This commit is contained in:
Senko-san
2026-06-12 13:30:02 +03:00
commit fec09570f8
152 changed files with 23285 additions and 0 deletions
+521
View File
@@ -0,0 +1,521 @@
import { useState } from 'react';
import {
ArrowRight,
Trash,
Plus,
DotsThree,
MagnifyingGlass,
Folder,
FolderOpen,
PencilSimple,
Copy,
Tag,
Info,
FilePdf,
FileText,
CheckCircle,
Warning,
WarningOctagon,
} from '@phosphor-icons/react';
import { useTheme } from './components/theme';
import {
AlertDialog,
Badge,
Button,
Callout,
Card,
Checkbox,
Chip,
Control,
Dialog,
DialogClose,
IconButton,
List,
MenuRow,
MenuSeparator,
MenuSurface,
Progress,
RadioGroup,
RadioItem,
Row,
ScrollArea,
SearchField,
SegmentedControl,
Select,
Slider,
Spinner,
Stepper,
Switch,
TBody,
THead,
Table,
Tabs,
TabsContent,
TabsList,
Td,
TextArea,
TextField,
Th,
Tooltip,
Tr,
} from './components/ui';
const Section = ({ label, children }: { label: string; children: React.ReactNode }) => (
<section>
<div className="sec-label">{label}</div>
{children}
</section>
);
const App = () => {
const { theme, setTheme } = useTheme();
const [seg, setSeg] = useState('list');
const [tab, setTab] = useState('general');
const [count, setCount] = useState(4);
const [chips, setChips] = useState(['design', '2026', 'invoices']);
return (
<div className="mta-felt">
<div className="wrap">
<header>
<div className="topbar">
<div>
<div className="word">
MT<b>AIR</b> · KITCHEN SINK
</div>
<p className="sub">
Every component, live and interactive, built on Radix Primitives
styled from the global tokens in{' '}
<span className="mta-mono">tokens.css</span> +{' '}
<span className="mta-mono">components.css</span>. Click, toggle,
focus it all responds.
</p>
</div>
<div style={{ flexShrink: 0 }}>
<SegmentedControl
value={theme}
onValueChange={(v) => setTheme(v as 'dark' | 'light')}
items={[
{ value: 'dark', label: 'Dark' },
{ value: 'light', label: 'Light' },
]}
/>
</div>
</div>
</header>
{/* BUTTONS */}
<Section label="Buttons">
<div className="stack">
<div>
<div className="cap">Variants</div>
<div className="cluster">
<Button variant="primary">
<ArrowRight size={16} />
Primary
</Button>
<Button>Push button</Button>
<Button variant="ember">
<Trash size={16} />
Delete
</Button>
<Button variant="ghost">Cancel</Button>
<Button disabled>Disabled</Button>
</div>
</div>
<div>
<div className="cap">Sizes &amp; icon</div>
<div className="cluster">
<Button variant="primary" size="sm">
Small
</Button>
<Button size="sm">Small</Button>
<Button iconOnly aria-label="Add">
<Plus size={16} />
</Button>
<Button iconOnly aria-label="More">
<DotsThree size={16} weight="bold" />
</Button>
</div>
</div>
</div>
</Section>
{/* FIELDS */}
<Section label="Text fields &amp; selects">
<div className="two">
<div className="stack">
<div>
<div className="lab">Name</div>
<TextField defaultValue="Quarterly Report" />
</div>
<div>
<div className="lab">Location</div>
<TextField placeholder="Choose a folder…" />
</div>
<div>
<div className="lab">Search</div>
<SearchField
icon={<MagnifyingGlass size={16} />}
placeholder="Search files…"
/>
</div>
</div>
<div className="stack">
<div>
<div className="lab">View</div>
<Select
defaultValue="name"
aria-label="Sort order"
items={[
{ value: 'name', label: 'Sort by name' },
{ value: 'date', label: 'Sort by date' },
{ value: 'size', label: 'Sort by size' },
]}
/>
</div>
<div>
<div className="lab">Notes</div>
<TextArea
placeholder="Add a note…"
defaultValue="Everything right at your hands."
/>
</div>
</div>
</div>
</Section>
{/* TOGGLES */}
<Section label="Switches · checkboxes · radios">
<div className="three">
<div>
<div className="cap">Switch</div>
<div className="stack">
<Control control={<Switch defaultChecked />}>
Sync across devices
</Control>
<Control control={<Switch />}>Show hidden files</Control>
</div>
</div>
<div>
<div className="cap">Checkbox</div>
<div className="stack">
<Control control={<Checkbox defaultChecked />}>
Include subfolders
</Control>
<Control control={<Checkbox />}>Follow symlinks</Control>
</div>
</div>
<div>
<div className="cap">Radio</div>
<RadioGroup defaultValue="list" className="stack">
<Control control={<RadioItem value="list" />}>List view</Control>
<Control control={<RadioItem value="grid" />}>Grid view</Control>
</RadioGroup>
</div>
</div>
</Section>
{/* CONTROLS */}
<Section label="Segmented · slider · stepper · tabs · progress">
<div className="two">
<div className="stack">
<div>
<div className="cap">Segmented</div>
<SegmentedControl
value={seg}
onValueChange={setSeg}
items={[
{ value: 'icons', label: 'Icons' },
{ value: 'list', label: 'List' },
{ value: 'columns', label: 'Columns' },
{ value: 'gallery', label: 'Gallery' },
]}
/>
</div>
<div>
<div className="cap">Slider &amp; stepper</div>
<div className="cluster">
<Slider defaultValue={[62]} max={100} step={1} />
<Slider defaultValue={[40]} max={100} step={20} marks />
<Stepper
onDecrement={() => setCount((n) => Math.max(0, n - 1))}
onIncrement={() => setCount((n) => n + 1)}
/>
<span className="mta-mono" style={{ color: 'var(--fg-2)' }}>
{count}
</span>
</div>
</div>
</div>
<div className="stack">
<div>
<div className="cap">Tabs</div>
<Tabs value={tab} onValueChange={setTab}>
<TabsList
items={[
{ value: 'general', label: 'General' },
{ value: 'sharing', label: 'Sharing' },
{ value: 'tags', label: 'Tags' },
]}
/>
<TabsContent value={tab} />
</Tabs>
</div>
<div>
<div className="cap">Progress</div>
<div style={{ marginTop: 6 }}>
<Progress value={64} />
</div>
</div>
</div>
</div>
</Section>
{/* BADGES */}
<Section label="Badges · chips · tags">
<div className="stack">
<div className="cluster">
<Badge variant="lime">Synced</Badge>
<Badge variant="ember">3 conflicts</Badge>
<Badge variant="neutral">Draft</Badge>
<Badge variant="outline">v2.4</Badge>
<Badge variant="neutral" dot style={{ color: 'var(--lime)' }}>
Online
</Badge>
</div>
<div className="cluster">
{chips.map((c) => (
<Chip
key={c}
onRemove={() => setChips((cs) => cs.filter((x) => x !== c))}
>
{c}
</Chip>
))}
</div>
</div>
</Section>
{/* SURFACES */}
<Section label="Cards · list rows · menu">
<div className="three">
<Card>
<div style={{ fontSize: 24, color: 'var(--lime)' }}>
<Folder weight="fill" />
</div>
<div style={{ fontWeight: 600, marginTop: 10 }}>Projects</div>
<div
style={{ color: 'var(--fg-3)', fontSize: 13, marginTop: 2 }}
>
24 items · 1.2 GB
</div>
<div style={{ marginTop: 14 }}>
<Progress value={48} />
</div>
</Card>
<List>
<Row selected>
<Folder weight="fill" size={22} color="var(--lime)" />
<span className="nm">Projects</span>
<span className="meta">24</span>
</Row>
<Row>
<Folder weight="fill" size={22} color="var(--ember)" />
<span className="nm">Invoices</span>
<span className="meta">8</span>
</Row>
<Row>
<FilePdf weight="fill" size={22} color="var(--fg-2)" />
<span className="nm">Contract.pdf</span>
<span className="meta">2.4 MB</span>
</Row>
<Row>
<FileText weight="fill" size={22} color="var(--fg-2)" />
<span className="nm">notes.md</span>
<span className="meta">12 KB</span>
</Row>
</List>
<MenuSurface>
<MenuRow icon={<FolderOpen size={16} />} shortcut="⌘O">
Open
</MenuRow>
<MenuRow icon={<PencilSimple size={16} />} shortcut="⏎">
Rename
</MenuRow>
<MenuRow icon={<Copy size={16} />} shortcut="⌘D">
Duplicate
</MenuRow>
<MenuSeparator />
<MenuRow icon={<Tag size={16} />}>Add Tag</MenuRow>
<MenuRow icon={<Info size={16} />} shortcut="⌘I">
Get Info
</MenuRow>
</MenuSurface>
</div>
</Section>
{/* TOOLTIP */}
<Section label="Tooltip">
<Tooltip
content={
<>
<Info size={14} style={{ marginRight: 5 }} />
Tooltip appears over floating layers
</>
}
>
<Button variant="ghost" size="sm">
Hover for tooltip
</Button>
</Tooltip>
</Section>
{/* ICON BUTTONS · SPINNER */}
<Section label="Icon buttons · spinner">
<div className="cluster">
<IconButton variant="primary" aria-label="Add">
<Plus size={16} weight="bold" />
</IconButton>
<IconButton aria-label="Edit">
<PencilSimple size={15} />
</IconButton>
<IconButton variant="ember" aria-label="Delete">
<Trash size={15} />
</IconButton>
<IconButton variant="ghost" aria-label="More">
<DotsThree size={18} weight="bold" />
</IconButton>
<IconButton size="lg" aria-label="Open">
<FolderOpen size={18} />
</IconButton>
<span style={{ width: 16 }} />
<Spinner size="sm" />
<Spinner />
<Spinner size="lg" />
</div>
</Section>
{/* CALLOUTS */}
<Section label="Callouts">
<div className="stack">
<Callout variant="info" icon={<Info size={17} weight="fill" />}>
<strong>Heads up.</strong> Files sync automatically when youre
online no manual save needed.
</Callout>
<Callout variant="success" icon={<CheckCircle size={17} weight="fill" />}>
<strong>All set.</strong> Your 24 projects are backed up and
encrypted.
</Callout>
<Callout variant="warning" icon={<Warning size={17} weight="fill" />}>
<strong>Low space.</strong> 1.2 GB left on this device.
</Callout>
<Callout variant="danger" icon={<WarningOctagon size={17} weight="fill" />}>
<strong>3 conflicts.</strong> Some files changed in two places at
once.
</Callout>
</div>
</Section>
{/* TABLE */}
<Section label="Table">
<Table>
<THead>
<Tr>
<Th>Name</Th>
<Th>Owner</Th>
<Th>Status</Th>
<Th style={{ textAlign: 'right' }}>Size</Th>
</Tr>
</THead>
<TBody>
<Tr selected>
<Td>Quarterly Report.pdf</Td>
<Td className="muted">You</Td>
<Td>
<Badge variant="lime">Synced</Badge>
</Td>
<Td className="num">2.4 MB</Td>
</Tr>
<Tr>
<Td>Invoices</Td>
<Td className="muted">Mara K.</Td>
<Td>
<Badge variant="ember">3 conflicts</Badge>
</Td>
<Td className="num">812 KB</Td>
</Tr>
<Tr>
<Td>notes.md</Td>
<Td className="muted">You</Td>
<Td>
<Badge variant="neutral">Draft</Badge>
</Td>
<Td className="num">12 KB</Td>
</Tr>
</TBody>
</Table>
</Section>
{/* SCROLL AREA */}
<Section label="Scroll area">
<Card style={{ padding: 0, maxWidth: 320 }}>
<ScrollArea style={{ height: 160 }}>
<List style={{ border: 'none', boxShadow: 'none', borderRadius: 0 }}>
{Array.from({ length: 12 }).map((_, i) => (
<Row key={i}>
<FileText weight="fill" size={20} color="var(--fg-2)" />
<span className="nm">document-{i + 1}.txt</span>
<span className="meta">{(i + 1) * 7} KB</span>
</Row>
))}
</List>
</ScrollArea>
</Card>
</Section>
{/* DIALOGS */}
<Section label="Dialog · alert dialog">
<div className="cluster">
<Dialog
title="Rename file"
description="Choose a new name for this item."
trigger={<Button variant="primary">Open dialog</Button>}
footer={
<>
<DialogClose asChild>
<Button variant="ghost">Cancel</Button>
</DialogClose>
<DialogClose asChild>
<Button variant="primary">Save</Button>
</DialogClose>
</>
}
>
<div className="lab">Name</div>
<TextField defaultValue="Quarterly Report.pdf" autoFocus />
</Dialog>
<AlertDialog
title="Delete 3 files?"
description="This permanently removes the selected files. This action cannot be undone."
cancelLabel="Keep files"
actionLabel="Delete"
destructive
trigger={
<Button variant="ember">
<Trash size={16} />
Delete
</Button>
}
/>
</div>
</Section>
</div>
</div>
);
};
export default App;
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+57
View File
@@ -0,0 +1,57 @@
import { type ReactNode } from 'react';
import { AlertDialog as RAlertDialog } from 'radix-ui';
import { Button } from '../button';
export const AlertDialog = ({
trigger,
title,
description,
cancelLabel = 'Cancel',
actionLabel = 'Confirm',
destructive,
onAction,
open,
defaultOpen,
onOpenChange,
}: {
trigger?: ReactNode;
title: string;
description?: ReactNode;
cancelLabel?: string;
actionLabel?: string;
destructive?: boolean;
onAction?: () => void;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (o: boolean) => void;
}) => (
<RAlertDialog.Root open={open} defaultOpen={defaultOpen} onOpenChange={onOpenChange}>
{trigger && <RAlertDialog.Trigger asChild>{trigger}</RAlertDialog.Trigger>}
<RAlertDialog.Portal>
<RAlertDialog.Overlay className="mta-overlay" />
<RAlertDialog.Content className="mta-dialog">
<RAlertDialog.Title className="mta-dialog__title">
{title}
</RAlertDialog.Title>
{description && (
<RAlertDialog.Description className="mta-dialog__desc">
{description}
</RAlertDialog.Description>
)}
<div className="mta-dialog__footer">
<RAlertDialog.Cancel asChild>
<Button variant="ghost">{cancelLabel}</Button>
</RAlertDialog.Cancel>
<RAlertDialog.Action asChild>
<Button
variant={destructive ? 'ember' : 'primary'}
onClick={onAction}
>
{actionLabel}
</Button>
</RAlertDialog.Action>
</div>
</RAlertDialog.Content>
</RAlertDialog.Portal>
</RAlertDialog.Root>
);
+44
View File
@@ -0,0 +1,44 @@
import { type ComponentPropsWithoutRef, type ReactNode } from 'react';
import { cx } from '../utils';
type BadgeVariant = 'lime' | 'ember' | 'neutral' | 'outline';
export const Badge = ({
variant = 'neutral',
dot,
className,
children,
...props
}: ComponentPropsWithoutRef<'span'> & {
variant?: BadgeVariant;
dot?: boolean;
}) => (
<span
className={cx(
'mta-badge',
`mta-badge--${variant}`,
dot && 'mta-badge--dot',
className,
)}
{...props}
>
{children}
</span>
);
export const Chip = ({
children,
onRemove,
}: {
children: ReactNode;
onRemove?: () => void;
}) => (
<span className="mta-chip">
{children}
{onRemove && (
<button type="button" className="x" onClick={onRemove} aria-label="Remove">
×
</button>
)}
</span>
);
+27
View File
@@ -0,0 +1,27 @@
import { forwardRef, type ComponentPropsWithoutRef } from 'react';
import { cx } from '../utils';
export type BtnVariant = 'key' | 'primary' | 'ember' | 'ghost';
type ButtonProps = ComponentPropsWithoutRef<'button'> & {
variant?: BtnVariant;
size?: 'sm';
iconOnly?: boolean;
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ variant = 'key', size, iconOnly, className, ...props }, ref) => (
<button
ref={ref}
className={cx(
'mta-btn',
variant !== 'key' && `mta-btn--${variant}`,
size === 'sm' && 'mta-btn--sm',
iconOnly && 'mta-btn--icon',
className,
)}
{...props}
/>
),
);
Button.displayName = 'Button';
+19
View File
@@ -0,0 +1,19 @@
import { type ReactNode } from 'react';
import { cx } from '../utils';
type CalloutVariant = 'info' | 'success' | 'warning' | 'danger';
export const Callout = ({
variant = 'info',
icon,
children,
}: {
variant?: CalloutVariant;
icon?: ReactNode;
children: ReactNode;
}) => (
<div className={cx('mta-callout', variant !== 'info' && `mta-callout--${variant}`)}>
{icon && <span className="mta-callout__icon">{icon}</span>}
<div className="mta-callout__body">{children}</div>
</div>
);
+6
View File
@@ -0,0 +1,6 @@
import { type ComponentPropsWithoutRef } from 'react';
import { cx } from '../utils';
export const Card = ({ className, ...props }: ComponentPropsWithoutRef<'div'>) => (
<div className={cx('mta-card', className)} {...props} />
);
+55
View File
@@ -0,0 +1,55 @@
import { type ReactNode } from 'react';
import { Dialog as RDialog } from 'radix-ui';
import { X } from '@phosphor-icons/react';
import { IconButton } from '../icon-button';
export const Dialog = ({
trigger,
title,
description,
children,
footer,
open,
defaultOpen,
onOpenChange,
modal,
}: {
trigger?: ReactNode;
title: string;
description?: ReactNode;
children?: ReactNode;
footer?: ReactNode;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (o: boolean) => void;
modal?: boolean;
}) => (
<RDialog.Root open={open} defaultOpen={defaultOpen} onOpenChange={onOpenChange} modal={modal}>
{trigger && <RDialog.Trigger asChild>{trigger}</RDialog.Trigger>}
<RDialog.Portal>
<RDialog.Overlay className="mta-overlay" />
<RDialog.Content className="mta-dialog">
<RDialog.Title className="mta-dialog__title">{title}</RDialog.Title>
{description && (
<RDialog.Description className="mta-dialog__desc">
{description}
</RDialog.Description>
)}
{children && <div className="mta-dialog__body">{children}</div>}
{footer && <div className="mta-dialog__footer">{footer}</div>}
<RDialog.Close asChild>
<IconButton
variant="ghost"
size="sm"
className="mta-dialog__close"
aria-label="Close"
>
<X size={14} weight="bold" />
</IconButton>
</RDialog.Close>
</RDialog.Content>
</RDialog.Portal>
</RDialog.Root>
);
export const DialogClose = RDialog.Close;
+25
View File
@@ -0,0 +1,25 @@
import { forwardRef, type ComponentPropsWithoutRef } from 'react';
import { cx } from '../utils';
import type { BtnVariant } from '../button';
type IconButtonProps = ComponentPropsWithoutRef<'button'> & {
variant?: BtnVariant;
size?: 'sm' | 'lg';
};
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
({ variant = 'key', size, className, ...props }, ref) => (
<button
ref={ref}
className={cx(
'mta-btn',
'mta-iconbtn',
variant !== 'key' && `mta-btn--${variant}`,
size && `mta-iconbtn--${size}`,
className,
)}
{...props}
/>
),
);
IconButton.displayName = 'IconButton';
+17
View File
@@ -0,0 +1,17 @@
import { type ComponentPropsWithoutRef } from 'react';
import { cx } from '../utils';
export const List = ({ className, ...props }: ComponentPropsWithoutRef<'div'>) => (
<div className={cx('mta-list', className)} {...props} />
);
export const Row = ({
selected,
className,
...props
}: ComponentPropsWithoutRef<'div'> & { selected?: boolean }) => (
<div
className={cx('mta-row', selected && 'is-selected', className)}
{...props}
/>
);
+56
View File
@@ -0,0 +1,56 @@
import { type ComponentPropsWithoutRef, type ReactNode } from 'react';
import { DropdownMenu as RMenu } from 'radix-ui';
export const Menu = RMenu.Root;
export const MenuTrigger = RMenu.Trigger;
export const MenuContent = ({
children,
...props
}: ComponentPropsWithoutRef<typeof RMenu.Content>) => (
<RMenu.Portal>
<RMenu.Content className="mta-menu" sideOffset={6} {...props}>
{children}
</RMenu.Content>
</RMenu.Portal>
);
export const MenuItem = ({
icon,
shortcut,
children,
...props
}: ComponentPropsWithoutRef<typeof RMenu.Item> & {
icon?: ReactNode;
shortcut?: string;
}) => (
<RMenu.Item className="mta-menu-item" {...props}>
{icon && <span className="ph">{icon}</span>}
{children}
{shortcut && <span className="sc">{shortcut}</span>}
</RMenu.Item>
);
export const MenuSeparator = () => (
<RMenu.Separator className="mta-menu-sep" />
);
export const MenuSurface = ({ children }: { children: ReactNode }) => (
<div className="mta-menu">{children}</div>
);
export const MenuRow = ({
icon,
shortcut,
children,
}: {
icon?: ReactNode;
shortcut?: string;
children: ReactNode;
}) => (
<div className="mta-menu-item">
{icon && <span className="ph">{icon}</span>}
{children}
{shortcut && <span className="sc">{shortcut}</span>}
</div>
);
+16
View File
@@ -0,0 +1,16 @@
import { type ComponentPropsWithoutRef } from 'react';
import { Progress as RProgress } from 'radix-ui';
import { cx } from '../utils';
export const Progress = ({
value = 0,
className,
...props
}: ComponentPropsWithoutRef<typeof RProgress.Root>) => (
<RProgress.Root className={cx('mta-progress', className)} value={value} {...props}>
<RProgress.Indicator
className="mta-progress__indicator"
style={{ width: `${value}%` }}
/>
</RProgress.Root>
);
+22
View File
@@ -0,0 +1,22 @@
import { type ComponentPropsWithoutRef } from 'react';
import { ScrollArea as RScrollArea } from 'radix-ui';
import { cx } from '../utils';
export const ScrollArea = ({
children,
className,
...props
}: ComponentPropsWithoutRef<typeof RScrollArea.Root>) => (
<RScrollArea.Root className={cx('mta-scroll', className)} {...props}>
<RScrollArea.Viewport className="mta-scroll__viewport">
{children}
</RScrollArea.Viewport>
<RScrollArea.Scrollbar className="mta-scroll__bar" orientation="vertical">
<RScrollArea.Thumb className="mta-scroll__thumb" />
</RScrollArea.Scrollbar>
<RScrollArea.Scrollbar className="mta-scroll__bar" orientation="horizontal">
<RScrollArea.Thumb className="mta-scroll__thumb" />
</RScrollArea.Scrollbar>
<RScrollArea.Corner />
</RScrollArea.Root>
);
@@ -0,0 +1,66 @@
import { type ComponentPropsWithoutRef, useEffect, useRef } from 'react';
import { ToggleGroup as RToggleGroup } from 'radix-ui';
import { cx } from '../utils';
type SegProps = Omit<
ComponentPropsWithoutRef<typeof RToggleGroup.Root>,
'type' | 'onValueChange' | 'defaultValue' | 'value'
> & {
value: string;
defaultValue?: string;
onValueChange: (v: string) => void;
items: Array<{ value: string; label: string }>;
};
export const SegmentedControl = ({
value,
onValueChange,
items,
className,
...props
}: SegProps) => {
const rootRef = useRef<HTMLDivElement>(null);
const thumbRef = useRef<HTMLSpanElement>(null);
const initialized = useRef(false);
useEffect(() => {
const root = rootRef.current;
const thumb = thumbRef.current;
if (!root || !thumb) return;
const selected = root.querySelector<HTMLElement>('[data-state="on"]');
if (!selected) return;
if (!initialized.current) {
thumb.style.transition = 'none';
}
thumb.style.transform = `translateX(${selected.offsetLeft}px)`;
thumb.style.width = `${selected.offsetWidth}px`;
if (!initialized.current) {
thumb.getBoundingClientRect();
thumb.style.transition = '';
initialized.current = true;
}
}, [value]);
return (
<RToggleGroup.Root
ref={rootRef}
type="single"
className={cx('mta-seg', className)}
value={value}
onValueChange={(v) => v && onValueChange(v)}
{...props}
>
<span ref={thumbRef} className="mta-seg__thumb" aria-hidden />
{items.map((it) => (
<RToggleGroup.Item
key={it.value}
value={it.value}
className="mta-seg__item"
>
{it.label}
</RToggleGroup.Item>
))}
</RToggleGroup.Root>
);
};
+42
View File
@@ -0,0 +1,42 @@
import { type ComponentPropsWithoutRef } from 'react';
import { Select as RSelect } from 'radix-ui';
import { Check, CaretDown } from '@phosphor-icons/react';
type SelectProps = ComponentPropsWithoutRef<typeof RSelect.Root> & {
placeholder?: string;
items: Array<{ value: string; label: string }>;
'aria-label'?: string;
};
export const Select = ({ placeholder, items, ...rest }: SelectProps) => (
<RSelect.Root {...rest}>
<RSelect.Trigger className="mta-select" aria-label={rest['aria-label']}>
<RSelect.Value placeholder={placeholder} />
<RSelect.Icon className="mta-select__icon">
<CaretDown size={12} weight="bold" />
</RSelect.Icon>
</RSelect.Trigger>
<RSelect.Portal>
<RSelect.Content
className="mta-select__content"
position="popper"
sideOffset={6}
>
<RSelect.Viewport>
{items.map((it) => (
<RSelect.Item
key={it.value}
value={it.value}
className="mta-select__item"
>
<RSelect.ItemText>{it.label}</RSelect.ItemText>
<RSelect.ItemIndicator className="mta-select__item-indicator">
<Check size={14} weight="bold" />
</RSelect.ItemIndicator>
</RSelect.Item>
))}
</RSelect.Viewport>
</RSelect.Content>
</RSelect.Portal>
</RSelect.Root>
);
+53
View File
@@ -0,0 +1,53 @@
import { type ComponentPropsWithoutRef, type ReactNode } from 'react';
import {
Switch as RSwitch,
Checkbox as RCheckbox,
RadioGroup as RRadioGroup,
} from 'radix-ui';
export const Switch = (props: ComponentPropsWithoutRef<typeof RSwitch.Root>) => (
<RSwitch.Root className="mta-switch" {...props}>
<RSwitch.Thumb className="mta-switch__thumb" />
</RSwitch.Root>
);
export const Checkbox = (props: ComponentPropsWithoutRef<typeof RCheckbox.Root>) => (
<RCheckbox.Root className="mta-check" {...props}>
<RCheckbox.Indicator className="mta-check__indicator">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="var(--lime-ink)"
strokeWidth={3.5}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M4 12l5 5L20 6" />
</svg>
</RCheckbox.Indicator>
</RCheckbox.Root>
);
export const RadioGroup = RRadioGroup.Root;
export const RadioItem = ({
value,
...props
}: ComponentPropsWithoutRef<typeof RRadioGroup.Item>) => (
<RRadioGroup.Item className="mta-radio" value={value} {...props}>
<RRadioGroup.Indicator className="mta-radio__indicator" />
</RRadioGroup.Item>
);
export const Control = ({
children,
control,
}: {
children: ReactNode;
control: ReactNode;
}) => (
<label className="mta-control">
{control}
{children}
</label>
);
+150
View File
@@ -0,0 +1,150 @@
import { type ComponentPropsWithoutRef, type CSSProperties } from 'react';
import { Slider as RSlider } from 'radix-ui';
type Mark = { value: number; label?: string };
type MarksProp = boolean | Array<number | Mark>;
type NotchPlacement = 'top' | 'bottom' | 'both' | 'none';
type KnobStyle = 'square' | 'round';
type SliderProps = Omit<ComponentPropsWithoutRef<typeof RSlider.Root>, 'className'> & {
/**
* Step marks.
* - `true` — auto-generate one mark per `step` between `min` and `max`.
* - array — explicit marks; numbers or `{ value, label }` for tick labels.
*/
marks?: MarksProp;
/**
* Where to draw the notch ticks relative to the track.
* `'bottom'` (default), `'top'`, `'both'`, or `'none'` to hide ticks
* (labels still render when provided). No effect without `marks`.
*/
notches?: NotchPlacement;
/** Thumb shape. `'square'` (default) has a small border-radius; `'round'` is a full circle. */
knobStyle?: KnobStyle;
/**
* Enable step-glide animation. Defaults to `true` when `marks` is set, `false` otherwise.
* Explicitly setting this always overrides the default.
*/
animated?: boolean;
className?: string;
};
function resolveMarks(
marks: MarksProp,
min: number,
max: number,
step: number,
): Mark[] {
if (Array.isArray(marks)) {
return marks
.map((m) => (typeof m === 'number' ? { value: m } : m))
.filter((m) => m.value >= min && m.value <= max);
}
if (marks !== true) return [];
if (!(step > 0) || max <= min) return [];
const count = Math.floor((max - min) / step);
// Guard against absurd notch counts (e.g. step=1 over a 01000 range).
if (count < 1 || count > 100) return [];
return Array.from({ length: count + 1 }, (_, i) => ({ value: min + i * step }));
}
const percent = (value: number, min: number, max: number) =>
max === min ? 0 : (value - min) / (max - min);
const NotchLayer = ({
marks,
min,
max,
side,
}: {
marks: Mark[];
min: number;
max: number;
side: 'top' | 'bottom';
}) => (
<div className={`mta-slider__notches mta-slider__notches--${side}`} aria-hidden>
{marks.map((mark) => (
<span
key={mark.value}
className="mta-slider__notch"
style={{ '--p': percent(mark.value, min, max) } as CSSProperties}
/>
))}
</div>
);
export const Slider = ({
marks,
notches = 'bottom',
knobStyle = 'square',
animated,
min = 0,
max = 100,
step = 1,
className,
...props
}: SliderProps) => {
const resolved = marks != null ? resolveMarks(marks, min, max, step) : [];
const hasMarks = resolved.length > 0;
const hasLabels = resolved.some((m) => m.label != null);
const showTop = hasMarks && (notches === 'top' || notches === 'both');
const showBottom = hasMarks && (notches === 'bottom' || notches === 'both');
const isAnimated = animated !== undefined ? animated : hasMarks;
const cls = [
'mta-slider',
`mta-slider--knob-${knobStyle}`,
isAnimated && 'mta-slider--animated',
hasMarks && 'mta-slider--has-marks',
hasLabels && 'mta-slider--has-labels',
showTop && 'mta-slider--notch-top',
showBottom && 'mta-slider--notch-bottom',
className,
]
.filter(Boolean)
.join(' ');
return (
<RSlider.Root className={cls} min={min} max={max} step={step} {...props}>
<RSlider.Track className="mta-slider__track">
<RSlider.Range className="mta-slider__range" />
{showTop && <NotchLayer marks={resolved} min={min} max={max} side="top" />}
{showBottom && <NotchLayer marks={resolved} min={min} max={max} side="bottom" />}
{hasLabels && (
<div className="mta-slider__labels" aria-hidden>
{resolved.map((mark) =>
mark.label != null ? (
<span
key={mark.value}
className="mta-slider__label"
style={{ '--p': percent(mark.value, min, max) } as CSSProperties}
>
{mark.label}
</span>
) : null,
)}
</div>
)}
</RSlider.Track>
<RSlider.Thumb className="mta-slider__thumb" aria-label="Value" />
</RSlider.Root>
);
};
export const Stepper = ({
onDecrement,
onIncrement,
}: {
onDecrement: () => void;
onIncrement: () => void;
}) => (
<div className="mta-stepper">
<button type="button" onClick={onDecrement} aria-label="Decrease">
</button>
<button type="button" onClick={onIncrement} aria-label="Increase">
+
</button>
</div>
);
+37
View File
@@ -0,0 +1,37 @@
import { type ComponentPropsWithoutRef } from 'react';
import { cx } from '../utils';
export const Spinner = ({
size,
className,
...props
}: ComponentPropsWithoutRef<'span'> & { size?: 'sm' | 'lg' }) => {
return (
<span
role="status"
aria-label="Loading"
className={cx('mta-spinner', size && `mta-spinner--${size}`, className)}
{...props}
>
<svg viewBox="0 0 36 36" fill="none">
<circle
cx="18"
cy="18"
r="14"
stroke="var(--color-bg-muted)"
strokeWidth="4"
/>
<circle
className="mta-spinner__arc"
cx="18"
cy="18"
r="14"
stroke="var(--color-accent)"
strokeWidth="4"
strokeLinecap="round"
strokeDasharray="22 88"
/>
</svg>
</span>
);
};
+24
View File
@@ -0,0 +1,24 @@
import { type ComponentPropsWithoutRef } from 'react';
import { cx } from '../utils';
export const Table = ({ children, ...props }: ComponentPropsWithoutRef<'table'>) => (
<div className="mta-table-wrap">
<table className="mta-table" {...props}>
{children}
</table>
</div>
);
export const THead = (p: ComponentPropsWithoutRef<'thead'>) => <thead {...p} />;
export const TBody = (p: ComponentPropsWithoutRef<'tbody'>) => <tbody {...p} />;
export const Tr = ({
selected,
className,
...props
}: ComponentPropsWithoutRef<'tr'> & { selected?: boolean }) => (
<tr className={cx(selected && 'is-selected', className)} {...props} />
);
export const Th = (p: ComponentPropsWithoutRef<'th'>) => <th {...p} />;
export const Td = (p: ComponentPropsWithoutRef<'td'>) => <td {...p} />;
+28
View File
@@ -0,0 +1,28 @@
import { type ComponentPropsWithoutRef } from 'react';
import { Tabs as RTabs } from 'radix-ui';
import { cx } from '../utils';
export const Tabs = RTabs.Root;
export const TabsList = ({
items,
className,
...props
}: { items: Array<{ value: string; label: string }> } & Omit<
ComponentPropsWithoutRef<typeof RTabs.List>,
'children'
>) => (
<RTabs.List className={cx('mta-tabs', className)} {...props}>
{items.map((it) => (
<RTabs.Trigger
key={it.value}
value={it.value}
className="mta-tabs__trigger"
>
{it.label}
</RTabs.Trigger>
))}
</RTabs.List>
);
export const TabsContent = RTabs.Content;
+278
View File
@@ -0,0 +1,278 @@
import {
forwardRef,
useCallback,
useId,
useLayoutEffect,
useRef,
useState,
type ComponentPropsWithoutRef,
type ReactNode,
type Ref,
} from 'react';
import { cx } from '../utils';
/* ------------------------------------------------------------------ *
* Typing animation (osu!-lazer style)
*
* Native inputs draw their own text, so individual letters can't be
* animated. Instead the real field renders transparent (caret stays
* visible) and a mirrored per-character <span> overlay sits behind it:
* newly typed letters rise + fade in, erased letters fall + fade out.
* ------------------------------------------------------------------ */
type FieldElement = HTMLInputElement | HTMLTextAreaElement;
interface CharEntry {
id: number;
char: string;
leaving: boolean;
x?: number;
y?: number;
}
/** Longest-common-subsequence match so unchanged letters keep their id
* (and thus don't replay the appear animation on every keystroke). */
function diffChars(prev: ReadonlyArray<{ id: number; char: string }>, next: string) {
const n = prev.length;
const m = next.length;
const dp: number[][] = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
for (let i = n - 1; i >= 0; i--) {
for (let j = m - 1; j >= 0; j--) {
dp[i][j] =
prev[i].char === next[j]
? dp[i + 1][j + 1] + 1
: Math.max(dp[i + 1][j], dp[i][j + 1]);
}
}
const reusedId: Array<number | null> = new Array(m).fill(null);
const keptPrev = new Array(n).fill(false);
let i = 0;
let j = 0;
while (i < n && j < m) {
if (prev[i].char === next[j]) {
reusedId[j] = prev[i].id;
keptPrev[i] = true;
i++;
j++;
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
i++;
} else {
j++;
}
}
return { reusedId, keptPrev };
}
function useFieldAnimation(
multiline: boolean,
externalRef: Ref<FieldElement>,
controlledValue: ComponentPropsWithoutRef<'input'>['value'],
initial: ComponentPropsWithoutRef<'input'>['defaultValue'],
) {
const innerRef = useRef<FieldElement | null>(null);
const overlayRef = useRef<HTMLDivElement | null>(null);
const spanRefs = useRef<Map<number, HTMLSpanElement>>(new Map());
const present = useRef<Array<{ id: number; char: string }>>([]);
const nextId = useRef(0);
const idPrefix = useId();
const [text, setText] = useState(() => String(controlledValue ?? initial ?? ''));
const [entries, setEntries] = useState<CharEntry[]>([]);
const setRef = useCallback(
(node: FieldElement | null) => {
innerRef.current = node;
if (typeof externalRef === 'function') externalRef(node);
else if (externalRef) (externalRef as { current: FieldElement | null }).current = node;
},
[externalRef],
);
const syncScroll = useCallback(() => {
const el = innerRef.current;
const ov = overlayRef.current;
if (el && ov) ov.style.transform = `translate(${-el.scrollLeft}px, ${-el.scrollTop}px)`;
}, []);
// Reconcile the overlay whenever the text changes.
useLayoutEffect(() => {
const prev = present.current;
const { reusedId, keptPrev } = diffChars(prev, text);
const nextPresent: Array<{ id: number; char: string }> = [];
for (let k = 0; k < text.length; k++) {
const id = reusedId[k] ?? nextId.current++;
nextPresent.push({ id, char: text[k] });
}
// Letters that were removed fall away — pin them where they last sat.
const leaving: CharEntry[] = [];
for (let k = 0; k < prev.length; k++) {
if (keptPrev[k]) continue;
const el = spanRefs.current.get(prev[k].id);
if (el) {
leaving.push({
id: prev[k].id,
char: prev[k].char,
leaving: true,
x: el.offsetLeft,
y: el.offsetTop,
});
}
}
present.current = nextPresent;
setEntries((current) => [
...nextPresent.map((e) => ({ ...e, leaving: false })),
...current.filter((e) => e.leaving),
...leaving,
]);
syncScroll();
}, [text, syncScroll]);
// Stay in sync when used as a controlled component.
useLayoutEffect(() => {
if (controlledValue !== undefined) setText(String(controlledValue));
}, [controlledValue]);
const handleChange = useCallback((value: string) => setText(value), []);
const onLeaveEnd = useCallback((id: number) => {
spanRefs.current.delete(id);
setEntries((current) => current.filter((e) => e.id !== id));
}, []);
const overlay = (
<div
ref={overlayRef}
aria-hidden="true"
className={cx('mta-field-overlay', multiline && 'mta-field-overlay--multiline')}
>
{entries.map((e) =>
e.leaving ? (
<span
key={`${idPrefix}-${e.id}`}
className="mta-field-char mta-field-char--leaving"
style={{ left: e.x, top: e.y }}
onAnimationEnd={() => onLeaveEnd(e.id)}
>
{e.char}
</span>
) : (
<span
key={`${idPrefix}-${e.id}`}
ref={(node) => {
if (node) spanRefs.current.set(e.id, node);
else spanRefs.current.delete(e.id);
}}
className={cx('mta-field-char', !multiline && 'mta-field-char--composited')}
>
{e.char}
</span>
),
)}
</div>
);
return { setRef, overlay, handleChange, syncScroll };
}
type TextFieldProps = ComponentPropsWithoutRef<'input'> & { animated?: boolean };
type TextAreaProps = ComponentPropsWithoutRef<'textarea'> & { animated?: boolean };
export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
({ className, style, onChange, onScroll, animated = true, ...props }, ref) => {
const { setRef, overlay, handleChange, syncScroll } = useFieldAnimation(
false,
ref,
props.value,
props.defaultValue,
);
if (!animated) {
return (
<input
ref={ref}
className={cx('mta-field', className)}
style={style}
onChange={onChange}
onScroll={onScroll}
{...props}
/>
);
}
return (
<div className={cx('mta-field-wrap', className)} style={style}>
<input
ref={setRef}
className="mta-field mta-field--animated"
onChange={(e) => {
handleChange(e.currentTarget.value);
onChange?.(e);
}}
onScroll={(e) => {
syncScroll();
onScroll?.(e);
}}
{...props}
/>
{overlay}
</div>
);
},
);
TextField.displayName = 'TextField';
export const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(
({ className, style, onChange, onScroll, animated = true, ...props }, ref) => {
const { setRef, overlay, handleChange, syncScroll } = useFieldAnimation(
true,
ref,
props.value,
props.defaultValue,
);
if (!animated) {
return (
<textarea
ref={ref}
className={cx('mta-field', className)}
style={style}
onChange={onChange}
onScroll={onScroll}
{...props}
/>
);
}
return (
<div
className={cx('mta-field-wrap', 'mta-field-wrap--multiline', className)}
style={style}
>
<textarea
ref={setRef}
className="mta-field mta-field--animated"
onChange={(e) => {
handleChange(e.currentTarget.value);
onChange?.(e);
}}
onScroll={(e) => {
syncScroll();
onScroll?.(e);
}}
{...props}
/>
{overlay}
</div>
);
},
);
TextArea.displayName = 'TextArea';
export const SearchField = ({
icon,
...props
}: TextFieldProps & { icon: ReactNode }) => (
<div className="mta-search">
<span className="ph">{icon}</span>
<TextField {...props} />
</div>
);
+35
View File
@@ -0,0 +1,35 @@
import {
createContext,
useContext,
useEffect,
useState,
type ReactNode,
} from 'react';
type ThemeMode = 'dark' | 'light';
const KEY = 'mta-theme';
const ThemeContext = createContext<{
theme: ThemeMode;
setTheme: (t: ThemeMode) => void;
}>({ theme: 'light', setTheme: () => {} });
export const useTheme = () => useContext(ThemeContext);
export const ThemeProvider = ({ children }: { children: ReactNode }) => {
const [theme, setTheme] = useState<ThemeMode>(() => {
if (typeof localStorage === 'undefined') return 'light';
return (localStorage.getItem(KEY) as ThemeMode) || 'light';
});
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem(KEY, theme);
}, [theme]);
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
);
};
+42
View File
@@ -0,0 +1,42 @@
import { type ComponentPropsWithoutRef, type ReactNode } from 'react';
import { Tooltip as RTooltip } from 'radix-ui';
import { cx } from '../utils';
type TooltipProps = {
content: ReactNode;
children: ReactNode;
delayDuration?: number;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (o: boolean) => void;
} & Omit<ComponentPropsWithoutRef<typeof RTooltip.Content>, 'children' | 'content'>;
export const Tooltip = ({
content,
children,
delayDuration,
open,
defaultOpen,
onOpenChange,
sideOffset = 6,
className,
...contentProps
}: TooltipProps) => (
<RTooltip.Root
delayDuration={delayDuration}
open={open}
defaultOpen={defaultOpen}
onOpenChange={onOpenChange}
>
<RTooltip.Trigger asChild>{children}</RTooltip.Trigger>
<RTooltip.Portal>
<RTooltip.Content
className={cx('mta-tooltip', className)}
sideOffset={sideOffset}
{...contentProps}
>
{content}
</RTooltip.Content>
</RTooltip.Portal>
</RTooltip.Root>
);
+20
View File
@@ -0,0 +1,20 @@
export * from './button';
export * from './icon-button';
export * from './text-field';
export * from './select';
export * from './selection';
export * from './segmented-control';
export * from './slider';
export * from './tabs';
export * from './progress';
export * from './badge';
export * from './card';
export * from './list';
export * from './menu';
export * from './tooltip';
export * from './spinner';
export * from './callout';
export * from './table';
export * from './scroll-area';
export * from './dialog';
export * from './alert-dialog';
+2
View File
@@ -0,0 +1,2 @@
export const cx = (...c: Array<string | false | undefined>) =>
c.filter(Boolean).join(' ');
+11
View File
@@ -0,0 +1,11 @@
/// <reference types="@rsbuild/core/types" />
/**
* Imports the SVG file as a React component.
* @requires [@rsbuild/plugin-svgr](https://npmjs.com/package/@rsbuild/plugin-svgr)
*/
declare module '*.svg?react' {
import type React from 'react';
const ReactComponent: React.FunctionComponent<React.SVGProps<SVGSVGElement>>;
export default ReactComponent;
}
+13
View File
@@ -0,0 +1,13 @@
/* ============================================================
MtAir — public package entry.
Import components from here; import the stylesheet once at your
app root: import '@olly/mt-air/styles.css';
Optionally add the branded fonts: import '@olly/mt-air/fonts.css';
============================================================ */
import { Tooltip } from 'radix-ui';
export * from './components/ui';
export { ThemeProvider, useTheme } from './components/theme';
/* Tooltips need a single provider near your app root. */
export const TooltipProvider = Tooltip.Provider;
+20
View File
@@ -0,0 +1,20 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { Tooltip } from 'radix-ui';
import App from './App';
import { ThemeProvider } from './components/theme';
import './styles/global.css';
const rootEl = document.getElementById('root');
if (rootEl) {
const root = ReactDOM.createRoot(rootEl);
root.render(
<React.StrictMode>
<ThemeProvider>
<Tooltip.Provider delayDuration={200}>
<App />
</Tooltip.Provider>
</ThemeProvider>
</React.StrictMode>,
);
}
+64
View File
@@ -0,0 +1,64 @@
import type { Meta, StoryObj } from 'storybook-react-rsbuild';
import { AlertDialog, Button } from '../components/ui';
const meta = {
title: 'Overlays/AlertDialog',
component: AlertDialog,
parameters: {
docs: {
description: {
component:
'Confirmation dialog built on Radix AlertDialog. Use for destructive or irreversible actions — focus is trapped and the cancel button is always reachable. Set `destructive` to switch the action button to the ember (red) variant.',
},
},
},
argTypes: {
title: { control: 'text' },
description: { control: 'text' },
cancelLabel: { control: 'text' },
actionLabel: { control: 'text' },
destructive: { control: 'boolean' },
trigger: { control: false },
},
args: {
title: 'Delete this project?',
description: 'This action cannot be undone.',
actionLabel: 'Delete',
cancelLabel: 'Cancel',
destructive: true,
trigger: <Button variant="ember">Delete</Button>,
onAction: () => {},
},
} satisfies Meta<typeof AlertDialog>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {};
export const Destructive: Story = {
render: () => (
<AlertDialog
trigger={<Button variant="ember">Delete account</Button>}
title="Delete your account?"
description="All data will be permanently removed. This cannot be undone."
actionLabel="Delete account"
cancelLabel="Keep account"
destructive
onAction={() => {}}
/>
),
};
export const Confirm: Story = {
render: () => (
<AlertDialog
trigger={<Button variant="primary">Publish</Button>}
title="Publish changes?"
description="This will make your changes visible to all users."
actionLabel="Publish"
cancelLabel="Cancel"
onAction={() => {}}
/>
),
};
+63
View File
@@ -0,0 +1,63 @@
import type { Meta, StoryObj } from 'storybook-react-rsbuild';
import { Button } from '../components/ui';
const meta = {
title: 'Inputs/Button',
component: Button,
parameters: {
docs: {
description: {
component:
'Tactile push button. Four variants and an optional small size; everything else is a native `<button>`, so all standard button props pass through.',
},
},
},
argTypes: {
variant: {
control: 'inline-radio',
options: ['key', 'primary', 'ember', 'ghost'],
description: 'Visual emphasis. `key` is the default neutral button.',
},
size: {
control: 'inline-radio',
options: [undefined, 'sm'],
description: 'Omit for default; `sm` for the compact size.',
},
iconOnly: { control: 'boolean', description: 'Square padding for a single glyph.' },
disabled: { control: 'boolean' },
children: { control: 'text' },
className: { control: 'text' },
},
args: { children: 'Button', variant: 'key' },
} satisfies Meta<typeof Button>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {};
export const Variants: Story = {
render: () => (
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
<Button variant="key">Key</Button>
<Button variant="primary">Primary</Button>
<Button variant="ember">Ember</Button>
<Button variant="ghost">Ghost</Button>
</div>
),
};
export const Sizes: Story = {
render: () => (
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
<Button variant="primary">Default</Button>
<Button variant="primary" size="sm">
Small
</Button>
</div>
),
};
export const Disabled: Story = {
args: { disabled: true, variant: 'primary', children: 'Disabled' },
};
+101
View File
@@ -0,0 +1,101 @@
import type { Meta, StoryObj } from 'storybook-react-rsbuild';
import {
Card,
List,
Row,
Table,
THead,
TBody,
Tr,
Th,
Td,
Badge,
Chip,
} from '../components/ui';
const meta = {
title: 'Data Display/Surfaces',
component: Card,
parameters: {
docs: {
description: {
component: 'Cards, selectable lists/rows, badges, chips, and the bordered table.',
},
},
},
argTypes: {
className: { control: 'text' },
},
} satisfies Meta<typeof Card>;
export default meta;
type Story = StoryObj<typeof meta>;
export const CardPlayground: Story = {
name: 'Card',
args: { children: 'Card content' },
render: (args) => (
<Card {...args} style={{ maxWidth: 320, padding: 20 }}>
<h3 className="mta-h3">Storage</h3>
<p className="mta-body">128 GB of 256 GB used.</p>
</Card>
),
};
export const ListRows: Story = {
render: () => (
<List style={{ width: 320 }}>
<Row selected>General</Row>
<Row>Appearance</Row>
<Row>Notifications</Row>
<Row>Privacy</Row>
</List>
),
};
export const BadgeShowcase: Story = {
render: () => (
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
<Badge variant="lime">Lime</Badge>
<Badge variant="ember">Ember</Badge>
<Badge variant="neutral">Neutral</Badge>
<Badge variant="outline">Outline</Badge>
<Badge variant="lime" dot>Online</Badge>
</div>
),
};
export const Chips: Story = {
render: () => (
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
<Chip>Design</Chip>
<Chip onRemove={() => {}}>Removable</Chip>
</div>
),
};
export const DataTable: Story = {
render: () => (
<Table>
<THead>
<Tr>
<Th>Device</Th>
<Th>Status</Th>
<Th>Battery</Th>
</Tr>
</THead>
<TBody>
<Tr selected>
<Td>MacBook Pro</Td>
<Td><Badge variant="lime" dot>Online</Badge></Td>
<Td>82%</Td>
</Tr>
<Tr>
<Td>iPhone 16</Td>
<Td><Badge variant="neutral">Idle</Badge></Td>
<Td>54%</Td>
</Tr>
</TBody>
</Table>
),
};
+54
View File
@@ -0,0 +1,54 @@
import type { Meta, StoryObj } from 'storybook-react-rsbuild';
import { Dialog, Button, TextField } from '../components/ui';
const meta = {
title: 'Overlays/Dialog',
component: Dialog,
parameters: {
docs: {
description: {
component:
'Modal dialog built on Radix Dialog. Pass a `trigger` to wire open/close automatically, or control it with `open` / `onOpenChange`. Title is required; description, body, and footer are optional slots.',
},
},
},
argTypes: {
title: { control: 'text' },
description: { control: 'text' },
trigger: { control: false },
children: { control: false },
footer: { control: false },
},
args: {
title: 'Rename project',
description: 'Choose a new name for this project.',
trigger: <Button variant="primary">Open dialog</Button>,
},
} satisfies Meta<typeof Dialog>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {};
export const WithBody: Story = {
name: 'With body',
render: () => (
<Dialog
trigger={<Button variant="primary">Open dialog</Button>}
title="Rename project"
description="Choose a new name for this project."
footer={<Button variant="primary">Save</Button>}
>
<TextField placeholder="Project name" defaultValue="My project" />
</Dialog>
),
};
export const NoDescription: Story = {
name: 'No description',
args: {
description: undefined,
title: 'Confirm action',
},
};
+90
View File
@@ -0,0 +1,90 @@
import { useState } from 'react';
import type { Meta, StoryObj } from 'storybook-react-rsbuild';
import { Info, CheckCircle, Warning, XCircle } from '@phosphor-icons/react';
import { Progress, Spinner, Callout, Badge, Chip, Button } from '../components/ui';
const meta = {
title: 'Feedback/Status',
component: Progress,
parameters: {
docs: {
description: {
component:
'Progress bar, spinner, callouts, badges and chips — the status + signalling family.',
},
},
},
argTypes: {
value: { control: { type: 'range', min: 0, max: 100, step: 1 } },
className: { control: 'text' },
},
args: { value: 40 },
} satisfies Meta<typeof Progress>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {
render: (args) => (
<div style={{ width: 320 }}>
<Progress {...args} />
</div>
),
};
function ProgressDemo() {
const [v, setV] = useState(40);
return (
<div style={{ width: 320, display: 'flex', flexDirection: 'column', gap: 12 }}>
<Progress value={v} />
<div style={{ display: 'flex', gap: 8 }}>
<Button size="sm" onClick={() => setV((x) => Math.max(0, x - 10))}>10</Button>
<Button size="sm" variant="primary" onClick={() => setV((x) => Math.min(100, x + 10))}>+10</Button>
</div>
</div>
);
}
export const ProgressBar: Story = { render: () => <ProgressDemo /> };
export const Spinners: Story = {
render: () => (
<div style={{ display: 'flex', gap: 16, alignItems: 'center' }}>
<Spinner size="sm" />
<Spinner />
<Spinner size="lg" />
</div>
),
};
export const Callouts: Story = {
render: () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, maxWidth: 460 }}>
<Callout variant="info" icon={<Info size={18} />}>Sync runs in the background.</Callout>
<Callout variant="success" icon={<CheckCircle size={18} />}>All changes saved.</Callout>
<Callout variant="warning" icon={<Warning size={18} />}>Storage is almost full.</Callout>
<Callout variant="danger" icon={<XCircle size={18} />}>Failed to reach the server.</Callout>
</div>
),
};
export const Badges: Story = {
render: () => (
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
<Badge variant="lime">Lime</Badge>
<Badge variant="ember">Ember</Badge>
<Badge variant="neutral">Neutral</Badge>
<Badge variant="outline">Outline</Badge>
<Badge variant="lime" dot>Online</Badge>
</div>
),
};
export const Chips: Story = {
render: () => (
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
<Chip>Design</Chip>
<Chip onRemove={() => {}}>Removable</Chip>
</div>
),
};
+57
View File
@@ -0,0 +1,57 @@
import type { Meta, StoryObj } from 'storybook-react-rsbuild';
import { Gear, Plus, Trash } from '@phosphor-icons/react';
import { IconButton } from '../components/ui';
const meta = {
title: 'Inputs/IconButton',
component: IconButton,
parameters: {
docs: {
description: {
component:
'Square button for a single icon. Shares the Button variants; sizes are `sm` / default / `lg`.',
},
},
},
argTypes: {
variant: {
control: 'inline-radio',
options: ['key', 'primary', 'ember', 'ghost'],
description: 'Visual emphasis. `key` is the default neutral button.',
},
size: {
control: 'inline-radio',
options: ['sm', undefined, 'lg'],
description: 'Button size: `sm` compact, default regular, `lg` large.',
},
disabled: { control: 'boolean' },
},
} satisfies Meta<typeof IconButton>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {
args: { variant: 'key', children: <Gear size={18} /> },
};
export const Variants: Story = {
render: () => (
<div style={{ display: 'flex', gap: 12 }}>
<IconButton variant="key" aria-label="settings"><Gear size={18} /></IconButton>
<IconButton variant="primary" aria-label="add"><Plus size={18} weight="bold" /></IconButton>
<IconButton variant="ember" aria-label="delete"><Trash size={18} /></IconButton>
<IconButton variant="ghost" aria-label="settings"><Gear size={18} /></IconButton>
</div>
),
};
export const Sizes: Story = {
render: () => (
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
<IconButton size="sm" variant="primary" aria-label="add"><Plus size={14} weight="bold" /></IconButton>
<IconButton variant="primary" aria-label="add"><Plus size={18} weight="bold" /></IconButton>
<IconButton size="lg" variant="primary" aria-label="add"><Plus size={22} weight="bold" /></IconButton>
</div>
),
};
+43
View File
@@ -0,0 +1,43 @@
import { Meta } from '@storybook/addon-docs/blocks';
<Meta title="Getting Started/Introduction" />
# MtAir
Calm, light-first React components built on [Radix](https://www.radix-ui.com/) primitives.
Simplicity, air, lightness — every interface should feel as open and uncluttered as a
deep breath.
This Storybook is the **development playground** — it is never published with the
package. Use it to browse every component, read its prop table (the **Docs** tab on
each story), and try props live in the **Controls** panel.
## Using the library in an app
```tsx
import '@olly/mt-air/styles.css'; // required — tokens + components
import '@olly/mt-air/fonts.css'; // optional — branded faces
import { ThemeProvider, TooltipProvider, Button } from '@olly/mt-air';
export function App() {
return (
<ThemeProvider>
<TooltipProvider delayDuration={200}>
<Button variant="primary">Click</Button>
</TooltipProvider>
</ThemeProvider>
);
}
```
## Theme
Use the **Theme** toggle in the toolbar above to flip every story between light and
dark. In an app the same lever is `data-theme` on `<html>`, managed by
`ThemeProvider` / `useTheme()`.
## Fonts
Components read the `--mta-font-sans` and `--mta-font-mono` tokens. Import
`@olly/mt-air/fonts.css` for the branded faces (Plus Jakarta Sans + Fira Code), or
override those tokens to map the components onto fonts your app already loads.
+102
View File
@@ -0,0 +1,102 @@
import type { Meta, StoryObj } from 'storybook-react-rsbuild';
import { Copy, Scissors, Trash, ArrowCounterClockwise } from '@phosphor-icons/react';
import {
Tooltip,
Menu,
MenuTrigger,
MenuContent,
MenuItem,
MenuSeparator,
Dialog,
AlertDialog,
Button,
} from '../components/ui';
const meta = {
title: 'Overlays/Floating',
component: Tooltip,
parameters: {
docs: {
description: {
component:
'Floating surfaces — Tooltip, dropdown Menu, Dialog and AlertDialog. All are Radix-backed and portal-rendered.',
},
},
},
argTypes: {
children: { control: false },
content: { control: 'text' },
delayDuration: { control: 'number' },
open: { control: 'boolean' },
defaultOpen: { control: 'boolean' },
onOpenChange: { action: 'open changed' },
sideOffset: { control: 'number' },
},
args: {
content: 'Tooltip text',
children: null,
delayDuration: 0,
},
} satisfies Meta<typeof Tooltip>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {
name: 'Tooltip Playground',
render: (args) => (
<Tooltip {...args}>
<Button variant="ghost">Hover me</Button>
</Tooltip>
),
};
export const TooltipStory: Story = {
name: 'Tooltip',
render: () => (
<Tooltip content="Saved to iCloud">
<Button variant="ghost">Hover me</Button>
</Tooltip>
),
};
export const DropdownMenu: Story = {
render: () => (
<Menu>
<MenuTrigger asChild>
<Button>Open menu</Button>
</MenuTrigger>
<MenuContent>
<MenuItem icon={<Copy size={16} />} shortcut="⌘C">Copy</MenuItem>
<MenuItem icon={<Scissors size={16} />} shortcut="⌘X">Cut</MenuItem>
<MenuItem icon={<ArrowCounterClockwise size={16} />} shortcut="⌘Z">Undo</MenuItem>
<MenuSeparator />
<MenuItem icon={<Trash size={16} />}>Delete</MenuItem>
</MenuContent>
</Menu>
),
};
export const ModalDialog: Story = {
render: () => (
<Dialog
trigger={<Button variant="primary">Open dialog</Button>}
title="Rename project"
description="Choose a new name for this project."
footer={<Button variant="primary">Save</Button>}
/>
),
};
export const Confirm: Story = {
render: () => (
<AlertDialog
trigger={<Button variant="ember">Delete</Button>}
title="Delete this project?"
description="This action cannot be undone."
actionLabel="Delete"
destructive
onAction={() => {}}
/>
),
};
+73
View File
@@ -0,0 +1,73 @@
import type { Meta, StoryObj } from 'storybook-react-rsbuild';
import { ScrollArea } from '../components/ui';
const meta = {
title: 'Layout/ScrollArea',
component: ScrollArea,
parameters: {
docs: {
description: {
component:
'Radix ScrollArea with custom styled scrollbars. Wraps content in a viewport with vertical and horizontal scrollbars.',
},
},
},
argTypes: {
children: { control: false },
className: { control: 'text' },
},
} satisfies Meta<typeof ScrollArea>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {
render: () => (
<ScrollArea style={{ width: 300, height: 200, border: '1px solid var(--neutral-border)' }}>
<div style={{ padding: 16 }}>
<h4 className="mta-h4" style={{ marginBottom: 8 }}>Scrollable content</h4>
{Array.from({ length: 20 }).map((_, i) => (
<p key={i} className="mta-body" style={{ marginBottom: 12 }}>
Item {i + 1}: Lorem ipsum dolor sit amet, consectetur adipiscing elit.
</p>
))}
</div>
</ScrollArea>
),
};
export const Vertical: Story = {
render: () => (
<ScrollArea style={{ width: 280, height: 150, border: '1px solid var(--neutral-border)' }}>
<div style={{ padding: 12 }}>
{Array.from({ length: 30 }).map((_, i) => (
<div key={i} style={{ padding: 8, borderBottom: '1px solid var(--neutral-border)' }}>
Row {i + 1}
</div>
))}
</div>
</ScrollArea>
),
};
export const Horizontal: Story = {
render: () => (
<ScrollArea style={{ width: 320, height: 80, border: '1px solid var(--neutral-border)' }}>
<div style={{ display: 'flex', gap: 8, padding: 12, width: 'fit-content' }}>
{Array.from({ length: 20 }).map((_, i) => (
<div
key={i}
style={{
padding: 8,
minWidth: 100,
background: 'var(--neutral-surface)',
borderRadius: 4,
}}
>
Item {i + 1}
</div>
))}
</div>
</ScrollArea>
),
};
+68
View File
@@ -0,0 +1,68 @@
import { useState } from 'react';
import type { Meta, StoryObj } from 'storybook-react-rsbuild';
import { SegmentedControl } from '../components/ui';
const meta = {
title: 'Selection/SegmentedControl',
component: SegmentedControl,
parameters: {
docs: {
description: {
component:
'Single-select button group with animated thumb. Pass `items` array of `{ value, label }` objects, plus `value` and `onValueChange` for control.',
},
},
},
argTypes: {
value: { control: 'text' },
onValueChange: { action: 'value changed' },
items: { control: false },
className: { control: 'text' },
disabled: { control: 'boolean' },
},
} satisfies Meta<typeof SegmentedControl>;
export default meta;
type Story = StoryObj<typeof meta>;
function SegmentedDemo() {
const [v, setV] = useState('day');
return (
<SegmentedControl
value={v}
onValueChange={setV}
items={[
{ value: 'day', label: 'Day' },
{ value: 'week', label: 'Week' },
{ value: 'month', label: 'Month' },
]}
/>
);
}
export const Playground: Story = {
render: () => <SegmentedDemo />,
};
export const TimeRange: Story = {
render: () => <SegmentedDemo />,
};
function OptionsDemo() {
const [v, setV] = useState('draft');
return (
<SegmentedControl
value={v}
onValueChange={setV}
items={[
{ value: 'draft', label: 'Draft' },
{ value: 'published', label: 'Published' },
{ value: 'archived', label: 'Archived' },
]}
/>
);
}
export const Options: Story = {
render: () => <OptionsDemo />,
};
+39
View File
@@ -0,0 +1,39 @@
import type { Meta, StoryObj } from 'storybook-react-rsbuild';
import { Select } from '../components/ui';
const items = [
{ value: 'sequoia', label: 'Sequoia' },
{ value: 'sonoma', label: 'Sonoma' },
{ value: 'ventura', label: 'Ventura' },
{ value: 'monterey', label: 'Monterey' },
];
const meta = {
title: 'Inputs/Select',
component: Select,
parameters: {
docs: {
description: {
component:
'Radix Select in the MtAir skin. Pass `items` plus an optional `placeholder`; control it with `value` / `onValueChange` or leave it uncontrolled with `defaultValue`.',
},
},
},
argTypes: {
items: { control: false },
placeholder: { control: 'text' },
disabled: { control: 'boolean' },
defaultValue: { control: 'text' },
value: { control: 'text' },
onValueChange: { action: 'value changed' },
'aria-label': { control: 'text' },
},
args: { items, placeholder: 'Pick a release…', 'aria-label': 'macOS release' },
} satisfies Meta<typeof Select>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {};
export const WithDefault: Story = { args: { defaultValue: 'sonoma' } };
+84
View File
@@ -0,0 +1,84 @@
import { useState } from 'react';
import type { Meta, StoryObj } from 'storybook-react-rsbuild';
import {
Switch,
Checkbox,
RadioGroup,
RadioItem,
SegmentedControl,
Control,
} from '../components/ui';
/* Grouped selection controls. Anchored on Switch for the docgen table;
the stories below showcase each control. */
const meta = {
title: 'Selection/Controls',
component: Switch,
parameters: {
docs: {
description: {
component:
'Toggle, checkbox, radio group and segmented control. The `Control` helper pairs any of them with a clickable label.',
},
},
},
argTypes: {
defaultChecked: { control: 'boolean' },
checked: { control: 'boolean' },
disabled: { control: 'boolean' },
onCheckedChange: { action: 'checked changed' },
},
} satisfies Meta<typeof Switch>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {
args: { defaultChecked: false },
};
export const Switches: Story = {
render: () => (
<div style={{ display: 'flex', gap: 20, alignItems: 'center' }}>
<Switch defaultChecked />
<Switch />
<Control control={<Switch defaultChecked />}>Wi-Fi</Control>
</div>
),
};
export const Checkboxes: Story = {
render: () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<Control control={<Checkbox defaultChecked />}>Sync to iCloud</Control>
<Control control={<Checkbox />}>Share analytics</Control>
</div>
),
};
export const Radios: Story = {
render: () => (
<RadioGroup defaultValue="comfortable" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<Control control={<RadioItem value="compact" />}>Compact</Control>
<Control control={<RadioItem value="comfortable" />}>Comfortable</Control>
<Control control={<RadioItem value="spacious" />}>Spacious</Control>
</RadioGroup>
),
};
function SegmentedDemo() {
const [v, setV] = useState('day');
return (
<SegmentedControl
value={v}
onValueChange={setV}
items={[
{ value: 'day', label: 'Day' },
{ value: 'week', label: 'Week' },
{ value: 'month', label: 'Month' },
]}
/>
);
}
export const Segmented: Story = { render: () => <SegmentedDemo /> };
+74
View File
@@ -0,0 +1,74 @@
import type { Meta, StoryObj } from 'storybook-react-rsbuild';
import { Slider } from '../components/ui';
const meta = {
title: 'Inputs/Slider',
component: Slider,
parameters: {
docs: {
description: {
component:
'Radix Slider in the carved-track skin. All Radix Slider props pass through (`defaultValue`, `min`, `max`, `step`, `onValueChange`). Set `marks` to carve step notches into the track — `marks` snaps to the Radix `step`.',
},
},
},
args: { defaultValue: [60], min: 0, max: 100, step: 1 },
argTypes: {
defaultValue: { control: 'object', description: 'Uncontrolled starting value(s).' },
min: { control: 'number' },
max: { control: 'number' },
step: { control: 'number', description: 'Snap increment (also drives auto `marks`).' },
disabled: { control: 'boolean' },
marks: {
control: 'boolean',
description: 'Step marks. `true` derives one per `step`; or pass an array for custom/labelled marks.',
},
notches: {
control: 'inline-radio',
options: ['top', 'bottom', 'both', 'none'],
description: 'Notch tick placement relative to the track (labels still render when `none`).',
},
knobStyle: {
control: 'inline-radio',
options: ['square', 'round'],
description: 'Knob shape: `square` (default) or `round`.',
},
className: { control: 'text' },
},
decorators: [(Story) => <div style={{ width: 280 }}><Story /></div>],
} satisfies Meta<typeof Slider>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {};
/** `marks` auto-derives one notch per `step`. */
export const Stepped: Story = {
args: { defaultValue: [40], step: 10, marks: true },
};
/** `notches='both'` carves ticks above and below the bar. */
export const NotchesBoth: Story = {
args: { defaultValue: [60], step: 20, marks: true, notches: 'both' },
};
/** Pass an array of `{ value, label }` for labelled ticks. */
export const LabelledMarks: Story = {
args: {
defaultValue: [50],
step: 25,
notches: 'bottom',
marks: [
{ value: 0, label: 'Off' },
{ value: 25, label: 'Low' },
{ value: 50, label: 'Mid' },
{ value: 75, label: 'High' },
{ value: 100, label: 'Max' },
],
},
};
export const Disabled: Story = {
args: { defaultValue: [30], step: 10, marks: true, disabled: true },
};
+47
View File
@@ -0,0 +1,47 @@
import type { Meta, StoryObj } from 'storybook-react-rsbuild';
import { Tabs, TabsList, TabsContent } from '../components/ui';
const meta = {
title: 'Navigation/Tabs',
component: Tabs,
parameters: {
docs: {
description: {
component:
'Radix Tabs. `Tabs` is the root, `TabsList` takes `items`, and `TabsContent` matches each `value`.',
},
},
},
argTypes: {
defaultValue: { control: 'text' },
value: { control: 'text' },
onValueChange: { action: 'value changed' },
disabled: { control: 'boolean' },
},
} satisfies Meta<typeof Tabs>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {
render: () => (
<Tabs defaultValue="overview" style={{ width: 420 }}>
<TabsList
items={[
{ value: 'overview', label: 'Overview' },
{ value: 'activity', label: 'Activity' },
{ value: 'settings', label: 'Settings' },
]}
/>
<TabsContent value="overview" style={{ paddingTop: 16 }} className="mta-body">
Project at a glance.
</TabsContent>
<TabsContent value="activity" style={{ paddingTop: 16 }} className="mta-body">
Recent activity feed.
</TabsContent>
<TabsContent value="settings" style={{ paddingTop: 16 }} className="mta-body">
Preferences and configuration.
</TabsContent>
</Tabs>
),
};
+48
View File
@@ -0,0 +1,48 @@
import type { Meta, StoryObj } from 'storybook-react-rsbuild';
import { MagnifyingGlass } from '@phosphor-icons/react';
import { TextField, TextArea, SearchField } from '../components/ui';
const meta = {
title: 'Inputs/TextField',
component: TextField,
parameters: {
docs: {
description: {
component:
'Sunken text input. `TextField`, `TextArea`, and `SearchField` (icon + input) share the `mta-field` look and forward all native props.',
},
},
},
argTypes: {
placeholder: { control: 'text' },
value: { control: 'text' },
defaultValue: { control: 'text' },
disabled: { control: 'boolean' },
readOnly: { control: 'boolean' },
required: { control: 'boolean' },
type: { control: 'text' },
onChange: { action: 'changed' },
},
args: { placeholder: 'Type here…' },
} satisfies Meta<typeof TextField>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {};
export const Disabled: Story = { args: { disabled: true, value: 'Read only' } };
export const Multiline: Story = {
render: () => <TextArea rows={4} placeholder="Multiple lines…" style={{ width: 320 }} />,
};
export const Search: Story = {
render: () => (
<SearchField
icon={<MagnifyingGlass size={16} />}
placeholder="Search…"
style={{ width: 280 }}
/>
),
};
File diff suppressed because it is too large Load Diff
+71
View File
@@ -0,0 +1,71 @@
/* ============================================================
MtAir — optional branded webfonts.
Import this ONLY if you want the default MtAir typefaces:
import '@olly/mt-air/styles.css'; // required — tokens + components
import '@olly/mt-air/fonts.css'; // optional — branded faces
Skip it and the --mta-font-* tokens degrade to system-ui, or
override --mta-font-sans / --mta-font-mono in your own CSS to
map components onto fonts your app already loads.
------------------------------------------------------------
Plus Jakarta Sans (variable, 200800) and Fira Code (mono) are
self-hosted and inlined at build time.
============================================================ */
@font-face {
font-family: 'Plus Jakarta Sans';
src: url('../assets/fonts/PlusJakartaSans-VariableFont_wght.ttf') format('truetype');
font-weight: 200 800;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Plus Jakarta Sans';
src: url('../assets/fonts/PlusJakartaSans-Italic-VariableFont_wght.ttf') format('truetype');
font-weight: 200 800;
font-style: italic;
font-display: swap;
}
/* ── Fira Code — monospace (code, tokens, metadata) ────────────── */
@font-face {
font-family: 'Fira Code';
src: url('../assets/fonts/FiraCode-Light.ttf') format('truetype');
font-weight: 300;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Fira Code';
src: url('../assets/fonts/FiraCode-Regular.ttf') format('truetype');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Fira Code';
src: url('../assets/fonts/FiraCode-Medium.ttf') format('truetype');
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Fira Code';
src: url('../assets/fonts/FiraCode-SemiBold.ttf') format('truetype');
font-weight: 600;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Fira Code';
src: url('../assets/fonts/FiraCode-Bold.ttf') format('truetype');
font-weight: 700;
font-style: normal;
font-display: swap;
}
+33
View File
@@ -0,0 +1,33 @@
@import './fonts.css';
@import './tokens.css';
@import './components.css';
*{ box-sizing:border-box; margin:0; padding:0; }
html{ height:100%; }
body{
min-height:100vh;
font-family:var(--font-sans);
color:var(--fg-1);
-webkit-font-smoothing:antialiased;
}
/* Kitchen Sink layout helpers (ported from the prototype's inline <style>) */
.wrap{ max-width:1100px; margin:0 auto; padding:64px 40px 120px; position:relative; z-index:1; }
.word{ font-family:var(--font-display); font-size:30px; letter-spacing:.01em; text-transform:uppercase; }
.word b{ color:var(--lime); font-weight:400; }
.sub{ color:var(--fg-2); font-size:15px; margin-top:8px; max-width:560px; line-height:1.5; }
.topbar{ display:flex; align-items:flex-start; justify-content:space-between; gap:24px; }
section{ margin-top:52px; }
.sec-label{ font-size:11px; font-weight:600; letter-spacing:.22em; text-transform:uppercase; color:var(--fg-3); margin-bottom:20px; display:flex; align-items:center; gap:12px; }
.sec-label::after{ content:""; flex:1; height:1px; background:var(--hair); }
.grid{ display:grid; gap:18px; }
.cluster{ display:flex; gap:16px; align-items:center; flex-wrap:wrap; }
.stack{ display:flex; flex-direction:column; gap:14px; }
.cap{ font-size:11px; font-weight:600; color:var(--fg-3); text-transform:uppercase; letter-spacing:.14em; margin-bottom:10px; }
.lab{ font-size:11px; font-weight:600; text-transform:uppercase; letter-spacing:.18em; color:var(--fg-3); margin-bottom:5px; }
.two{ display:grid; grid-template-columns:1fr 1fr; gap:32px; }
.three{ display:grid; grid-template-columns:repeat(3,1fr); gap:24px; align-items:start; }
.panel{ padding:20px; }
+19
View File
@@ -0,0 +1,19 @@
/* ============================================================
MtAir — shippable stylesheet.
Tokens + component styles only. No demo/page layout, no global
reset of consumer elements. No fonts: import '@olly/mt-air/fonts.css'
for the branded faces, or override the --mta-font-* tokens.
============================================================ */
@import './tokens.css';
@import './components.css';
/* Non-invasive box-sizing + branded font for our own components only
(zero specificity, so consumer elements are never touched and the
--font-mono / --font-display classes still win by class specificity).
This is what carries the typeface onto portalled content (tooltips,
menus, dialogs) and bare text nodes (control labels) that never set
their own font-family. */
:where([class^='mta-'], [class*=' mta-']) {
box-sizing: border-box;
font-family: var(--font-sans);
}
+647
View File
@@ -0,0 +1,647 @@
/* ============================================================
MtAir — Design Tokens (ported from the MtAir Design System)
Warm, airy, light-first design system for MedipalTech Air.
------------------------------------------------------------
Single source of truth. Every component reads from here.
Fonts are NOT loaded here — see `fonts.css` for the branded
Plus Jakarta Sans / Fira Code faces. The --mta-font-* chains
below degrade to system-ui when that file is not imported.
============================================================ */
/* ---------- COLOR ----------
Warm, natural palette: health clarity, air, simplicity. */
:root {
/* ── Green (Primary brand — forest green) ──────────────────── */
--mta-green-50: #EFF7F2;
--mta-green-100: #D4EADE;
--mta-green-200: #A8D5BE;
--mta-green-300: #75BE9C;
--mta-green-400: #46A579;
--mta-green-500: #298D60;
--mta-green-600: #1E6E48; /* ← brand primary */
--mta-green-700: #155337;
--mta-green-800: #0D3924;
--mta-green-900: #062113;
/* ── Amber (Warm accent — copper/honey) ────────────────────── */
--mta-amber-50: #FFF8EE;
--mta-amber-100: #FEECD3;
--mta-amber-200: #FDD5A6;
--mta-amber-300: #FBB869;
--mta-amber-400: #F89935;
--mta-amber-500: #E87D1A;
--mta-amber-600: #CA681E; /* ← warm CTA / text highlights */
--mta-amber-700: #A55118;
--mta-amber-800: #823C11;
--mta-amber-900: #5C270B;
/* ── Neutral (Warm gray — cream undertone) ─────────────────── */
--mta-neutral-0: #FFFFFF;
--mta-neutral-50: #FAF9F7;
--mta-neutral-100: #F3F0EB;
--mta-neutral-200: #E9E5DD;
--mta-neutral-300: #D4CFC9;
--mta-neutral-400: #B3AFA7;
--mta-neutral-500: #908C83;
--mta-neutral-600: #6B6760;
--mta-neutral-700: #47433D;
--mta-neutral-800: #2C2A24;
--mta-neutral-900: #181610;
/* ── Dark (Hero / panel backgrounds) ──────────────────────── */
--mta-dark-50: #2C2920;
--mta-dark-100: #201E17;
--mta-dark-200: #171510;
--mta-dark-300: #0F0D09;
/* ── Dark-theme surface scale (warm charcoal — never pitch black) ─ */
--mta-surface-dark-0: #1C1B15; /* page — deep warm charcoal, "airy" */
--mta-surface-dark-1: #24221A; /* card */
--mta-surface-dark-2: #2C2A20; /* raised / popover */
--mta-surface-dark-3: #353227; /* muted fill */
--mta-surface-dark-hover: #322F25; /* row / item hover */
--mta-surface-dark-border: #38352B;
--mta-surface-dark-border-strong: #4A4639;
/* ── Red (Error / danger) ──────────────────────────────────── */
--mta-red-50: #FFF2F1;
--mta-red-100: #FFE2DF;
--mta-red-200: #FFBFBB;
--mta-red-500: #DC3B2F;
--mta-red-600: #B82920;
--mta-red-700: #951E16;
--mta-red-900: #4A0A06;
}
/* ── Semantic tokens ───────────────────────────────────────────── */
:root {
/* Backgrounds */
--color-bg: var(--mta-neutral-100);
--color-bg-page: var(--mta-neutral-100);
--color-bg-card: var(--mta-neutral-0);
--color-bg-subtle: var(--mta-neutral-50);
--color-bg-muted: var(--mta-neutral-200);
--color-bg-dark: var(--mta-dark-100);
--color-bg-overlay: rgba(24, 22, 16, 0.40);
/* Surfaces */
--color-surface: var(--mta-neutral-0);
--color-surface-raised: var(--mta-neutral-50);
/* Filled controls (secondary buttons, chips, default tags/badges) */
--color-control: var(--mta-neutral-100);
--color-control-hover: var(--mta-neutral-200);
/* Text */
--color-text-primary: var(--mta-neutral-900);
--color-text-secondary: var(--mta-neutral-600);
--color-text-muted: var(--mta-neutral-500);
--color-text-disabled: var(--mta-neutral-400);
--color-text-inverse: var(--mta-neutral-0);
--color-text-on-primary: var(--mta-neutral-0);
/* Borders */
--color-border: var(--mta-neutral-200);
--color-border-strong: var(--mta-neutral-300);
--color-border-subtle: var(--mta-neutral-100);
--color-border-focus: var(--mta-green-600);
/* Accent — green */
--color-accent: var(--mta-green-600);
--color-accent-hover: var(--mta-green-700);
--color-accent-active: var(--mta-green-800);
--color-accent-subtle: var(--mta-green-50);
--color-accent-muted: var(--mta-green-100);
--color-accent-text: var(--mta-green-700);
/* Warm — amber */
--color-warm: var(--mta-amber-600);
--color-warm-hover: var(--mta-amber-700);
--color-warm-subtle: var(--mta-amber-50);
--color-warm-text: var(--mta-amber-700);
/* Status */
--color-success: var(--mta-green-600);
--color-success-bg: var(--mta-green-50);
--color-success-border: var(--mta-green-200);
--color-success-text: var(--mta-green-700);
--color-warning: var(--mta-amber-500);
--color-warning-bg: var(--mta-amber-50);
--color-warning-border: var(--mta-amber-200);
--color-warning-text: var(--mta-amber-700);
--color-error: var(--mta-red-500);
--color-error-bg: var(--mta-red-50);
--color-error-border: var(--mta-red-200);
--color-error-text: var(--mta-red-600);
}
/* ── Dark theme ────────────────────────────────────────────────────
* Background is a deep WARM charcoal — never pitch black — to keep
* the brand's airy, natural feel. Activated by [data-theme="dark"]
* (set by ThemeProvider) — .dark / .dark-theme included for drop-in
* compatibility with @radix-ui/themes.
* ─────────────────────────────────────────────────────────────────── */
.dark,
.dark-theme,
[data-theme="dark"] {
color-scheme: dark;
/* Backgrounds */
--color-bg: var(--mta-surface-dark-0);
--color-bg-page: var(--mta-surface-dark-0);
--color-bg-card: var(--mta-surface-dark-1);
--color-bg-subtle: var(--mta-surface-dark-2);
--color-bg-muted: var(--mta-surface-dark-3);
--color-bg-dark: #14130E;
--color-bg-overlay: rgba(8, 7, 4, 0.62);
/* Surfaces */
--color-surface: var(--mta-surface-dark-1);
--color-surface-raised: var(--mta-surface-dark-2);
/* Filled controls */
--color-control: var(--mta-surface-dark-2);
--color-control-hover: var(--mta-surface-dark-3);
/* Text */
--color-text-primary: var(--mta-neutral-100);
--color-text-secondary: var(--mta-neutral-400);
--color-text-muted: var(--mta-neutral-500);
--color-text-disabled: var(--mta-neutral-600);
--color-text-inverse: var(--mta-neutral-900);
--color-text-on-primary: #ffffff;
/* Borders */
--color-border: var(--mta-surface-dark-border);
--color-border-strong: var(--mta-surface-dark-border-strong);
--color-border-subtle: #2A2820;
--color-border-focus: var(--mta-green-400);
/* Accent — green (lifted for legibility on dark) */
--color-accent: var(--mta-green-500);
--color-accent-hover: var(--mta-green-400);
--color-accent-active: var(--mta-green-300);
--color-accent-subtle: rgba(70, 165, 121, 0.14);
--color-accent-muted: rgba(70, 165, 121, 0.24);
--color-accent-text: var(--mta-green-300);
/* Warm — amber */
--color-warm: var(--mta-amber-500);
--color-warm-hover: var(--mta-amber-400);
--color-warm-subtle: rgba(248, 153, 53, 0.14);
--color-warm-text: var(--mta-amber-300);
/* Status */
--color-success: var(--mta-green-400);
--color-success-bg: rgba(70, 165, 121, 0.14);
--color-success-border: rgba(70, 165, 121, 0.34);
--color-success-text: var(--mta-green-300);
--color-warning: var(--mta-amber-400);
--color-warning-bg: rgba(248, 153, 53, 0.14);
--color-warning-border: rgba(248, 153, 53, 0.32);
--color-warning-text: var(--mta-amber-300);
--color-error: var(--mta-red-500);
--color-error-bg: rgba(220, 59, 47, 0.15);
--color-error-border: rgba(220, 59, 47, 0.36);
--color-error-text: #FF9A90;
/* Shadows — deeper & cooler against dark surfaces */
--mta-shadow-xs: 0 1px 2px 0 rgba(0, 0, 0, 0.40);
--mta-shadow-sm: 0 1px 3px 0 rgba(0, 0, 0, 0.46),
0 1px 2px -1px rgba(0, 0, 0, 0.40);
--mta-shadow-md: 0 4px 8px -1px rgba(0, 0, 0, 0.50),
0 2px 4px -2px rgba(0, 0, 0, 0.42);
--mta-shadow-lg: 0 10px 20px -3px rgba(0, 0, 0, 0.55),
0 4px 8px -4px rgba(0, 0, 0, 0.46);
--mta-shadow-xl: 0 20px 32px -5px rgba(0, 0, 0, 0.60),
0 8px 14px -6px rgba(0, 0, 0, 0.50);
--mta-shadow-modal: 0 32px 64px -12px rgba(0, 0, 0, 0.70),
0 12px 24px -8px rgba(0, 0, 0, 0.55);
/* Focus rings — brighter green on dark */
--mta-focus-ring: 0 0 0 3px rgba(70, 165, 121, 0.40);
}
/* ---------- TYPOGRAPHY ----------
Typeface: Plus Jakarta Sans (variable, 200800 weight). */
:root {
/* Font families */
--mta-font-sans: 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
/* Fira Code — self-hosted (weights 300700), see fonts.css */
--mta-font-mono: 'Fira Code', 'SF Mono', 'Cascadia Code', ui-monospace, monospace;
/* Font sizes */
--mta-font-size-xs: 11px;
--mta-font-size-sm: 13px;
--mta-font-size-base: 14px;
--mta-font-size-md: 16px;
--mta-font-size-lg: 18px;
--mta-font-size-xl: 20px;
--mta-font-size-2xl: 24px;
--mta-font-size-3xl: 30px;
--mta-font-size-4xl: 38px;
--mta-font-size-5xl: 48px;
--mta-font-size-6xl: 60px;
/* Font weights */
--mta-font-weight-light: 300;
--mta-font-weight-regular: 400;
--mta-font-weight-medium: 500;
--mta-font-weight-semibold: 600;
--mta-font-weight-bold: 700;
--mta-font-weight-extrabold: 800;
/* Line heights */
--mta-line-height-none: 1;
--mta-line-height-tight: 1.2;
--mta-line-height-snug: 1.35;
--mta-line-height-normal: 1.5;
--mta-line-height-relaxed: 1.65;
--mta-line-height-loose: 1.8;
/* Letter spacing */
--mta-tracking-tighter: -0.03em;
--mta-tracking-tight: -0.02em;
--mta-tracking-snug: -0.01em;
--mta-tracking-normal: 0em;
--mta-tracking-wide: 0.02em;
--mta-tracking-wider: 0.06em;
--mta-tracking-widest: 0.12em;
/* ── Semantic type styles ──────────────────────────────────── */
--mta-text-display-size: var(--mta-font-size-5xl);
--mta-text-display-weight: var(--mta-font-weight-bold);
--mta-text-display-leading: var(--mta-line-height-tight);
--mta-text-display-tracking: var(--mta-tracking-tighter);
--mta-text-h1-size: var(--mta-font-size-4xl);
--mta-text-h1-weight: var(--mta-font-weight-bold);
--mta-text-h1-leading: var(--mta-line-height-tight);
--mta-text-h1-tracking: var(--mta-tracking-tight);
--mta-text-h2-size: var(--mta-font-size-3xl);
--mta-text-h2-weight: var(--mta-font-weight-semibold);
--mta-text-h2-leading: var(--mta-line-height-snug);
--mta-text-h2-tracking: var(--mta-tracking-tight);
--mta-text-h3-size: var(--mta-font-size-2xl);
--mta-text-h3-weight: var(--mta-font-weight-semibold);
--mta-text-h3-leading: var(--mta-line-height-snug);
--mta-text-h3-tracking: var(--mta-tracking-snug);
--mta-text-h4-size: var(--mta-font-size-xl);
--mta-text-h4-weight: var(--mta-font-weight-semibold);
--mta-text-h4-leading: var(--mta-line-height-snug);
--mta-text-h4-tracking: var(--mta-tracking-snug);
--mta-text-body-lg-size: var(--mta-font-size-md);
--mta-text-body-lg-weight: var(--mta-font-weight-regular);
--mta-text-body-lg-leading: var(--mta-line-height-relaxed);
--mta-text-body-size: var(--mta-font-size-base);
--mta-text-body-weight: var(--mta-font-weight-regular);
--mta-text-body-leading: var(--mta-line-height-normal);
--mta-text-body-sm-size: var(--mta-font-size-sm);
--mta-text-body-sm-weight: var(--mta-font-weight-regular);
--mta-text-body-sm-leading: var(--mta-line-height-normal);
--mta-text-label-size: var(--mta-font-size-sm);
--mta-text-label-weight: var(--mta-font-weight-medium);
--mta-text-label-leading: var(--mta-line-height-none);
--mta-text-label-sm-size: var(--mta-font-size-xs);
--mta-text-label-sm-weight: var(--mta-font-weight-medium);
--mta-text-label-sm-tracking: var(--mta-tracking-wide);
--mta-text-caption-size: var(--mta-font-size-xs);
--mta-text-caption-weight: var(--mta-font-weight-regular);
--mta-text-caption-leading: var(--mta-line-height-normal);
}
/* ---------- SPACING + SIZING ----------
Base unit: 4px. */
:root {
--mta-space-px: 1px;
--mta-space-0: 0px;
--mta-space-0-5: 2px;
--mta-space-1: 4px;
--mta-space-1-5: 6px;
--mta-space-2: 8px;
--mta-space-2-5: 10px;
--mta-space-3: 12px;
--mta-space-3-5: 14px;
--mta-space-4: 16px;
--mta-space-5: 20px;
--mta-space-6: 24px;
--mta-space-7: 28px;
--mta-space-8: 32px;
--mta-space-9: 36px;
--mta-space-10: 40px;
--mta-space-11: 44px;
--mta-space-12: 48px;
--mta-space-14: 56px;
--mta-space-16: 64px;
--mta-space-20: 80px;
--mta-space-24: 96px;
--mta-space-28: 112px;
--mta-space-32: 128px;
/* ── Border radius ─────────────────────────────────────────── */
--mta-radius-none: 0px;
--mta-radius-xs: 3px; /* tags, badges */
--mta-radius-sm: 5px; /* small inputs, chips */
--mta-radius-md: 8px; /* inputs, buttons, code */
--mta-radius-lg: 12px; /* cards, dropdowns */
--mta-radius-xl: 16px; /* modals, panels */
--mta-radius-2xl: 20px; /* large cards */
--mta-radius-3xl: 28px; /* hero cards */
--mta-radius-full: 9999px; /* pills, avatars, toggles */
/* ── Layout constants ──────────────────────────────────────── */
--mta-sidebar-width: 240px;
--mta-topbar-height: 56px;
--mta-max-content-width: 760px;
--mta-max-page-width: 1280px;
/* ── Control sizing (compact, global) ─────────────────────── */
--ctl-font: var(--mta-font-size-base);
--ctl-pad-y: 9px;
--ctl-pad-x: var(--mta-space-4);
--field-pad-y: var(--mta-space-2);
--field-pad-x: var(--mta-space-3);
--switch-w: 36px;
--switch-h: 20px;
--switch-knob: 16px;
--switch-gap: 2px;
--seg-pad-y: var(--mta-space-1);
--stepper-h: 30px;
}
/* ---------- EFFECTS ----------
Shadows, transitions, blur — warm-tinted, minimal, airy. */
:root {
/* ── Shadows (warm-tinted: hsl(42 30% 6%)) ────────────────── */
--mta-shadow-none: none;
--mta-shadow-xs: 0 1px 2px 0 rgba(24, 20, 12, 0.05);
--mta-shadow-sm: 0 1px 3px 0 rgba(24, 20, 12, 0.07),
0 1px 2px -1px rgba(24, 20, 12, 0.05);
--mta-shadow-md: 0 4px 8px -1px rgba(24, 20, 12, 0.08),
0 2px 4px -2px rgba(24, 20, 12, 0.05);
--mta-shadow-lg: 0 10px 20px -3px rgba(24, 20, 12, 0.09),
0 4px 8px -4px rgba(24, 20, 12, 0.06);
--mta-shadow-xl: 0 20px 32px -5px rgba(24, 20, 12, 0.10),
0 8px 14px -6px rgba(24, 20, 12, 0.07);
--mta-shadow-modal: 0 32px 64px -12px rgba(24, 20, 12, 0.22),
0 12px 24px -8px rgba(24, 20, 12, 0.12);
/* ── Focus rings ───────────────────────────────────────────── */
--mta-focus-ring: 0 0 0 3px rgba(30, 110, 72, 0.28);
--mta-focus-ring-amber: 0 0 0 3px rgba(202, 104, 30, 0.28);
--mta-focus-ring-error: 0 0 0 3px rgba(220, 59, 47, 0.28);
/* ── Transitions ───────────────────────────────────────────── */
--mta-duration-instant: 50ms;
--mta-duration-fast: 100ms;
--mta-duration-normal: 150ms;
--mta-duration-slow: 250ms;
--mta-duration-slower: 400ms;
--mta-duration-slowest: 600ms;
--mta-ease-default: cubic-bezier(0.16, 1, 0.3, 1);
--mta-ease-in: cubic-bezier(0.4, 0, 1, 1);
--mta-ease-out: cubic-bezier(0, 0, 0.2, 1);
--mta-ease-inout: cubic-bezier(0.4, 0, 0.2, 1);
--mta-ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
/* Shorthand */
--mta-transition-color: color var(--mta-duration-normal) var(--mta-ease-default),
background-color var(--mta-duration-normal) var(--mta-ease-default),
border-color var(--mta-duration-normal) var(--mta-ease-default);
--mta-transition-transform: transform var(--mta-duration-fast) var(--mta-ease-default);
--mta-transition-shadow: box-shadow var(--mta-duration-normal) var(--mta-ease-default);
--mta-transition-opacity: opacity var(--mta-duration-normal) var(--mta-ease-default);
--mta-transition-all: all var(--mta-duration-normal) var(--mta-ease-default);
/* ── Backdrop blur ─────────────────────────────────────────── */
--mta-blur-sm: 4px;
--mta-blur-md: 12px;
--mta-blur-lg: 24px;
--mta-blur-xl: 40px;
/* ── Z-index scale ─────────────────────────────────────────── */
--mta-z-base: 0;
--mta-z-raised: 10;
--mta-z-sticky: 100;
--mta-z-overlay: 200;
--mta-z-modal: 300;
--mta-z-toast: 400;
--mta-z-tooltip: 500;
}
/* ============================================================
COMPATIBILITY BRIDGE
Legacy variable names used by components.css recipes that
haven't been individually re-styled to the MtAir tokens above
yet. As each component's CSS is rewritten, its references to
these legacy names should be dropped in favor of the --mta-*
and --color-* tokens directly, and this bridge trimmed.
============================================================ */
:root {
/* Type */
--font-sans: var(--mta-font-sans);
--font-display: var(--mta-font-sans);
--font-mono: var(--mta-font-mono);
--text-xs: var(--mta-font-size-xs);
--text-sm: var(--mta-font-size-sm);
--text-base: var(--mta-font-size-base);
--text-md: var(--mta-font-size-md);
--text-lg: var(--mta-font-size-lg);
--text-xl: var(--mta-font-size-xl);
--text-2xl: var(--mta-font-size-2xl);
--text-3xl: var(--mta-font-size-3xl);
--text-4xl: var(--mta-font-size-4xl);
--text-5xl: var(--mta-font-size-5xl);
--w-light: var(--mta-font-weight-light);
--w-regular: var(--mta-font-weight-regular);
--w-medium: var(--mta-font-weight-medium);
--w-semibold: var(--mta-font-weight-semibold);
--w-bold: var(--mta-font-weight-bold);
--w-black: var(--mta-font-weight-extrabold);
--lh-tight: var(--mta-line-height-tight);
--lh-snug: var(--mta-line-height-snug);
--lh-normal: var(--mta-line-height-normal);
--lh-relaxed: var(--mta-line-height-relaxed);
--track-tight: var(--mta-tracking-tight);
--track-snug: var(--mta-tracking-snug);
--track-wide: var(--mta-tracking-wide);
--track-caps: var(--mta-tracking-widest);
/* Radii */
--r-xs: var(--mta-radius-xs);
--r-sm: var(--mta-radius-sm);
--r-md: var(--mta-radius-md);
--r-lg: var(--mta-radius-lg);
--r-xl: var(--mta-radius-xl);
--r-pill: var(--mta-radius-full);
/* Spacing 1:1 — both scales are 4px-based with matching steps */
--space-1: var(--mta-space-1);
--space-2: var(--mta-space-2);
--space-3: var(--mta-space-3);
--space-4: var(--mta-space-4);
--space-5: var(--mta-space-5);
--space-6: var(--mta-space-6);
--space-8: var(--mta-space-8);
--space-10: var(--mta-space-10);
--space-12: var(--mta-space-12);
--space-16: var(--mta-space-16);
/* Surfaces, text, borders */
--ink: var(--color-bg-page);
--ink-deep: var(--color-bg);
--ink-raised: var(--color-bg-card);
--steel-900: var(--color-bg-card);
--steel-800: var(--color-control);
--steel-700: var(--color-control-hover);
--steel-600: var(--color-border-strong);
--steel-500: var(--color-border);
--steel-400: var(--color-text-disabled);
--fg-1: var(--color-text-primary);
--fg-2: var(--color-text-secondary);
--fg-3: var(--color-text-muted);
--fg-on-lime: var(--color-text-on-primary);
--fg-on-ember: #ffffff;
--hair: var(--color-border);
--hair-strong: var(--color-border-strong);
--edge-top: rgba(255, 255, 255, 0.6);
--edge-inset: var(--color-border);
/* Brand accents — green (lime) / red (ember) */
--lime: var(--color-accent);
--lime-bright: var(--mta-green-400);
--lime-deep: var(--color-accent-hover);
--lime-ink: var(--color-text-on-primary);
--ember: var(--color-error);
--ember-bright: var(--mta-red-500);
--ember-deep: var(--mta-red-700);
/* Status */
--success: var(--color-success);
--warning: var(--color-warning);
--danger: var(--color-error);
--info: var(--color-accent);
--success-bg: var(--color-success-bg);
--warning-bg: var(--color-warning-bg);
--danger-bg: var(--color-error-bg);
--info-bg: var(--color-accent-subtle);
/* Shadows */
--shadow-raised: var(--mta-shadow-sm);
--shadow-raised-hover: var(--mta-shadow-md);
--shadow-pressed: inset 0 1px 2px rgba(24, 20, 12, 0.10);
--shadow-inset-well: inset 0 1px 2px rgba(24, 20, 12, 0.08);
--shadow-card: var(--mta-shadow-sm);
--shadow-window: var(--mta-shadow-modal);
--focus-ring: var(--mta-focus-ring);
--focus-ring-ember: var(--mta-focus-ring-error);
--glow-lime: 0 0 0 0 transparent;
/* Flat "gradients" — MtAir uses flat fills, no gloss */
--grad-key: var(--color-control);
--grad-key-hover: var(--color-control-hover);
--grad-key-down: var(--color-control-hover);
--grad-primary: var(--color-accent);
--grad-primary-down: var(--color-accent-active);
--grad-ember: var(--color-error);
--grad-ember-down: var(--mta-red-700);
--grad-titlebar: var(--color-bg-subtle);
/* Motion */
--dur-press: var(--mta-duration-instant);
--dur-quick: var(--mta-duration-fast);
--dur-base: var(--mta-duration-normal);
--ease-out: var(--mta-ease-out);
--ease-snap: var(--mta-ease-spring);
}
/* ============================================================
SEMANTIC TYPE CLASSES
============================================================ */
.mta-felt {
background-color: var(--color-bg-page);
color: var(--color-text-primary);
}
.mta-display {
font-family: var(--mta-font-sans);
font-weight: var(--mta-text-display-weight);
font-size: var(--mta-text-display-size);
line-height: var(--mta-text-display-leading);
letter-spacing: var(--mta-text-display-tracking);
color: var(--color-text-primary);
}
.mta-h1 {
font-family: var(--mta-font-sans);
font-weight: var(--mta-text-h1-weight);
font-size: var(--mta-text-h1-size);
line-height: var(--mta-text-h1-leading);
letter-spacing: var(--mta-text-h1-tracking);
color: var(--color-text-primary);
}
.mta-h2 {
font-family: var(--mta-font-sans);
font-weight: var(--mta-text-h2-weight);
font-size: var(--mta-text-h2-size);
line-height: var(--mta-text-h2-leading);
letter-spacing: var(--mta-text-h2-tracking);
color: var(--color-text-primary);
}
.mta-h3 {
font-family: var(--mta-font-sans);
font-weight: var(--mta-text-h3-weight);
font-size: var(--mta-text-h3-size);
line-height: var(--mta-text-h3-leading);
letter-spacing: var(--mta-text-h3-tracking);
color: var(--color-text-primary);
}
.mta-body {
font-family: var(--mta-font-sans);
font-weight: var(--mta-text-body-weight);
font-size: var(--mta-text-body-size);
line-height: var(--mta-text-body-leading);
color: var(--color-text-secondary);
}
.mta-body-strong {
font-weight: var(--mta-font-weight-medium);
color: var(--color-text-primary);
}
.mta-caption {
font-family: var(--mta-font-sans);
font-weight: var(--mta-text-caption-weight);
font-size: var(--mta-text-caption-size);
color: var(--color-text-muted);
}
.mta-label {
font-family: var(--mta-font-sans);
font-weight: var(--mta-text-label-sm-weight);
font-size: var(--mta-font-size-xs);
text-transform: uppercase;
letter-spacing: var(--mta-tracking-widest);
color: var(--color-text-muted);
}
.mta-mono {
font-family: var(--mta-font-mono);
font-weight: var(--mta-font-weight-regular);
font-size: var(--mta-text-body-sm-size);
color: var(--color-text-secondary);
}