From d915443b8c49fb736ee2762f086cc17531208a10 Mon Sep 17 00:00:00 2001 From: Richie Date: Wed, 3 Jun 2026 14:24:23 +1000 Subject: [PATCH] Align design system with ADS 3.0 and add new components Token foundation: fix 16 palette colours to match official ADS_COLORS, add 5 new palettes (teal, brown, purple, fuchsia, yellow), realign semantic tokens (primary=navy, info=bright blue), fix border radii to 8px base, add responsive heading typography. Component migration: swap primary/info references across all existing components, update Button (44px/semibold), Switch (green/compact), Chip (30px/8px radius + colour variants), SideNav (80px rail), Tag (11 colours). New components: SideNav, TopBar, Avatar, Tabs, PageHeader, Slider, RangeSlider, FileInput, DataTable, List, Autocomplete. Co-Authored-By: Claude Opus 4.6 --- ARCHITECTURE.md | 162 +++++++++ .../Autocomplete/Autocomplete.stories.tsx | 91 +++++ .../atoms/Autocomplete/Autocomplete.tsx | 216 ++++++++++++ src/components/atoms/Autocomplete/index.ts | 2 + .../atoms/Avatar/Avatar.stories.tsx | 49 +++ src/components/atoms/Avatar/Avatar.tsx | 46 +++ src/components/atoms/Avatar/index.ts | 2 + src/components/atoms/Badge/Badge.tsx | 4 +- src/components/atoms/Button/Button.tsx | 4 +- src/components/atoms/Chip/Chip.tsx | 23 +- src/components/atoms/Chip/index.ts | 2 +- .../atoms/FileInput/FileInput.stories.tsx | 42 +++ src/components/atoms/FileInput/FileInput.tsx | 120 +++++++ src/components/atoms/FileInput/index.ts | 2 + src/components/atoms/Input/Input.tsx | 4 +- src/components/atoms/List/List.stories.tsx | 57 +++ src/components/atoms/List/List.tsx | 96 +++++ src/components/atoms/List/index.ts | 2 + src/components/atoms/Select/Select.tsx | 4 +- .../atoms/Slider/Slider.stories.tsx | 63 ++++ src/components/atoms/Slider/Slider.tsx | 201 +++++++++++ src/components/atoms/Slider/index.ts | 2 + src/components/atoms/Switch/Switch.tsx | 10 +- src/components/atoms/Tabs/Tabs.stories.tsx | 82 +++++ src/components/atoms/Tabs/Tabs.tsx | 141 ++++++++ src/components/atoms/Tabs/index.ts | 2 + src/components/atoms/Tag/Tag.tsx | 29 +- .../molecules/Accordion/Accordion.tsx | 4 +- src/components/molecules/Card/Card.tsx | 2 +- .../molecules/DataTable/DataTable.stories.tsx | 79 +++++ .../molecules/DataTable/DataTable.tsx | 282 +++++++++++++++ src/components/molecules/DataTable/index.ts | 2 + src/components/molecules/Dialog/Dialog.tsx | 2 +- .../PageHeader/PageHeader.stories.tsx | 97 ++++++ .../organisms/PageHeader/PageHeader.tsx | 91 +++++ src/components/organisms/PageHeader/index.ts | 2 + .../organisms/SideNav/SideNav.stories.tsx | 314 +++++++++++++++++ src/components/organisms/SideNav/SideNav.tsx | 329 ++++++++++++++++++ src/components/organisms/SideNav/index.ts | 7 + .../organisms/TopBar/TopBar.stories.tsx | 205 +++++++++++ src/components/organisms/TopBar/TopBar.tsx | 44 +++ src/components/organisms/TopBar/index.ts | 2 + src/styles/global.css | 16 + src/styles/typography.css | 34 +- src/tokens/tokens.css | 113 ++++-- 45 files changed, 3029 insertions(+), 54 deletions(-) create mode 100644 ARCHITECTURE.md create mode 100644 src/components/atoms/Autocomplete/Autocomplete.stories.tsx create mode 100644 src/components/atoms/Autocomplete/Autocomplete.tsx create mode 100644 src/components/atoms/Autocomplete/index.ts create mode 100644 src/components/atoms/Avatar/Avatar.stories.tsx create mode 100644 src/components/atoms/Avatar/Avatar.tsx create mode 100644 src/components/atoms/Avatar/index.ts create mode 100644 src/components/atoms/FileInput/FileInput.stories.tsx create mode 100644 src/components/atoms/FileInput/FileInput.tsx create mode 100644 src/components/atoms/FileInput/index.ts create mode 100644 src/components/atoms/List/List.stories.tsx create mode 100644 src/components/atoms/List/List.tsx create mode 100644 src/components/atoms/List/index.ts create mode 100644 src/components/atoms/Slider/Slider.stories.tsx create mode 100644 src/components/atoms/Slider/Slider.tsx create mode 100644 src/components/atoms/Slider/index.ts create mode 100644 src/components/atoms/Tabs/Tabs.stories.tsx create mode 100644 src/components/atoms/Tabs/Tabs.tsx create mode 100644 src/components/atoms/Tabs/index.ts create mode 100644 src/components/molecules/DataTable/DataTable.stories.tsx create mode 100644 src/components/molecules/DataTable/DataTable.tsx create mode 100644 src/components/molecules/DataTable/index.ts create mode 100644 src/components/organisms/PageHeader/PageHeader.stories.tsx create mode 100644 src/components/organisms/PageHeader/PageHeader.tsx create mode 100644 src/components/organisms/PageHeader/index.ts create mode 100644 src/components/organisms/SideNav/SideNav.stories.tsx create mode 100644 src/components/organisms/SideNav/SideNav.tsx create mode 100644 src/components/organisms/SideNav/index.ts create mode 100644 src/components/organisms/TopBar/TopBar.stories.tsx create mode 100644 src/components/organisms/TopBar/TopBar.tsx create mode 100644 src/components/organisms/TopBar/index.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..1fec93a --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,162 @@ +# ARCHITECTURE.md — ADS 3.0 Design System + +This is the living architecture document for the ADS 3.0 design system. All structural decisions are recorded here. Update this document when the architecture evolves — never let the codebase and this document drift apart. + +--- + +## 1. Overview + +ADS 3.0 Design System is a React component library implementing the ADS 3.0 (Adaptive Design System) design language. It provides tokens, primitives, and composite components as a shared foundation. Application-specific screens and domain logic belong in downstream forks of this repo. + +--- + +## 2. Token Pipeline + +``` +Figma (design tool) + ↓ Figma MCP / get_variable_defs +src/tokens/tokens.css (@theme block) + ↓ Tailwind CSS v4 reads @theme + ↓ Generates utility classes + CSS custom properties +Components (use Tailwind utilities or var() references) + ↓ Rendered in +Storybook (visual verification) +``` + +### Token Layers +Tokens are structured in three layers: + +1. **Palette** — raw colour values (`--color-blue-01`, `--color-grey-03`). Not used directly in components. +2. **Semantic** — purpose-based aliases (`--color-primary`, `--color-error`, `--color-text`). General UI usage. +3. **Form control** — shared interactive-state tokens for all form components (`--color-control-border`, `--color-control-checked`, `--color-control-label`, etc.). Ensures consistent styling across Input, Checkbox, Radio, Switch, Select, and future form primitives. +4. **Button** — dedicated tokens for the Button component's intent system (`--color-button-default`, `--color-button-danger`, `--color-button-neutral`, `--color-button-subtle-bg`, `--color-button-subtle-text`). +5. **Badge** — status colour tokens for the Badge component's variant system (`--color-badge-info`, `--color-badge-success`, `--color-badge-error`, `--color-badge-warning`, `--color-badge-neutral`, `--color-badge-text`). +6. **Chip** — tokens for the Chip component's border/fill states (`--color-chip-border`, `--color-chip-text`, `--color-chip-bg`, `--color-chip-selected-bg`, `--color-chip-selected-text`). +7. **Tag** — colour tokens for the Tag component's 11-colour system (`--color-tag-navy`, `--color-tag-blue`, `--color-tag-green`, `--color-tag-red`, `--color-tag-orange`, `--color-tag-grey`, `--color-tag-teal`, `--color-tag-brown`, `--color-tag-purple`, `--color-tag-fuchsia`, `--color-tag-yellow`, plus `-light` variants for each). +8. **Alert** — background, border, and icon colour tokens for 5 alert variants (`--color-alert-{variant}-bg`, `--color-alert-{variant}-border`, `--color-alert-{variant}-icon` for info, warning, error, success, neutral). +9. **Switch** — dedicated on-state tokens (`--color-switch-on`, `--color-switch-on-hover`) using success green per ADS 3.0. +10. **Avatar** — background and text colour tokens (`--color-avatar`, `--color-avatar-text`). +11. **TopBar** — background colour token (`--color-topbar`). +12. **SideNav** — navigation-specific tokens (`--color-nav-bg`, `--color-nav-text`, `--color-nav-icon`, `--color-nav-active`, `--color-nav-divider`). + +### Token Categories +- **Palette colours**: `--color-{palette}-{shade}` — 10 families (blue, red, orange, green, teal, brown, purple, fuchsia, yellow, grey) × 4 shades each +- **Semantic colours**: `--color-{purpose}` (e.g., `--color-primary` = navy, `--color-info` = bright blue, `--color-error`, `--color-text`) +- **Form control colours**: `--color-control-{role}` (e.g., `--color-control-border`, `--color-control-checked`) +- **Button colours**: `--color-button-{intent}` (e.g., `--color-button-default`, `--color-button-danger`) +- **Badge colours**: `--color-badge-{variant}` (e.g., `--color-badge-info`, `--color-badge-error`) +- **Chip colours**: `--color-chip-{role}` (e.g., `--color-chip-border`, `--color-chip-selected-bg`) +- **Tag colours**: `--color-tag-{color}` and `--color-tag-{color}-light` (e.g., `--color-tag-blue`, `--color-tag-blue-light`) +- **Radii**: `--radius-*` (sm, default, lg, full) +- **Shadows**: `--shadow-*` (default, md) + +### How Tailwind v4 @theme Works +Declaring `--color-primary: #2563eb` inside `@theme` in `tokens.css` automatically generates utilities like `bg-primary`, `text-primary`, `border-primary`. No JavaScript config file needed — the CSS file is the config. + +--- + +## 3. Component Taxonomy + +### `src/components/atoms/` — Atoms +Single-purpose, atomic building blocks. Each wraps a single native element or interaction pattern with no domain logic. +- Button, IconButton +- Input, Textarea, Select, Autocomplete +- Checkbox, Radio/RadioGroup, Switch +- Slider, RangeSlider +- FileInput +- Badge, Tag, Chip +- Tabs (TabList, Tab, TabPanel) +- List (ListItem, ListSubheader, ListDivider) +- Avatar, Tooltip + +### `src/components/molecules/` — Molecules +Small compositions of atoms into reusable units. May combine icons, text, buttons, or other atoms. +- Alert, Accordion, Card, Dialog, Popover +- DataTable + +### `src/components/organisms/` — Organisms +Larger compositions that carry domain semantics or define page-level regions. Built from atoms and molecules. +- TopBar, SideNav, PageHeader +- *(planned)* AppShell (header + sidebar + content area) +- *(planned)* DatePicker + +### Which Tier Does a Component Belong To? + +| Question | If yes → | +|---|---| +| Does it wrap a single native element or a single interaction pattern (button, input, toggle)? | **atoms/** | +| Does it compose 2+ atoms into a reusable unit (e.g., Alert = icon + text + close button)? | **molecules/** | +| Does it carry domain-specific naming or logic (e.g., ThemeCard, ParticipantRow)? | **organisms/** | +| Does it define a page-level region or shell (header, sidebar, content area)? | **organisms/** | + +When in doubt: start in `atoms/`. Promote to `molecules/` when a component begins importing other atoms. + +--- + +## 4. Styling Approach + +- **Primary**: Tailwind utility classes +- **Conditional classes**: `cn()` from `@/lib/utils` (clsx + tailwind-merge) +- **Token values**: Always from `src/tokens/tokens.css`, never hardcoded +- **Token discipline**: Components reference semantic or domain-specific tokens (form-control, button), not palette tokens. If the needed token doesn't exist, add it to `tokens.css` before using a raw palette value. +- **No CSS modules, no styled-components, no inline styles** (except truly dynamic values) +- **Class ordering**: Enforced by `prettier-plugin-tailwindcss` + +--- + +## 5. Storybook Conventions + +- Every component has a co-located `.stories.tsx` file +- All stories use `tags: ['autodocs']` for auto-generated docs +- Stories cover: default state, all variants, edge cases, disabled/error states +- A11y addon runs on all stories — violations should be addressed +- MCP addon enabled at `localhost:6006/mcp` for AI-assisted development + +--- + +## 6. Project Structure + +``` +src/ +├── components/ +│ ├── atoms/ # Single-purpose elements +│ ├── molecules/ # Small compositions of atoms +│ └── organisms/ # Domain-aware / page-level components +├── tokens/ +│ └── tokens.css # Design tokens (@theme block) +├── styles/ +│ └── global.css # Tailwind imports + base styles +├── lib/ +│ └── utils.ts # cn() utility +├── hooks/ # Custom React hooks +├── App.tsx # Root component +└── main.tsx # Vite entry point +``` + +--- + +## 7. Design Tool Integration + +### Figma +- Project file: https://www.figma.com/design/mrabO6AtxN3ektGiTk0I9c/ResearchInsights (file key: `mrabO6AtxN3ektGiTk0I9c`) +- MCP server: Official Figma Remote MCP at `https://mcp.figma.com/mcp` (HTTP transport, OAuth auth) +- Key tools: `get_design_context`, `get_variable_defs`, `get_screenshot`, `search_design_system`, `use_figma` +- Design tokens extracted via `get_variable_defs` → mapped to `@theme` values in `tokens.css` + +### Code Connect +- Links Figma components to their React implementations +- Once linked, `get_design_context` returns actual component code instead of generic markup +- Mapped as we build each component via `add_code_connect_map` (label: "React") + +### Storybook MCP +- Available at `localhost:6006/mcp` when Storybook dev server is running +- Provides: component listing, documentation retrieval, story generation, a11y testing +- `@storybook/addon-designs` embeds Figma frames in the story panel for side-by-side comparison +- `@storybook/addon-mcp` serves the MCP endpoint + +### claude2figma Skills +- 4 skills in `.claude/skills/` that enforce design system compliance when writing to Figma +- **figma-preflight**: Validates MCP connection, audits libraries, builds a Token Map of all styles/variables +- **component-rules**: Library-first lookup, Auto Layout conventions, semantic node naming +- **figma-style-binding**: All visual values must bind to Figma Styles or Variables, never hardcoded; includes post-write QA +- **reference-interpreter**: Converts screenshots/references into structured Design Briefs mapped to design tokens diff --git a/src/components/atoms/Autocomplete/Autocomplete.stories.tsx b/src/components/atoms/Autocomplete/Autocomplete.stories.tsx new file mode 100644 index 0000000..cf706ff --- /dev/null +++ b/src/components/atoms/Autocomplete/Autocomplete.stories.tsx @@ -0,0 +1,91 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { useState } from 'react' +import { Autocomplete } from './Autocomplete' + +const states = [ + { value: 'nsw', label: 'New South Wales' }, + { value: 'vic', label: 'Victoria' }, + { value: 'qld', label: 'Queensland' }, + { value: 'wa', label: 'Western Australia' }, + { value: 'sa', label: 'South Australia' }, + { value: 'tas', label: 'Tasmania' }, + { value: 'act', label: 'Australian Capital Territory' }, + { value: 'nt', label: 'Northern Territory' }, +] + +const meta: Meta = { + title: 'Atoms/Autocomplete', + component: Autocomplete, + tags: ['autodocs'], + parameters: { layout: 'padded' }, +} + +export default meta +type Story = StoryObj + +const BasicTemplate = () => { + const [value, setValue] = useState('') + return ( +
+ +
+ ) +} + +export const Default: Story = { + render: () => , +} + +const FreeSoloTemplate = () => { + const [value, setValue] = useState('') + return ( +
+ +
+ ) +} + +export const FreeSolo: Story = { + name: 'Free solo (Combobox)', + render: () => , +} + +export const WithError: Story = { + name: 'With error', + render: () => ( +
+ +
+ ), +} + +export const Disabled: Story = { + render: () => ( +
+ +
+ ), +} diff --git a/src/components/atoms/Autocomplete/Autocomplete.tsx b/src/components/atoms/Autocomplete/Autocomplete.tsx new file mode 100644 index 0000000..3c3391d --- /dev/null +++ b/src/components/atoms/Autocomplete/Autocomplete.tsx @@ -0,0 +1,216 @@ +import { + forwardRef, + useCallback, + useEffect, + useId, + useMemo, + useRef, + useState, + type HTMLAttributes, +} from 'react' +import { cn } from '@/lib/utils' + +export interface AutocompleteOption { + value: string + label: string + disabled?: boolean +} + +export interface AutocompleteProps extends Omit, 'onChange'> { + label: string + description?: string + hint?: string + error?: string + placeholder?: string + options: AutocompleteOption[] + value?: string + onChange?: (value: string) => void + freeSolo?: boolean + disabled?: boolean + loading?: boolean + noResultsText?: string +} + +const ChevronIcon = () => ( + + + +) + +export const Autocomplete = forwardRef( + ( + { + label, + description, + hint, + error, + placeholder, + options, + value, + onChange, + freeSolo = false, + disabled = false, + loading = false, + noResultsText = 'No results found', + className, + ...props + }, + ref, + ) => { + const id = useId() + const inputRef = useRef(null) + const listRef = useRef(null) + const [open, setOpen] = useState(false) + const [query, setQuery] = useState('') + const [activeIndex, setActiveIndex] = useState(-1) + + const selectedOption = options.find((o) => o.value === value) + + const filtered = useMemo(() => { + if (!query) return options + const q = query.toLowerCase() + return options.filter((o) => o.label.toLowerCase().includes(q)) + }, [options, query]) + + const selectOption = useCallback( + (opt: AutocompleteOption) => { + onChange?.(opt.value) + setQuery(opt.label) + setOpen(false) + setActiveIndex(-1) + }, + [onChange], + ) + + const handleInputChange = useCallback( + (e: React.ChangeEvent) => { + setQuery(e.target.value) + setOpen(true) + setActiveIndex(-1) + if (freeSolo) { + onChange?.(e.target.value) + } + }, + [freeSolo, onChange], + ) + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (!open && (e.key === 'ArrowDown' || e.key === 'Enter')) { + setOpen(true) + return + } + if (!open) return + + if (e.key === 'ArrowDown') { + e.preventDefault() + setActiveIndex((i) => (i + 1) % filtered.length) + } else if (e.key === 'ArrowUp') { + e.preventDefault() + setActiveIndex((i) => (i <= 0 ? filtered.length - 1 : i - 1)) + } else if (e.key === 'Enter' && activeIndex >= 0) { + e.preventDefault() + const opt = filtered[activeIndex] + if (opt && !opt.disabled) selectOption(opt) + } else if (e.key === 'Escape') { + setOpen(false) + } + }, + [open, filtered, activeIndex, selectOption], + ) + + useEffect(() => { + if (selectedOption && !open) { + setQuery(selectedOption.label) + } + }, [selectedOption, open]) + + useEffect(() => { + if (!open) return + const handleClick = (e: MouseEvent) => { + const el = (ref as React.RefObject)?.current + if (el && !el.contains(e.target as Node)) setOpen(false) + } + document.addEventListener('mousedown', handleClick) + return () => document.removeEventListener('mousedown', handleClick) + }, [open, ref]) + + const listboxId = `${id}-listbox` + const hasError = !!error + + return ( +
+ + {description &&

{description}

} + +
+ = 0 ? `${id}-opt-${activeIndex}` : undefined} + aria-invalid={hasError || undefined} + disabled={disabled} + placeholder={placeholder} + value={query} + onChange={handleInputChange} + onFocus={() => setOpen(true)} + onKeyDown={handleKeyDown} + className={cn( + 'h-14 w-full rounded-default border bg-control-bg px-4 pr-10 text-body text-text outline-none transition-colors', + 'focus:border-primary focus:ring-2 focus:ring-control-focus-ring', + hasError ? 'border-control-error' : 'border-control-border hover:border-primary', + disabled && 'pointer-events-none opacity-55', + )} + /> + + + +
+ + {open && !disabled && ( +
    + {loading ? ( +
  • Loading…
  • + ) : filtered.length === 0 ? ( +
  • {noResultsText}
  • + ) : ( + filtered.map((opt, i) => ( +
  • !opt.disabled && selectOption(opt)} + className={cn( + 'cursor-pointer px-4 py-3 text-body transition-colors', + opt.value === value && 'bg-info/12 font-bold', + i === activeIndex && opt.value !== value && 'bg-info/5', + opt.disabled && 'pointer-events-none opacity-55', + )} + > + {opt.label} +
  • + )) + )} +
+ )} + + {hint && !error &&

{hint}

} + {error &&

{error}

} +
+ ) + }, +) +Autocomplete.displayName = 'Autocomplete' diff --git a/src/components/atoms/Autocomplete/index.ts b/src/components/atoms/Autocomplete/index.ts new file mode 100644 index 0000000..aa9f2fd --- /dev/null +++ b/src/components/atoms/Autocomplete/index.ts @@ -0,0 +1,2 @@ +export { Autocomplete } from './Autocomplete' +export type { AutocompleteProps, AutocompleteOption } from './Autocomplete' diff --git a/src/components/atoms/Avatar/Avatar.stories.tsx b/src/components/atoms/Avatar/Avatar.stories.tsx new file mode 100644 index 0000000..33d34ed --- /dev/null +++ b/src/components/atoms/Avatar/Avatar.stories.tsx @@ -0,0 +1,49 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { Avatar } from './Avatar' + +const meta: Meta = { + title: 'Atoms/Avatar', + component: Avatar, + tags: ['autodocs'], + parameters: { layout: 'centered' }, +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + args: { initials: 'DW' }, +} + +export const Small: Story = { + args: { initials: 'SR', size: 'sm' }, +} + +export const Large: Story = { + args: { initials: 'JB', size: 'lg' }, +} + +export const SingleInitial: Story = { + name: 'Single initial', + args: { initials: 'R', size: 'default' }, +} + +export const AllSizes: Story = { + name: 'All sizes', + render: () => ( +
+ + + +
+ ), +} + +export const OnDarkBackground: Story = { + name: 'On dark background', + render: () => ( +
+ +
+ ), +} diff --git a/src/components/atoms/Avatar/Avatar.tsx b/src/components/atoms/Avatar/Avatar.tsx new file mode 100644 index 0000000..86489dc --- /dev/null +++ b/src/components/atoms/Avatar/Avatar.tsx @@ -0,0 +1,46 @@ +import { forwardRef, type HTMLAttributes } from 'react' +import { cn } from '@/lib/utils' + +export interface AvatarProps extends HTMLAttributes { + initials: string + src?: string + alt?: string + size?: 'sm' | 'default' | 'lg' +} + +const sizeStyles = { + sm: 'size-8 text-caption', + default: 'size-10 text-body', + lg: 'size-12 text-[18px]', +} + +export const Avatar = forwardRef( + ({ initials, src, alt, size = 'default', className, ...props }, ref) => { + const label = alt || initials + + return ( +
+ {src ? ( + {label} + ) : ( + initials.slice(0, 2).toUpperCase() + )} +
+ ) + }, +) +Avatar.displayName = 'Avatar' diff --git a/src/components/atoms/Avatar/index.ts b/src/components/atoms/Avatar/index.ts new file mode 100644 index 0000000..11f8055 --- /dev/null +++ b/src/components/atoms/Avatar/index.ts @@ -0,0 +1,2 @@ +export { Avatar } from './Avatar' +export type { AvatarProps } from './Avatar' diff --git a/src/components/atoms/Badge/Badge.tsx b/src/components/atoms/Badge/Badge.tsx index b570305..718da89 100644 --- a/src/components/atoms/Badge/Badge.tsx +++ b/src/components/atoms/Badge/Badge.tsx @@ -21,7 +21,7 @@ export interface BadgeProps extends HTMLAttributes { const variantStyles: Record = { navy: 'bg-badge-navy text-white', info: 'bg-badge-info text-white', - 'info-light': 'bg-badge-info-light text-primary-dark', + 'info-light': 'bg-badge-info-light text-primary', success: 'bg-badge-success text-white', 'success-light': 'bg-badge-success-light text-badge-on-success-light', error: 'bg-badge-error text-white', @@ -29,7 +29,7 @@ const variantStyles: Record = { warning: 'bg-badge-warning text-white', 'warning-light': 'bg-badge-warning-light text-badge-on-warning-light', neutral: 'bg-badge-neutral text-text-secondary', - white: 'bg-surface text-primary-dark border border-primary-dark', + white: 'bg-surface text-primary border border-primary', } export function Badge({ diff --git a/src/components/atoms/Button/Button.tsx b/src/components/atoms/Button/Button.tsx index a48c9ac..a0d3cb0 100644 --- a/src/components/atoms/Button/Button.tsx +++ b/src/components/atoms/Button/Button.tsx @@ -38,7 +38,7 @@ const variantIntentStyles: Record> = { } const sizeStyles: Record = { - default: 'h-12 px-6 text-body gap-2', + default: 'h-11 px-6 text-body gap-2', comfortable: 'h-10 px-5 text-body gap-2', compact: 'h-9 px-4 text-small gap-1.5', } @@ -89,7 +89,7 @@ export const Button = forwardRef( disabled={isDisabled} aria-busy={loading || undefined} className={cn( - 'inline-flex items-center justify-center rounded-full font-bold transition-colors', + 'inline-flex items-center justify-center rounded-full font-semibold transition-colors', 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-button-default', sizeStyles[size], variantIntentStyles[variant][intent], diff --git a/src/components/atoms/Chip/Chip.tsx b/src/components/atoms/Chip/Chip.tsx index 5fff2c4..fe61fae 100644 --- a/src/components/atoms/Chip/Chip.tsx +++ b/src/components/atoms/Chip/Chip.tsx @@ -1,9 +1,12 @@ import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from 'react' import { cn } from '@/lib/utils' -export interface ChipProps extends Omit, 'children'> { +export type ChipColor = 'default' | 'info' | 'error' | 'warning' | 'success' + +export interface ChipProps extends Omit, 'children' | 'color'> { children: ReactNode selected?: boolean + color?: ChipColor onDismiss?: () => void rightIcon?: ReactNode } @@ -24,8 +27,16 @@ const DismissIcon = () => ( ) +const colorStyles: Record = { + default: 'bg-grey-04/40 text-text-secondary', + info: 'bg-blue-04/60 text-info', + error: 'bg-red-04/60 text-error', + warning: 'bg-orange-04/60 text-warning', + success: 'bg-green-04/60 text-success', +} + export const Chip = forwardRef( - ({ children, selected = false, onDismiss, rightIcon, disabled, className, onClick, ...props }, ref) => { + ({ children, selected = false, color, onDismiss, rightIcon, disabled, className, onClick, ...props }, ref) => { const isInteractive = !!(onClick || onDismiss) const Component = isInteractive ? 'button' : 'span' @@ -49,10 +60,12 @@ export const Chip = forwardRef( : undefined } className={cn( - 'inline-flex h-8 items-center gap-2 rounded-lg border px-3 py-1.5 text-small leading-[19px]', + 'inline-flex h-[30px] items-center gap-2 rounded-default px-3 py-1.5 text-small font-bold leading-[19px]', selected - ? 'border-chip-selected-bg bg-chip-selected-bg text-chip-selected-text' - : 'border-chip-border bg-chip-bg text-chip-text', + ? 'border border-chip-selected-bg bg-chip-selected-bg text-chip-selected-text' + : color && color !== 'default' + ? cn('border-transparent', colorStyles[color]) + : 'border border-chip-border bg-chip-bg text-chip-text', isInteractive && !disabled && !selected && 'hover:bg-chip-border/5 active:bg-chip-border/10', isInteractive && !disabled && selected && 'hover:bg-chip-selected-bg/90 active:bg-chip-selected-bg/80', isInteractive && 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-chip-border', diff --git a/src/components/atoms/Chip/index.ts b/src/components/atoms/Chip/index.ts index d806682..ccb4b54 100644 --- a/src/components/atoms/Chip/index.ts +++ b/src/components/atoms/Chip/index.ts @@ -1,2 +1,2 @@ export { Chip } from './Chip' -export type { ChipProps } from './Chip' +export type { ChipProps, ChipColor } from './Chip' diff --git a/src/components/atoms/FileInput/FileInput.stories.tsx b/src/components/atoms/FileInput/FileInput.stories.tsx new file mode 100644 index 0000000..bb60901 --- /dev/null +++ b/src/components/atoms/FileInput/FileInput.stories.tsx @@ -0,0 +1,42 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { FileInput } from './FileInput' + +const meta: Meta = { + title: 'Atoms/FileInput', + component: FileInput, + tags: ['autodocs'], + parameters: { layout: 'padded' }, +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + args: { + label: 'Upload document', + description: 'Supported formats: PDF, DOCX, PNG', + }, +} + +export const Multiple: Story = { + args: { + label: 'Upload files', + multiple: true, + accept: '.pdf,.docx,.png,.jpg', + }, +} + +export const WithError: Story = { + name: 'With error', + args: { + label: 'Upload evidence', + error: 'File size must be under 10MB', + }, +} + +export const Disabled: Story = { + args: { + label: 'Upload document', + disabled: true, + }, +} diff --git a/src/components/atoms/FileInput/FileInput.tsx b/src/components/atoms/FileInput/FileInput.tsx new file mode 100644 index 0000000..c819ba5 --- /dev/null +++ b/src/components/atoms/FileInput/FileInput.tsx @@ -0,0 +1,120 @@ +import { forwardRef, useCallback, useRef, useState, type HTMLAttributes } from 'react' +import { cn } from '@/lib/utils' + +export interface FileInputProps extends Omit, 'onChange'> { + label: string + description?: string + error?: string + accept?: string + multiple?: boolean + disabled?: boolean + onChange?: (files: File[]) => void +} + +const PaperclipIcon = () => ( + + + +) + +export const FileInput = forwardRef( + ({ label, description, error, accept, multiple = false, disabled = false, onChange, className, ...props }, ref) => { + const inputRef = useRef(null) + const [files, setFiles] = useState([]) + + const handleFiles = useCallback( + (fileList: FileList | null) => { + if (!fileList) return + const next = Array.from(fileList) + setFiles(next) + onChange?.(next) + }, + [onChange], + ) + + const removeFile = useCallback( + (index: number) => { + setFiles((prev) => { + const next = prev.filter((_, i) => i !== index) + onChange?.(next) + return next + }) + if (inputRef.current) inputRef.current.value = '' + }, + [onChange], + ) + + const displayText = files.length > 0 + ? files.map((f) => f.name).join(', ') + : `Select file${multiple ? 's' : ''}…` + + return ( +
+ + {description &&

{description}

} + + + + handleFiles(e.target.files)} + className="sr-only" + tabIndex={-1} + /> + + {files.length > 0 && ( +
+ {files.map((file, i) => ( + + {file.name} + + + ))} +
+ )} + + {error &&

{error}

} +
+ ) + }, +) +FileInput.displayName = 'FileInput' diff --git a/src/components/atoms/FileInput/index.ts b/src/components/atoms/FileInput/index.ts new file mode 100644 index 0000000..73c0b3a --- /dev/null +++ b/src/components/atoms/FileInput/index.ts @@ -0,0 +1,2 @@ +export { FileInput } from './FileInput' +export type { FileInputProps } from './FileInput' diff --git a/src/components/atoms/Input/Input.tsx b/src/components/atoms/Input/Input.tsx index d4befd2..de6eb67 100644 --- a/src/components/atoms/Input/Input.tsx +++ b/src/components/atoms/Input/Input.tsx @@ -117,7 +117,7 @@ export const Input = forwardRef( {leftIcon && ( svg]:size-full', styles.icon)} + className={cn('inline-flex shrink-0 items-center justify-center text-primary [&>svg]:size-full', styles.icon)} > {leftIcon} @@ -144,7 +144,7 @@ export const Input = forwardRef( {rightIcon && ( svg]:size-full', styles.icon)} + className={cn('inline-flex shrink-0 items-center justify-center text-primary [&>svg]:size-full', styles.icon)} > {rightIcon} diff --git a/src/components/atoms/List/List.stories.tsx b/src/components/atoms/List/List.stories.tsx new file mode 100644 index 0000000..e956f62 --- /dev/null +++ b/src/components/atoms/List/List.stories.tsx @@ -0,0 +1,57 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { List, ListItem, ListSubheader, ListDivider } from './List' + +const HomeIcon = () => ( + +) +const StarIcon = () => ( + +) +const SettingsIcon = () => ( + +) + +const meta: Meta = { + title: 'Atoms/List', + component: List, + tags: ['autodocs'], + parameters: { layout: 'padded' }, +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + render: () => ( + + }>Real-Time + }>Audience + }>Conversions + + ), +} + +export const WithActive: Story = { + name: 'With active item', + render: () => ( + + } active>Real-Time + }>Audience + }>Conversions + + ), +} + +export const WithSubheaders: Story = { + name: 'With subheaders', + render: () => ( + + Reports + }>Real-Time + }>Audience + + Settings + }>Preferences + + ), +} diff --git a/src/components/atoms/List/List.tsx b/src/components/atoms/List/List.tsx new file mode 100644 index 0000000..fabd523 --- /dev/null +++ b/src/components/atoms/List/List.tsx @@ -0,0 +1,96 @@ +import { forwardRef, type HTMLAttributes, type ReactNode } from 'react' +import { cn } from '@/lib/utils' + +// --- List --- + +export interface ListProps extends HTMLAttributes {} + +export const List = forwardRef( + ({ className, children, ...props }, ref) => ( +
    + {children} +
+ ), +) +List.displayName = 'List' + +// --- ListItem --- + +export interface ListItemProps extends HTMLAttributes { + icon?: ReactNode + active?: boolean + disabled?: boolean + href?: string +} + +export const ListItem = forwardRef( + ({ icon, active = false, disabled = false, href, className, children, ...props }, ref) => { + const styles = cn( + 'flex min-h-12 items-center gap-4 px-4 py-2 transition-colors', + active + ? 'bg-info/12 text-info' + : 'text-text hover:bg-text/[0.04]', + disabled && 'pointer-events-none opacity-55', + className, + ) + + const content = ( + <> + {icon && ( + svg]:size-full', active ? 'text-info' : 'text-text-secondary')}> + {icon} + + )} + {children} + + ) + + if (href) { + return ( +
  • + {content} +
  • + ) + } + + return ( +
  • + {content} +
  • + ) + }, +) +ListItem.displayName = 'ListItem' + +// --- ListSubheader --- + +export interface ListSubheaderProps extends HTMLAttributes {} + +export const ListSubheader = forwardRef( + ({ className, children, ...props }, ref) => ( +
  • + {children} +
  • + ), +) +ListSubheader.displayName = 'ListSubheader' + +// --- ListDivider --- + +export interface ListDividerProps extends HTMLAttributes {} + +export const ListDivider = forwardRef( + ({ className, ...props }, ref) => ( +
  • + ), +) +ListDivider.displayName = 'ListDivider' diff --git a/src/components/atoms/List/index.ts b/src/components/atoms/List/index.ts new file mode 100644 index 0000000..45f018a --- /dev/null +++ b/src/components/atoms/List/index.ts @@ -0,0 +1,2 @@ +export { List, ListItem, ListSubheader, ListDivider } from './List' +export type { ListProps, ListItemProps, ListSubheaderProps, ListDividerProps } from './List' diff --git a/src/components/atoms/Select/Select.tsx b/src/components/atoms/Select/Select.tsx index 968e760..61fb0e3 100644 --- a/src/components/atoms/Select/Select.tsx +++ b/src/components/atoms/Select/Select.tsx @@ -311,8 +311,8 @@ export const Select = forwardRef( }} className={cn( 'cursor-pointer px-4 py-2.5 text-body text-text transition-colors', - option.value === selectedValue && 'bg-primary/12 font-bold', - index === activeIndex && option.value !== selectedValue && 'bg-primary/5', + option.value === selectedValue && 'bg-info/12 font-bold', + index === activeIndex && option.value !== selectedValue && 'bg-info/5', option.disabled && 'pointer-events-none text-text/30', )} > diff --git a/src/components/atoms/Slider/Slider.stories.tsx b/src/components/atoms/Slider/Slider.stories.tsx new file mode 100644 index 0000000..7e61dfd --- /dev/null +++ b/src/components/atoms/Slider/Slider.stories.tsx @@ -0,0 +1,63 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { useState } from 'react' +import { Slider, RangeSlider } from './Slider' + +const meta: Meta = { + title: 'Atoms/Slider', + component: Slider, + tags: ['autodocs'], + parameters: { layout: 'padded' }, +} + +export default meta +type Story = StoryObj + +const SliderTemplate = () => { + const [value, setValue] = useState(40) + return ( +
    + +

    Value: {value}

    +
    + ) +} + +export const Default: Story = { + render: () => , +} + +const SteppedTemplate = () => { + const [value, setValue] = useState(50) + return ( +
    + +

    Value: {value}

    +
    + ) +} + +export const Stepped: Story = { + render: () => , +} + +export const Disabled: Story = { + render: () => ( +
    + {}} disabled /> +
    + ), +} + +const RangeTemplate = () => { + const [value, setValue] = useState<[number, number]>([20, 80]) + return ( +
    + +

    Range: {value[0]} – {value[1]}

    +
    + ) +} + +export const Range: Story = { + render: () => , +} diff --git a/src/components/atoms/Slider/Slider.tsx b/src/components/atoms/Slider/Slider.tsx new file mode 100644 index 0000000..6e747f5 --- /dev/null +++ b/src/components/atoms/Slider/Slider.tsx @@ -0,0 +1,201 @@ +import { forwardRef, useCallback, useRef, type HTMLAttributes } from 'react' +import { cn } from '@/lib/utils' + +export interface SliderProps extends Omit, 'onChange'> { + label?: string + value: number + onChange: (value: number) => void + min?: number + max?: number + step?: number + disabled?: boolean +} + +export const Slider = forwardRef( + ({ label, value, onChange, min = 0, max = 100, step = 1, disabled = false, className, ...props }, ref) => { + const trackRef = useRef(null) + const percent = ((value - min) / (max - min)) * 100 + + const handlePointer = useCallback( + (clientX: number) => { + const track = trackRef.current + if (!track || disabled) return + const rect = track.getBoundingClientRect() + const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)) + const raw = min + ratio * (max - min) + const stepped = Math.round(raw / step) * step + onChange(Math.max(min, Math.min(max, stepped))) + }, + [min, max, step, disabled, onChange], + ) + + const onPointerDown = (e: React.PointerEvent) => { + if (disabled) return + e.currentTarget.setPointerCapture(e.pointerId) + handlePointer(e.clientX) + } + + const onPointerMove = (e: React.PointerEvent) => { + if (e.currentTarget.hasPointerCapture(e.pointerId)) { + handlePointer(e.clientX) + } + } + + return ( +
    + {label && ( + + )} +
    { + if (disabled) return + if (e.key === 'ArrowRight' || e.key === 'ArrowUp') { + e.preventDefault() + onChange(Math.min(max, value + step)) + } else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') { + e.preventDefault() + onChange(Math.max(min, value - step)) + } else if (e.key === 'Home') { + e.preventDefault() + onChange(min) + } else if (e.key === 'End') { + e.preventDefault() + onChange(max) + } + }} + > +
    +
    +
    +
    +
    +
    + ) + }, +) +Slider.displayName = 'Slider' + +// --- RangeSlider --- + +export interface RangeSliderProps extends Omit, 'onChange'> { + label?: string + value: [number, number] + onChange: (value: [number, number]) => void + min?: number + max?: number + step?: number + disabled?: boolean +} + +export const RangeSlider = forwardRef( + ({ label, value, onChange, min = 0, max = 100, step = 1, disabled = false, className, ...props }, ref) => { + const trackRef = useRef(null) + const activeThumb = useRef<0 | 1>(0) + + const toPercent = (v: number) => ((v - min) / (max - min)) * 100 + const lowPct = toPercent(value[0]) + const highPct = toPercent(value[1]) + + const snap = useCallback( + (clientX: number) => { + const track = trackRef.current + if (!track || disabled) return + const rect = track.getBoundingClientRect() + const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)) + const raw = min + ratio * (max - min) + const stepped = Math.round(raw / step) * step + const clamped = Math.max(min, Math.min(max, stepped)) + + const next: [number, number] = [...value] + if (activeThumb.current === 0) { + next[0] = Math.min(clamped, value[1]) + } else { + next[1] = Math.max(clamped, value[0]) + } + onChange(next) + }, + [min, max, step, value, disabled, onChange], + ) + + const pickThumb = (clientX: number) => { + const track = trackRef.current + if (!track) return + const rect = track.getBoundingClientRect() + const ratio = (clientX - rect.left) / rect.width + const pos = min + ratio * (max - min) + activeThumb.current = Math.abs(pos - value[0]) <= Math.abs(pos - value[1]) ? 0 : 1 + } + + return ( +
    + {label && ( + + )} +
    { + if (disabled) return + e.currentTarget.setPointerCapture(e.pointerId) + pickThumb(e.clientX) + snap(e.clientX) + }} + onPointerMove={(e) => { + if (e.currentTarget.hasPointerCapture(e.pointerId)) snap(e.clientX) + }} + > +
    +
    +
    + {[lowPct, highPct].map((pct, i) => ( +
    { activeThumb.current = i as 0 | 1 }} + onKeyDown={(e) => { + if (disabled) return + const delta = e.key === 'ArrowRight' || e.key === 'ArrowUp' ? step + : e.key === 'ArrowLeft' || e.key === 'ArrowDown' ? -step + : e.key === 'Home' ? min - value[i] + : e.key === 'End' ? max - value[i] + : 0 + if (!delta) return + e.preventDefault() + const next: [number, number] = [...value] + next[i] = Math.max(min, Math.min(max, value[i] + delta)) + if (next[0] > next[1]) return + onChange(next) + }} + /> + ))} +
    +
    + ) + }, +) +RangeSlider.displayName = 'RangeSlider' diff --git a/src/components/atoms/Slider/index.ts b/src/components/atoms/Slider/index.ts new file mode 100644 index 0000000..3cde825 --- /dev/null +++ b/src/components/atoms/Slider/index.ts @@ -0,0 +1,2 @@ +export { Slider, RangeSlider } from './Slider' +export type { SliderProps, RangeSliderProps } from './Slider' diff --git a/src/components/atoms/Switch/Switch.tsx b/src/components/atoms/Switch/Switch.tsx index a5478b0..8707585 100644 --- a/src/components/atoms/Switch/Switch.tsx +++ b/src/components/atoms/Switch/Switch.tsx @@ -40,9 +40,9 @@ export const Switch = forwardRef( disabled={disabled} onClick={() => onChange?.(!checked)} className={cn( - 'relative inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full transition-colors duration-150', - checked ? 'bg-control-checked' : 'bg-control-border', - !disabled && checked && 'hover:bg-control-checked-hover', + 'relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full transition-colors duration-150', + checked ? 'bg-switch-on' : 'bg-control-border', + !disabled && checked && 'hover:bg-switch-on-hover', !disabled && !checked && 'hover:bg-control-border-hover', 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-control-focus-ring focus-visible:ring-offset-2', 'active:scale-[0.97]', @@ -52,8 +52,8 @@ export const Switch = forwardRef( > diff --git a/src/components/atoms/Tabs/Tabs.stories.tsx b/src/components/atoms/Tabs/Tabs.stories.tsx new file mode 100644 index 0000000..3fa3661 --- /dev/null +++ b/src/components/atoms/Tabs/Tabs.stories.tsx @@ -0,0 +1,82 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { useState } from 'react' +import { Tabs, TabList, Tab, TabPanel } from './Tabs' + +const meta: Meta = { + title: 'Atoms/Tabs', + component: Tabs, + tags: ['autodocs'], + parameters: { layout: 'padded' }, +} + +export default meta +type Story = StoryObj + +const BasicTemplate = () => { + const [value, setValue] = useState('tab1') + return ( + + + Overview + Details + History + + Overview content goes here. + Details content goes here. + History content goes here. + + ) +} + +export const Default: Story = { + render: () => , +} + +const WithIconsTemplate = () => { + const [value, setValue] = useState('status') + + const StatusIcon = () => ( + + ) + const DetailsIcon = () => ( + + ) + + return ( + + + }>Status + }>Details + Disabled + + Status panel content. + Details panel content. + + ) +} + +export const WithIcons: Story = { + name: 'With icons', + render: () => , +} + +const ManyTabsTemplate = () => { + const [value, setValue] = useState('tab1') + return ( + + + {Array.from({ length: 8 }, (_, i) => ( + Tab {i + 1} + ))} + + {Array.from({ length: 8 }, (_, i) => ( + Content for tab {i + 1} + ))} + + ) +} + +export const ManyTabs: Story = { + name: 'Many tabs', + render: () => , +} diff --git a/src/components/atoms/Tabs/Tabs.tsx b/src/components/atoms/Tabs/Tabs.tsx new file mode 100644 index 0000000..0af6088 --- /dev/null +++ b/src/components/atoms/Tabs/Tabs.tsx @@ -0,0 +1,141 @@ +import { + createContext, + forwardRef, + useContext, + useId, + useMemo, + type HTMLAttributes, + type ReactNode, +} from 'react' +import { cn } from '@/lib/utils' + +// --- Context --- + +interface TabsContextValue { + value: string + onChange: (value: string) => void + baseId: string +} + +const TabsContext = createContext(null) + +function useTabsContext() { + const ctx = useContext(TabsContext) + if (!ctx) throw new Error('Tab components must be used within Tabs') + return ctx +} + +// --- Tabs --- + +export interface TabsProps extends Omit, 'onChange'> { + value: string + onChange: (value: string) => void +} + +export const Tabs = forwardRef( + ({ value, onChange, className, children, ...props }, ref) => { + const baseId = useId() + const ctx = useMemo(() => ({ value, onChange, baseId }), [value, onChange, baseId]) + + return ( + +
    + {children} +
    +
    + ) + }, +) +Tabs.displayName = 'Tabs' + +// --- TabList --- + +export interface TabListProps extends HTMLAttributes {} + +export const TabList = forwardRef( + ({ className, children, ...props }, ref) => ( +
    + {children} +
    + ), +) +TabList.displayName = 'TabList' + +// --- Tab --- + +export interface TabProps extends HTMLAttributes { + value: string + icon?: ReactNode + disabled?: boolean +} + +export const Tab = forwardRef( + ({ value, icon, disabled = false, className, children, ...props }, ref) => { + const { value: selected, onChange, baseId } = useTabsContext() + const isSelected = value === selected + + return ( + + ) + }, +) +Tab.displayName = 'Tab' + +// --- TabPanel --- + +export interface TabPanelProps extends HTMLAttributes { + value: string +} + +export const TabPanel = forwardRef( + ({ value, className, children, ...props }, ref) => { + const { value: selected, baseId } = useTabsContext() + const isSelected = value === selected + + if (!isSelected) return null + + return ( +
    + {children} +
    + ) + }, +) +TabPanel.displayName = 'TabPanel' diff --git a/src/components/atoms/Tabs/index.ts b/src/components/atoms/Tabs/index.ts new file mode 100644 index 0000000..605d158 --- /dev/null +++ b/src/components/atoms/Tabs/index.ts @@ -0,0 +1,2 @@ +export { Tabs, TabList, Tab, TabPanel } from './Tabs' +export type { TabsProps, TabListProps, TabProps, TabPanelProps } from './Tabs' diff --git a/src/components/atoms/Tag/Tag.tsx b/src/components/atoms/Tag/Tag.tsx index ac4d1e7..ee0f003 100644 --- a/src/components/atoms/Tag/Tag.tsx +++ b/src/components/atoms/Tag/Tag.tsx @@ -1,7 +1,9 @@ import { forwardRef, type HTMLAttributes, type ReactNode } from 'react' import { cn } from '@/lib/utils' -export type TagColor = 'navy' | 'blue' | 'green' | 'red' | 'orange' | 'grey' +export type TagColor = + | 'navy' | 'blue' | 'green' | 'red' | 'orange' | 'grey' + | 'teal' | 'brown' | 'purple' | 'fuchsia' | 'yellow' export interface TagProps extends HTMLAttributes { variant?: 'outline' | 'filled' | 'light' @@ -42,6 +44,31 @@ const colorVariantStyles: Record> = { filled: 'bg-tag-grey text-white', light: 'bg-tag-grey-light text-tag-grey', }, + teal: { + outline: 'border border-tag-teal text-tag-teal', + filled: 'bg-tag-teal text-white', + light: 'bg-tag-teal-light text-tag-teal', + }, + brown: { + outline: 'border border-tag-brown text-tag-brown', + filled: 'bg-tag-brown text-white', + light: 'bg-tag-brown-light text-tag-brown', + }, + purple: { + outline: 'border border-tag-purple text-tag-purple', + filled: 'bg-tag-purple text-white', + light: 'bg-tag-purple-light text-tag-purple', + }, + fuchsia: { + outline: 'border border-tag-fuchsia text-tag-fuchsia', + filled: 'bg-tag-fuchsia text-white', + light: 'bg-tag-fuchsia-light text-tag-fuchsia', + }, + yellow: { + outline: 'border border-tag-yellow text-tag-yellow', + filled: 'bg-tag-yellow text-white', + light: 'bg-tag-yellow-light text-tag-yellow', + }, } const sizeStyles: Record = { diff --git a/src/components/molecules/Accordion/Accordion.tsx b/src/components/molecules/Accordion/Accordion.tsx index 0f5f47f..9b84d6f 100644 --- a/src/components/molecules/Accordion/Accordion.tsx +++ b/src/components/molecules/Accordion/Accordion.tsx @@ -189,8 +189,8 @@ export const AccordionTrigger = forwardRef toggle(value)} className={cn( 'flex h-16 w-full items-center gap-3 px-6 text-left font-bold text-text transition-colors', - 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary', - isOpen ? 'bg-primary/12' : 'bg-surface hover:bg-primary/5', + 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-info', + isOpen ? 'bg-info/12' : 'bg-surface hover:bg-info/5', disabled && 'pointer-events-none opacity-50', className, )} diff --git a/src/components/molecules/Card/Card.tsx b/src/components/molecules/Card/Card.tsx index 31df479..e8ae667 100644 --- a/src/components/molecules/Card/Card.tsx +++ b/src/components/molecules/Card/Card.tsx @@ -11,7 +11,7 @@ const variantStyles: Record = { surface: 'bg-surface border border-border shadow-default', outlined: 'bg-surface border border-border', elevated: 'bg-surface shadow-md', - filled: 'bg-primary-dark text-white', + filled: 'bg-primary text-white', } export const Card = forwardRef( diff --git a/src/components/molecules/DataTable/DataTable.stories.tsx b/src/components/molecules/DataTable/DataTable.stories.tsx new file mode 100644 index 0000000..d2070f4 --- /dev/null +++ b/src/components/molecules/DataTable/DataTable.stories.tsx @@ -0,0 +1,79 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { DataTable, type DataTableColumn } from './DataTable' + +type Dessert = { + name: string + calories: number + fat: number + carbs: number + protein: number + iron: number +} + +const desserts: Dessert[] = [ + { name: 'Frozen Yogurt', calories: 159, fat: 6, carbs: 24, protein: 4, iron: 1 }, + { name: 'Ice cream sandwich', calories: 237, fat: 9, carbs: 37, protein: 4.3, iron: 1 }, + { name: 'Eclair', calories: 262, fat: 16, carbs: 23, protein: 6, iron: 7 }, + { name: 'Cupcake', calories: 305, fat: 3.7, carbs: 67, protein: 4.3, iron: 8 }, + { name: 'Gingerbread', calories: 356, fat: 16, carbs: 49, protein: 3.9, iron: 16 }, + { name: 'Jelly bean', calories: 375, fat: 0, carbs: 94, protein: 0, iron: 0 }, + { name: 'Lollipop', calories: 392, fat: 0.2, carbs: 98, protein: 0, iron: 2 }, + { name: 'Honeycomb', calories: 408, fat: 3.2, carbs: 87, protein: 6.5, iron: 45 }, + { name: 'Donut', calories: 452, fat: 25, carbs: 51, protein: 4.9, iron: 22 }, + { name: 'KitKat', calories: 518, fat: 26, carbs: 65, protein: 7, iron: 6 }, +] + +const columns: DataTableColumn[] = [ + { key: 'name', header: 'Dessert (100g serving)', sortable: true }, + { key: 'calories', header: 'Calories', sortable: true, align: 'right' }, + { key: 'fat', header: 'Fat (g)', sortable: true, align: 'right' }, + { key: 'carbs', header: 'Carbs (g)', align: 'right' }, + { key: 'protein', header: 'Protein (g)', align: 'right' }, + { key: 'iron', header: 'Iron (%)', align: 'right' }, +] + +const meta: Meta = { + title: 'Molecules/DataTable', + tags: ['autodocs'], + parameters: { layout: 'padded' }, +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + render: () => ( + + ), +} + +export const WithSelection: Story = { + name: 'With selection', + render: () => ( + console.log('Selected:', sel)} + /> + ), +} + +export const CustomPageSize: Story = { + name: 'Custom page size', + render: () => ( + + ), +} + +export const Empty: Story = { + render: () => ( + + ), +} + +export const Loading: Story = { + render: () => ( + + ), +} diff --git a/src/components/molecules/DataTable/DataTable.tsx b/src/components/molecules/DataTable/DataTable.tsx new file mode 100644 index 0000000..4592e8e --- /dev/null +++ b/src/components/molecules/DataTable/DataTable.tsx @@ -0,0 +1,282 @@ +import { + forwardRef, + useCallback, + useMemo, + useState, + type HTMLAttributes, + type ReactNode, +} from 'react' +import { cn } from '@/lib/utils' + +// --- Types --- + +export interface DataTableColumn> { + key: string + header: string + sortable?: boolean + align?: 'left' | 'center' | 'right' + render?: (value: unknown, row: T, index: number) => ReactNode +} + +export interface DataTableProps> extends Omit, 'children'> { + columns: DataTableColumn[] + data: T[] + selectable?: boolean + pagination?: boolean + pageSize?: number + pageSizeOptions?: number[] + loading?: boolean + emptyMessage?: string + onSelectionChange?: (selected: T[]) => void +} + +type SortState = { key: string; dir: 'asc' | 'desc' } | null + +const ChevronUpIcon = () => ( + + + +) + +const ChevronDownIcon = () => ( + + + +) + +// --- DataTable --- + +function DataTableInner>( + { + columns, + data, + selectable = false, + pagination = true, + pageSize: initialPageSize = 5, + pageSizeOptions = [5, 10, 25], + loading = false, + emptyMessage = 'No data available', + onSelectionChange, + className, + ...props + }: DataTableProps, + ref: React.ForwardedRef, +) { + const [sort, setSort] = useState(null) + const [page, setPage] = useState(0) + const [pageSize, setPageSize] = useState(initialPageSize) + const [selected, setSelected] = useState>(new Set()) + + const sortedData = useMemo(() => { + if (!sort) return data + const { key, dir } = sort + return [...data].sort((a, b) => { + const va = a[key] + const vb = b[key] + if (va == null && vb == null) return 0 + if (va == null) return 1 + if (vb == null) return -1 + const cmp = String(va).localeCompare(String(vb), undefined, { numeric: true }) + return dir === 'asc' ? cmp : -cmp + }) + }, [data, sort]) + + const pageCount = pagination ? Math.max(1, Math.ceil(sortedData.length / pageSize)) : 1 + const pageData = pagination ? sortedData.slice(page * pageSize, (page + 1) * pageSize) : sortedData + const rangeStart = page * pageSize + 1 + const rangeEnd = Math.min((page + 1) * pageSize, sortedData.length) + + const toggleSort = useCallback((key: string) => { + setSort((prev) => { + if (prev?.key === key) { + return prev.dir === 'asc' ? { key, dir: 'desc' } : null + } + return { key, dir: 'asc' } + }) + }, []) + + const toggleRow = useCallback( + (index: number) => { + setSelected((prev) => { + const next = new Set(prev) + if (next.has(index)) next.delete(index) + else next.add(index) + onSelectionChange?.( + [...next].map((i) => sortedData[i]).filter(Boolean), + ) + return next + }) + }, + [sortedData, onSelectionChange], + ) + + const toggleAll = useCallback(() => { + setSelected((prev) => { + if (prev.size === sortedData.length) { + onSelectionChange?.([]) + return new Set() + } + const all = new Set(sortedData.map((_, i) => i)) + onSelectionChange?.([...sortedData]) + return all + }) + }, [sortedData, onSelectionChange]) + + return ( +
    +
    + + + + {selectable && ( + + )} + {columns.map((col) => ( + + ))} + + + + {loading ? ( + + + + ) : pageData.length === 0 ? ( + + + + ) : ( + pageData.map((row, rowIdx) => { + const globalIdx = page * pageSize + rowIdx + return ( + + {selectable && ( + + )} + {columns.map((col) => ( + + ))} + + ) + }) + )} + +
    + 0} + onChange={toggleAll} + className="accent-primary" + /> + toggleSort(col.key) : undefined} + > + + {col.header} + {col.sortable && sort?.key === col.key && ( + sort.dir === 'asc' ? : + )} + +
    + Loading… +
    + {emptyMessage} +
    + toggleRow(globalIdx)} + className="accent-primary" + /> + + {col.render + ? col.render(row[col.key], row, globalIdx) + : String(row[col.key] ?? '')} +
    +
    + + {pagination && sortedData.length > 0 && ( +
    + + {rangeStart}-{rangeEnd} of {sortedData.length} +
    + + + + +
    +
    + )} +
    + ) +} + +export const DataTable = forwardRef(DataTableInner) as >( + props: DataTableProps & { ref?: React.Ref }, +) => React.ReactElement | null diff --git a/src/components/molecules/DataTable/index.ts b/src/components/molecules/DataTable/index.ts new file mode 100644 index 0000000..e8fba91 --- /dev/null +++ b/src/components/molecules/DataTable/index.ts @@ -0,0 +1,2 @@ +export { DataTable } from './DataTable' +export type { DataTableProps, DataTableColumn } from './DataTable' diff --git a/src/components/molecules/Dialog/Dialog.tsx b/src/components/molecules/Dialog/Dialog.tsx index a5db6e8..ac61301 100644 --- a/src/components/molecules/Dialog/Dialog.tsx +++ b/src/components/molecules/Dialog/Dialog.tsx @@ -101,7 +101,7 @@ export const DialogHeader = forwardRef( + +
    + + ), +} diff --git a/src/components/organisms/PageHeader/PageHeader.tsx b/src/components/organisms/PageHeader/PageHeader.tsx new file mode 100644 index 0000000..90bf0f3 --- /dev/null +++ b/src/components/organisms/PageHeader/PageHeader.tsx @@ -0,0 +1,91 @@ +import { forwardRef, type HTMLAttributes, type ReactNode } from 'react' +import { cn } from '@/lib/utils' + +export interface PageHeaderProps extends HTMLAttributes { + title: string + subtitle?: string + icon?: ReactNode + iconSize?: string + theme?: 'light' | 'dark' + centered?: boolean + noBackground?: boolean + children?: ReactNode +} + +function DecoArcs({ isDark }: { isDark: boolean }) { + const stroke = isDark ? 'rgba(20, 108, 253, 0.25)' : 'rgba(0, 38, 100, 0.12)' + return ( + + ) +} + +export const PageHeader = forwardRef( + ( + { + title, + subtitle, + icon, + iconSize = '50px', + theme = 'light', + centered = false, + noBackground = false, + className, + children, + ...props + }, + ref, + ) => { + const isDark = theme === 'dark' + + return ( +
    + {!noBackground && } + +
    + {icon && ( + + {icon} + + )} +
    +

    {title}

    + {subtitle && ( +

    + {subtitle} +

    + )} + {children &&
    {children}
    } +
    +
    +
    + ) + }, +) +PageHeader.displayName = 'PageHeader' diff --git a/src/components/organisms/PageHeader/index.ts b/src/components/organisms/PageHeader/index.ts new file mode 100644 index 0000000..8e94e36 --- /dev/null +++ b/src/components/organisms/PageHeader/index.ts @@ -0,0 +1,2 @@ +export { PageHeader } from './PageHeader' +export type { PageHeaderProps } from './PageHeader' diff --git a/src/components/organisms/SideNav/SideNav.stories.tsx b/src/components/organisms/SideNav/SideNav.stories.tsx new file mode 100644 index 0000000..784bab7 --- /dev/null +++ b/src/components/organisms/SideNav/SideNav.stories.tsx @@ -0,0 +1,314 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { useState } from 'react' +import { SideNav, SideNavItem, SideNavGroup, SideNavDivider } from './SideNav' + +const HomeIcon = () => ( + + + +) + +const PersonIcon = () => ( + + + +) + +const GridIcon = () => ( + + + +) + +const BookIcon = () => ( + + + +) + +const FolderIcon = () => ( + + + +) + +const SchoolIcon = () => ( + + + +) + +const SupportIcon = () => ( + + + +) + +const meta: Meta = { + title: 'Organisms/SideNav', + component: SideNav, + tags: ['autodocs'], + parameters: { + layout: 'centered', + }, + decorators: [ + (Story) => ( +
    + +
    + ), + ], +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + render: () => ( + + } href="#" active> + My status + + } href="#"> + My details + + } href="#"> + Workspace + + } href="#"> + Resources + + } href="#"> + My documents & links + + + } label="PDP" defaultOpen> + My PDP + PDP guide + Management + Useful links + + + } href="#"> + Support + + + ), +} + +export const WithActiveNested: Story = { + name: 'Active nested item', + render: () => ( + + } href="#"> + My status + + } href="#"> + My details + + } href="#"> + Workspace + + } href="#"> + Resources + + } href="#"> + My documents & links + + + } label="PDP" defaultOpen> + + My PDP + + PDP guide + Management + Useful links + + + } href="#"> + Support + + + ), +} + +export const Collapsed: Story = { + render: () => ( + + } href="#" active> + My status + + } href="#"> + My details + + } href="#"> + Workspace + + } href="#"> + Resources + + } href="#"> + My documents & links + + + } label="PDP" active> + + My PDP + + PDP guide + + + } href="#"> + Support + + + ), +} + +export const WithBadges: Story = { + name: 'With badges', + render: () => ( + + } href="#" active> + My status + + } href="#"> + My details + + } href="#" badge={12}> + Resources + + } href="#" badge={3}> + My documents & links + + + } label="PDP" badge={5} defaultOpen> + + My PDP + + + PDP guide + + + + } href="#"> + Support + + + ), +} + +const ToggleTemplate = () => { + const [collapsed, setCollapsed] = useState(false) + + return ( +
    + + } href="#" active> + My status + + } href="#"> + My details + + } href="#"> + Workspace + + } href="#"> + Resources + + } href="#"> + My documents & links + + + } label="PDP" defaultOpen> + My PDP + PDP guide + Management + Useful links + + + } href="#"> + Support + + + +
    + ) +} + +export const Interactive: Story = { + name: 'Toggle collapsed', + render: () => , +} + +export const WithAlerts: Story = { + name: 'With alerts', + render: () => ( + + } href="#" active> + My status + + } href="#" alert="error"> + My details + + } href="#"> + Workspace + + } href="#" alert="info"> + Resources + + } href="#" badge={3} alert="warning"> + My documents & links + + + } label="PDP" alert="success" defaultOpen> + + My PDP + + PDP guide + + + } href="#"> + Support + + + ), +} + +export const CollapsedWithAlerts: Story = { + name: 'Collapsed with alerts', + render: () => ( + + } href="#" active> + My status + + } href="#" alert="error"> + My details + + } href="#"> + Workspace + + } href="#" alert="info"> + Resources + + } href="#" badge={3} alert="warning"> + My documents & links + + + } label="PDP" alert="success"> + + My PDP + + + + } href="#"> + Support + + + ), +} diff --git a/src/components/organisms/SideNav/SideNav.tsx b/src/components/organisms/SideNav/SideNav.tsx new file mode 100644 index 0000000..3c72551 --- /dev/null +++ b/src/components/organisms/SideNav/SideNav.tsx @@ -0,0 +1,329 @@ +import { + createContext, + forwardRef, + useCallback, + useContext, + useMemo, + useState, + type AnchorHTMLAttributes, + type ButtonHTMLAttributes, + type HTMLAttributes, + type ReactNode, +} from 'react' +import { cn } from '@/lib/utils' +import { Tooltip } from '@/components/atoms/Tooltip/Tooltip' + +type AlertVariant = 'info' | 'success' | 'warning' | 'error' + +const alertStyles: Record = { + info: 'bg-info', + success: 'bg-success', + warning: 'bg-warning', + error: 'bg-error', +} + +function resolveAlertColor(alert: boolean | AlertVariant | undefined): string | null { + if (!alert) return null + return alertStyles[alert === true ? 'error' : alert] +} + +const ChevronIcon = () => ( + + + +) + +// --- Contexts --- + +interface SideNavContextValue { + collapsed: boolean +} + +const SideNavContext = createContext({ collapsed: false }) + +const NestedContext = createContext(false) + +// --- SideNav --- + +export interface SideNavProps extends HTMLAttributes { + collapsed?: boolean +} + +export const SideNav = forwardRef( + ({ collapsed = false, className, children, ...props }, ref) => { + const contextValue = useMemo(() => ({ collapsed }), [collapsed]) + + return ( + + + + ) + }, +) +SideNav.displayName = 'SideNav' + +// --- SideNavDivider --- + +export interface SideNavDividerProps extends HTMLAttributes {} + +export const SideNavDivider = forwardRef( + ({ className, ...props }, ref) => { + const { collapsed } = useContext(SideNavContext) + + return ( +
    + ) + }, +) +SideNavDivider.displayName = 'SideNavDivider' + +// --- Badge (internal) --- + +function NavBadge({ count }: { count: number }) { + return ( + + {count} + + ) +} + +// --- Alert dot (internal) --- + +function AlertDot({ alert }: { alert: boolean | AlertVariant | undefined }) { + const color = resolveAlertColor(alert) + if (!color) return null + + return ( + <> +
    + } + > + {trailingActions} + + ), +} + +export const MenuClosed: Story = { + name: 'Top level (menu closed)', + render: () => ( + } label="Open menu" />} + logo={} + > + {trailingActions} + + ), +} + +export const MenuOpen: Story = { + name: 'Top level (menu open)', + render: () => ( + } label="Close menu" />} + logo={} + > + {trailingActions} + + ), +} + +export const ChildLevel: Story = { + name: 'Child level', + render: () => ( + } label="Go back" />} + > + {trailingActions} + + ), +} + +export const FullscreenDialog: Story = { + name: 'Fullscreen dialog', + render: () => ( + } label="Close" />} + > + + + ), +} + +const InteractiveTemplate = () => { + const [menuOpen, setMenuOpen] = useState(false) + + return ( +
    + : } + label={menuOpen ? 'Close menu' : 'Open menu'} + /> + } + logo={} + > + {trailingActions} + +
    + Menu is {menuOpen ? 'open' : 'closed'} — click the hamburger/close icon to toggle +
    + {/* Invisible click handler since IconBtn doesn't take onClick */} +
    + ) +} + +export const Interactive: Story = { + name: 'Interactive menu toggle', + render: () => , +} diff --git a/src/components/organisms/TopBar/TopBar.tsx b/src/components/organisms/TopBar/TopBar.tsx new file mode 100644 index 0000000..f932542 --- /dev/null +++ b/src/components/organisms/TopBar/TopBar.tsx @@ -0,0 +1,44 @@ +import { forwardRef, type HTMLAttributes, type ReactNode } from 'react' +import { cn } from '@/lib/utils' + +export interface TopBarProps extends HTMLAttributes { + title: string + leading?: ReactNode + logo?: ReactNode + children?: ReactNode +} + +export const TopBar = forwardRef( + ({ title, leading, logo, className, children, ...props }, ref) => { + return ( +
    + {leading && ( +
    + {leading} +
    + )} + +
    + {logo &&
    {logo}
    } +

    + {title} +

    +
    + + {children && ( +
    + {children} +
    + )} +
    + ) + }, +) +TopBar.displayName = 'TopBar' diff --git a/src/components/organisms/TopBar/index.ts b/src/components/organisms/TopBar/index.ts new file mode 100644 index 0000000..184dd50 --- /dev/null +++ b/src/components/organisms/TopBar/index.ts @@ -0,0 +1,2 @@ +export { TopBar } from './TopBar' +export type { TopBarProps } from './TopBar' diff --git a/src/styles/global.css b/src/styles/global.css index 3c9ea60..8015e69 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -12,3 +12,19 @@ color: var(--color-text); } } + +:root { + --text-h1-responsive: 2.25rem; + --text-h2-responsive: 1.75rem; + --text-h3-responsive: 1.375rem; + --text-h4-responsive: 1.125rem; +} + +@media (min-width: 960px) { + :root { + --text-h1-responsive: 3rem; + --text-h2-responsive: 2rem; + --text-h3-responsive: 1.5rem; + --text-h4-responsive: 1.25rem; + } +} diff --git a/src/styles/typography.css b/src/styles/typography.css index b17377a..e658eb5 100644 --- a/src/styles/typography.css +++ b/src/styles/typography.css @@ -29,7 +29,7 @@ line-height: var(--text-body--line-height); font-weight: 700; text-decoration-line: underline; - color: var(--color-blue-02); + color: var(--color-info); } @utility text-small-link { @@ -37,5 +37,35 @@ line-height: var(--text-small--line-height); font-weight: 700; text-decoration-line: underline; - color: var(--color-blue-02); + color: var(--color-info); +} + +@utility text-button { + font-size: var(--text-body); + line-height: calc(19 / 16); + font-weight: 600; +} + +@utility text-h1-responsive { + font-size: var(--text-h1-responsive); + line-height: 1.25; + font-weight: 700; +} + +@utility text-h2-responsive { + font-size: var(--text-h2-responsive); + line-height: 1.25; + font-weight: 700; +} + +@utility text-h3-responsive { + font-size: var(--text-h3-responsive); + line-height: 1.333; + font-weight: 700; +} + +@utility text-h4-responsive { + font-size: var(--text-h4-responsive); + line-height: 1.4; + font-weight: 700; } diff --git a/src/tokens/tokens.css b/src/tokens/tokens.css index af6fb17..12ec6eb 100644 --- a/src/tokens/tokens.css +++ b/src/tokens/tokens.css @@ -27,47 +27,76 @@ /* Blues */ --color-blue-01: #002664; --color-blue-02: #146CFD; - --color-blue-03: #69B3E7; + --color-blue-03: #8CE0FF; --color-blue-04: #CBEDFD; - --color-blue-05: #EBF5FF; /* extrapolated: ultra-light background */ /* Reds */ - --color-red-01: #3E0014; + --color-red-01: #630019; --color-red-02: #D7153A; - --color-red-03: #F5C5D0; - --color-red-04: #FDDDE5; - --color-red-05: #FFF5F8; /* extrapolated: ultra-light background */ + --color-red-03: #FFB8C1; + --color-red-04: #FFE6EA; /* Oranges */ - --color-orange-01: #7A3300; /* extrapolated: dark */ - --color-orange-02: #EC6608; - --color-orange-03: #F5B98A; - --color-orange-04: #FEF0E4; /* extrapolated: light background */ + --color-orange-01: #941B00; + --color-orange-02: #F3631B; + --color-orange-03: #FFCE99; + --color-orange-04: #FDEDDF; /* Greens */ - --color-green-01: #005C35; /* extrapolated: dark */ - --color-green-02: #00A651; - --color-green-03: #89E5B3; - --color-green-04: #E0F8EA; /* extrapolated: light background */ + --color-green-01: #004000; + --color-green-02: #00AA45; + --color-green-03: #A8EDB3; + --color-green-04: #DBFADF; + + /* Teals */ + --color-teal-01: #0B3F47; + --color-teal-02: #2E808E; + --color-teal-03: #8CDBE5; + --color-teal-04: #D1EEEA; + + /* Browns */ + --color-brown-01: #523719; + --color-brown-02: #B68D5D; + --color-brown-03: #E8D0B5; + --color-brown-04: #EDE3D7; + + /* Purples */ + --color-purple-01: #441170; + --color-purple-02: #8055F1; + --color-purple-03: #CEBFFF; + --color-purple-04: #E6E1FD; + + /* Fuchsias */ + --color-fuchsia-01: #65004D; + --color-fuchsia-02: #D912AE; + --color-fuchsia-03: #F4B5E6; + --color-fuchsia-04: #FDDEF2; + + /* Yellows */ + --color-yellow-01: #694800; + --color-yellow-02: #FAAF05; + --color-yellow-03: #FDE79A; + --color-yellow-04: #FFF4CF; /* Greys */ --color-grey-01: #22272B; - --color-grey-02: #6D7278; - --color-grey-03: #C0C0C0; - --color-grey-04: #E0E0E0; - --color-off-white: #F4F4F4; + --color-grey-02: #495054; + --color-grey-03: #CDD3D6; + --color-grey-04: #EBEBEB; + --color-grey-05: #F2F2F2; --color-white: #FFFFFF; /* Semantic Aliases */ - --color-primary: var(--color-blue-02); - --color-primary-dark: var(--color-blue-01); + --color-primary: var(--color-blue-01); + --color-info: var(--color-blue-02); + --color-secondary: var(--color-blue-04); --color-error: var(--color-red-02); --color-success: var(--color-green-02); --color-warning: var(--color-orange-02); --color-text: var(--color-grey-01); --color-text-secondary: var(--color-grey-02); --color-border: var(--color-grey-04); - --color-bg: var(--color-off-white); + --color-bg: var(--color-grey-05); --color-surface: var(--color-white); /* Form Controls */ @@ -80,7 +109,7 @@ --color-control-description: var(--color-grey-02); --color-control-error: var(--color-red-02); --color-control-bg: var(--color-white); - --color-control-bg-readonly: var(--color-off-white); + --color-control-bg-readonly: var(--color-grey-05); /* Button */ --color-button-default: var(--color-blue-01); @@ -89,6 +118,10 @@ --color-button-subtle-bg: var(--color-blue-04); --color-button-subtle-text: var(--color-blue-01); + /* Switch */ + --color-switch-on: var(--color-success); + --color-switch-on-hover: var(--color-green-01); + /* Badge */ --color-badge-navy: var(--color-blue-01); --color-badge-info: var(--color-blue-02); @@ -124,29 +157,53 @@ --color-tag-orange-light: var(--color-orange-04); --color-tag-grey: var(--color-grey-02); --color-tag-grey-light: var(--color-grey-04); + --color-tag-teal: var(--color-teal-01); + --color-tag-teal-light: var(--color-teal-04); + --color-tag-brown: var(--color-brown-01); + --color-tag-brown-light: var(--color-brown-04); + --color-tag-purple: var(--color-purple-01); + --color-tag-purple-light: var(--color-purple-04); + --color-tag-fuchsia: var(--color-fuchsia-01); + --color-tag-fuchsia-light: var(--color-fuchsia-04); + --color-tag-yellow: var(--color-yellow-01); + --color-tag-yellow-light: var(--color-yellow-04); + + /* Avatar */ + --color-avatar: var(--color-blue-04); + --color-avatar-text: var(--color-grey-01); + + /* TopBar */ + --color-topbar: var(--color-blue-01); + + /* SideNav */ + --color-nav-bg: var(--color-white); + --color-nav-text: var(--color-grey-01); + --color-nav-icon: var(--color-blue-01); + --color-nav-active: var(--color-info); + --color-nav-divider: var(--color-grey-03); /* Alert */ - --color-alert-info-bg: var(--color-blue-05); + --color-alert-info-bg: var(--color-blue-04); --color-alert-info-border: var(--color-blue-02); --color-alert-info-icon: var(--color-blue-02); --color-alert-warning-bg: var(--color-orange-04); --color-alert-warning-border: var(--color-orange-02); --color-alert-warning-icon: var(--color-orange-02); - --color-alert-error-bg: var(--color-red-05); + --color-alert-error-bg: var(--color-red-04); --color-alert-error-border: var(--color-red-02); --color-alert-error-icon: var(--color-red-02); --color-alert-success-bg: var(--color-green-04); --color-alert-success-border: var(--color-green-02); --color-alert-success-icon: var(--color-green-02); - --color-alert-neutral-bg: var(--color-off-white); + --color-alert-neutral-bg: var(--color-grey-05); --color-alert-neutral-border: var(--color-grey-03); --color-alert-neutral-icon: var(--color-blue-01); /* Radius */ --radius-sm: 4px; - --radius-default: 6px; - --radius-lg: 10px; - --radius-xl: 16px; + --radius-default: 8px; + --radius-lg: 16px; + --radius-xl: 24px; --radius-full: 9999px; /* Shadows */