# Inkbox design system: instructions for agents Use this system to build Inkbox pages and interfaces from the user's brief. These instructions work with any agent that can read text and edit project files. The CLI is a local Node.js tool; no model account or agent-specific integration is needed for setup, discovery, installation or token checks. ## Start here 1. Read the project instructions and package.json. Identify the framework, package manager, stylesheet and import aliases. Use the user's requirements to decide whether to build new UI or restyle existing UI. 2. Read this guide, then the relevant component references. The library is at https://inkbox-ds.vercel.app/components. Plain text is at https://inkbox-ds.vercel.app/llms.txt; a complete copy is at https://inkbox-ds.vercel.app/llms-full.txt. 3. Reuse the library's components and variants. Do not recreate them from screenshots or guess their props. Each reference contains the actual exports, dependencies, working examples and source code. ## Install and prepare a project Requires Node.js 20 or later. Run commands from the consuming project root, not the design-system repository. ```sh npm install --save-dev github:anwarlemu/inkbox-brand-cli#release npx inkbox-ds init ``` `npm install` installs the CLI only. `init` installs styles, fonts and agent instructions without converting existing components or creating conversion review tasks. It preserves other instructions already in the project. Inspect its warnings about a missing stylesheet or alias. React components require React 19 or later, Tailwind CSS 4 and a working build. The installer follows components.json and TypeScript or JavaScript aliases; without aliases, it uses relative imports. Set up that framework first. Other frameworks can use the CSS tokens and styling guidance, but cannot render the React components. For an existing interface that the user wants restyled, preview the explicit apply command: ```sh npx -y github:anwarlemu/inkbox-brand-cli#release apply --dry-run npx -y github:anwarlemu/inkbox-brand-cli#release apply ``` The apply command can adapt matching components, convert style values, and install agent rules and hooks. Color conversion is scoped to recognized components; unrelated palettes and artwork remain unchanged. The legacy bare command also converts styles; use explicit commands for setup and discovery. Subsequent runs resume review tasks. `--redo` repeats conversion. Review the diff and test existing behavior after conversion; a successful exit is not proof that the visual review is complete. ## Find, inspect and add components ```sh npx inkbox-ds list --json npx inkbox-ds show select npx inkbox-ds show select --json npx inkbox-ds add field select button --dry-run --json npx inkbox-ds add field select button --json ``` The add command above is an example, not the list required for every page. Choose items from `list`. `show` is read-only and works offline using the registry bundled with the CLI. `show --json` returns source, examples, usage rules, exports, dependencies and links. Hooks and helper libraries are included in the JSON index. `add` copies source files and their transitive dependencies, installs missing npm packages, and wires the theme. Existing files are kept. Use `--overwrite` only when intentionally replacing local component changes. Read the returned file list and warnings. Generated imports and target files follow components.json and the project alias configuration. Existing utility files are preserved; brand helpers use their own names. For HTTP-only agents, fetch `https://inkbox-ds.vercel.app/r/index.json`, then `https://inkbox-ds.vercel.app/r/.json` or `https://inkbox-ds.vercel.app/r/.md`. Follow `requires` recursively. Each `files[].path` is relative to the source root; `files[].content` is the exact source. Each example lists its own `requires` and `packages`; install these too when using that example. Do not copy the example file as the component implementation. The CLI handles dependency ordering and theme installation for you. The component reference states its source. `https://inkbox-ds.vercel.app/r/audit.json` records the reference basis for all components. Many components come straight from the console's design (Figma 427:2715); the rest extend its patterns because the design has no standalone equivalent (menus, calendars, charts). Each reference says which. Do not describe extensions as exact copies of the design. ## Compose the interface - Follow the user's brief. For new pages, add the required content and behavior. When only restyling an existing page, preserve its content, links, order and behavior unless the user requests changes. - Use component props and variants shown in the source. Read the named example functions before composing compound components. Keep client state inside a client component when using Next.js; imports of client components can still be rendered by server pages. - Apply color tokens by component and state (primary action, field, panel, selected, invalid), using the component reference. The restyle installs the color definitions and Tailwind aliases, then maps identified uses. Preserve shared palette definitions, chart data, artwork, gradients and unrelated colors; do not replace every occurrence of a color or repurpose a shared variable. Leave ambiguous dynamic styling for review. - `init`, `add` and restyle install the complete token set: colors, typography, spacing, layout, radii, shadows, blur and motion. Tailwind utilities read those variables, including fractional spacing, font weights and transition durations. Keep shared values in these tokens so edits remain consistent across components. - Use design tokens for custom styles. `npx inkbox-ds tokens --json` finds values; `npx inkbox-ds look --json` explains styling by role. Use `var(--ds-*)` in CSS or the matching Tailwind utilities. - Every word is Inter: 14px regular for body and controls, 20px regular for page and dialog titles, medium for tabs and card titles. Keys, hosts and IDs are IBM Plex Mono. Use the purple pill for the one main action, lilac wells and rails, 40px fields with the lilac-gray edge, Shadow/S on raised white panels and filled Phosphor glyphs. Do not introduce unrelated colors, shadows or type scales. - Every icon is Phosphor: `@phosphor-icons/react` on the web (import from `@phosphor-icons/react/ssr` in server components), `phosphor-react-native` in native apps. Use the fill weight for navigation and object glyphs, regular for chevrons, close, plus and other line marks. Do not add another icon library or draw icons as inline SVG; `check` flags other icon packages. - The installed stylesheet imports `./_inkbox-ds/brand.css` and `./_inkbox-ds/theme.css` beside the main stylesheet. `brand.css` includes tokens and base styles. Keep both imports after `@import "tailwindcss"`. Let the installer configure fonts; do not add a second font loader. - Put one control in each Field. Field connects labels, help, errors and required state. Validation and submission belong to your form. For Select use `value` and `onValueChange`, SelectTrigger with SelectValue, and SelectContent with SelectItem; values must be non-empty strings. Give the root a `name` for FormData. Use `defaultValue` only for uncontrolled input. - Connect every action to its intended behavior. Handle empty, loading, disabled, invalid, success and error states. Preserve keyboard controls, focus return, accessible names and native form semantics. Never present a preview's local demo action as a completed backend operation. - Use the provided portal behavior. Surface is for an isolated preview or a scoped theme; ordinary pages do not need an extra Surface wrapper. Mount one Toaster per application when using toast notifications. ## Interaction and motion rules - Rare triggers (onboarding, occasional feedback, celebrations) may use expressive animation. Keep it interruptible and respect reduced motion; never make an action wait for it. Repeated feedback is a frequent interaction, even if it is called a celebration. - Tens of uses per day (hover effects, list navigation) get only subtle color, background, border-color, shadow or opacity changes, normally 150ms. Reuse one component variant and interaction treatment for each role. - Hundreds of uses per day, keyboard shortcuts and command palette toggles have no animation, transition or smooth-scroll wait. Use `data-ds-motion="instant"` on custom high-frequency surfaces, including portaled content. The command components already do this. - Show press feedback on pointer-down with `:active` (or `onPointerDown` for custom drawing), never after click/touch-up. Keep the actual action on accessible click/keyboard activation. Use `touch-action: manipulation` on ordinary controls to avoid the tap delay while preserving zoom. - Keep dimensions, padding, gaps and font metrics identical across hover, focus and pressed states. Never scale a button or change font weight from 400 to 500/600 on interaction. Change visual styling instead. - Buttons and compact controls use `rounded-pill`; cards use `rounded-dialog` (24px), including small cards and cards with footers. Multi-select fields also use 24px corners; their individual chips remain pills. - All dropdown menus and popup panels use the shared 16px `rounded-panel` radius, including submenus, command palettes, date pickers, hover cards, tooltips, dialogs and toasts. Sheets and drawers use it on their exposed corners. - Use shared components, size variants and spacing tokens. Equivalent sections share a gutter; equivalent controls share padding. Do not invent separate hover treatments or per-instance spacing for the same component role. - Audit every debounce, timeout, transition wait and artificial latency. Update controlled inputs and local filtering immediately. Debounce only necessary network/background work, with immediate local feedback and stale-result cancellation. Never wait for an exit animation before accepting input or opening the next view. - `check` reports UI timers for review. Remove artificial waits. For an essential nonblocking timer, put `// ds-latency: ` immediately above the call, explaining what it delays and why typing/actions remain immediate. This documents an audit, not a general exemption for delayed input. - Verify in a browser with motion enabled and reduced motion: hold a pointer down before release, type rapidly, toggle the command palette and shortcuts repeatedly, compare bounds and font weight before/after hover and press, and check consistent section/component padding. Static checks cannot establish actual usage frequency or timing by themselves. ## Verify before finishing 1. Run the consuming project's type checks, tests and production build. 2. Run `npx inkbox-ds check --json`. Exit 0 means no detected token or interaction violations; exit 1 means unresolved violations or a command error. Read the JSON result, fix the findings and rerun. Static checks do not replace accessibility and interaction testing. 3. Test every interaction with mouse and keyboard. Submit invalid and valid forms, choose options, open and close overlays, and test loading/error paths. Check desktop and phone widths, focus visibility, overflow and reduced-motion behavior. 4. Compare screenshots against the relevant library previews and reference. `npx inkbox-ds review http://localhost:3000 --any` can capture and review a running page. It requires playwright-core and Chromium. Automated scoring uses an available supported reviewer; if none is available, read the saved screenshots and report a manual review. Never claim an independent score that was not produced. 5. Report what changed, which checks passed, and any remaining limitations. If conversion left follow-up tasks, complete or report them explicitly. ## Instructions and hooks `init` and conversion install AGENTS.md and a local `.ds/agent-guide.md`, plus supported agent instruction files and hook configuration. Agents that do not load those files automatically should read them explicitly. Hooks support Claude Code, Codex and Cursor when enabled by that tool; other agents run the CLI checks directly. Codex hook activation may require the user's `/hooks` trust step. No hook integration is required to use the components correctly. Read-only commands: `guide`, `list`, `show`, `look`, `tokens`, `check` without `--fix`. Commands that write project files: `init`, `add`, `apply`, the legacy bare conversion command, `check --fix`, and review/snapshot state updates. Use `--dry-run` with `init`, `add` or conversion to inspect planned writes. JSON goes to stdout and progress goes to stderr. Installation/build failures return a nonzero exit code. ## Project configuration and agent handoff Start with `npx inkbox-ds info --json`, then `search --json` and `view --json`. These commands are read-only. `show` and `inspect` remain aliases of `view`. `add` respects components.json, inherited TypeScript or JavaScript aliases, the target stylesheet and the package manager. It emits JSX/JS when `tsx` is false, installs the theme and agent guidance, and preserves differing local files. Use `add --diff` to review changes before `--overwrite`. Use `--cwd ` from a repository root. For shared workspace packages, use the upstream installer described below. `create --template vite --name ` scaffolds a new app and installs brand components. `init` configures an existing app; `init button card` also adds components. `apply --dry-run --json` previews a requested conversion of existing styles. Do not run conversion to discover or install the library. Portable skills are installed in `.agents/skills/inkbox-ds/SKILL.md` and `.claude/skills/inkbox-ds/SKILL.md`; the full guide is `.ds/agent-guide.md`. Read `add --view` or the installed source for imports resolved to this project's paths. The installed brand utility is separate from the application's existing `lib/utils` exports. The standard shadcn registry is configured under `@inkbox-ds` in components.json. For workspace routing, install with `npx inkbox-ds shadcn add @inkbox-ds/theme @inkbox-ds/button`. Include the theme explicitly with upstream shadcn so existing font and color aliases are updated. Native `add` applies it automatically. Advanced operations use pinned shadcn 4.22.1: `shadcn `, `mcp`, `registry`, `preset`, `migrate`, `eject`, and `build`. These need network access on first use and preserve upstream output formats. Native brand commands accept `--json`. Preset/base changes are upstream operations that may change the brand; use them only when requested. MCP configuration alone does not prove the agent host has loaded its server. The explicit `apply` command scopes color conversion to recognized components and preserves unrelated artwork and shared palette data. The legacy bare-command conversion remains for compatibility; use `apply` for new restyling tasks. # Inkbox theme The Inkbox tokens and the Tailwind names that read them. Install: npx inkbox-ds add theme Requires: none Packages: none Exports: See source Client boundary: not required by this entry file ## Usage ## Source styles/tokens.css ```css /* Written by inkbox-ds from its brand files. Do not edit; run the CLI again to update. */ :root { /* Core: deep purple ink, the action purple, the lilac wash, the pink accent, and white. */ --ds-color-brand: #74629D; --ds-color-black: #000000; --ds-color-white: #FFFFFF; --ds-color-ink: #3E2A53; --ds-color-lilac: #F4F2F6; --ds-color-pink: #F9B1B5; /* The pink's dark partner: the glyph on a pink channel tile, and an agent handle in a table. */ --ds-color-rose: #8C5659; /* An avatar's initial tile. */ --ds-color-avatar: #5063B1; /* Surfaces: white panels on a white page; the lilac for the second rail, a selected row, a table's head and foot, a field's well and a channel card. */ --ds-color-bg-default: #FFFFFF; --ds-color-bg-page: #FFFFFF; --ds-color-bg-subtle: #F4F2F6; /* The lilac under the pointer or pressed: the wash a step deeper (not in the design; derived for hover). */ --ds-color-bg-subtle-hover: #ECE9F0; --ds-color-bg-frame: #F4F2F6; --ds-color-bg-canvas: #F4F2F6; --ds-color-bg-tint: #F4F2F6; --ds-color-bg-brand: #74629D; --ds-color-bg-inverse: #3E2A53; --ds-color-bg-dark: #3E2A53; /* Code: the light editor theme of the event catalog (a gray head over an off-white body). */ --ds-color-bg-code: #FFFFFE; --ds-color-bg-code-head: #F0F0F0; /* The page behind a dialog: the ink at 12 percent, over a light blur. */ --ds-color-bg-scrim: rgb(62 42 83 / 0.12); /* A switch that is off, and a disabled control's fill. */ --ds-color-bg-off: #B1ADBB; /* Text: the ink, and four lighter purples (secondary for body copy under a title, muted for table heads, dates and descriptions, subtle for placeholders, faint for a close glyph); the action purple for links and labels. */ --ds-color-text-default: #3E2A53; --ds-color-text-secondary: #70637E; --ds-color-text-muted: #998EA5; --ds-color-text-subtle: #9E94A9; --ds-color-text-nav: #3E2A53; --ds-color-text-faint: #BCB3C5; --ds-color-text-inverse: #FFFFFF; --ds-color-text-brand: #74629D; --ds-color-text-on-brand: #FFFFFF; --ds-color-text-label: #74629D; /* Icons: the ink for nav glyphs, a softer purple for row actions and empty-state orbits. */ --ds-color-icon-default: #3E2A53; --ds-color-icon-secondary: #5B4C6A; /* Code: the editor theme's keys, strings, line numbers, braces and plain text. */ --ds-color-code-key: #A31515; --ds-color-code-string: #0451A5; --ds-color-code-line: #237893; --ds-color-code-brace: #F8CC5E; --ds-color-code-text: #1A202C; --ds-color-code-punct: #0431FA; --ds-color-code-line-active: #0B216F; --ds-color-code-head-fg: #4A4A4A; /* The rest of the same editor theme (Light+), for code other than JSON: keywords, comments, numbers, functions, types, markup tags and attributes. */ --ds-color-code-keyword: #0000FF; --ds-color-code-comment: #008000; --ds-color-code-number: #098658; --ds-color-code-function: #795E26; --ds-color-code-type: #267F99; --ds-color-code-tag: #800000; --ds-color-code-attr: #E50000; /* Lines: a field's edge, the rail and header rules, and the two shared styles (Line/S Bottom under a table row, Shadow/S's ring around a raised white panel). */ --ds-color-border-default: #D2C9DB; --ds-color-border-subtle: #F3F1F5; --ds-color-border-strong: #B1ADBB; --ds-color-border-field: #D2C9DB; --ds-color-border-row: rgb(102 102 99 / 0.12); --ds-color-border-ring: rgb(23 47 41 / 0.12); /* Shadow/S's drop, for a raised panel drawn without box-shadow (a table body built from cells). */ --ds-color-shadow-drop: rgb(23 47 41 / 0.08); --ds-color-border-brand: #74629D; --ds-color-border-pink: #F9B1B5; /* A code block's edge, its head's rule and its indent guides (the editor theme's gray). */ --ds-color-border-code: #E0E0E0; --ds-color-wash-hover: rgb(62 42 83 / 0.04); --ds-color-ring-tint: rgb(116 98 157 / 0.24); /* Actions and states. The main action is the purple pill. */ --ds-color-action-primary: #74629D; --ds-color-action-primary-hover: #67568E; --ds-color-action-primary-fg: #FFFFFF; --ds-color-focus-ring: #74629D; /* Success: the Included pill and its checks, and the Active status well. */ --ds-color-status-success: #1CA721; --ds-color-status-success-bg: #C6F6D5; /* Warning: the copy-this-key banner (its fill, title ink, glyph and note). */ --ds-color-status-warning: #8A581D; --ds-color-status-warning-bg: #F6E8C9; --ds-color-status-warning-fg: #4A2F0B; --ds-color-status-warning-muted: #8E7045; /* Danger: a field that failed (its edge and message) and the error glyph inside it. */ --ds-color-status-danger: #BD5557; --ds-color-status-danger-strong: #A71C1F; /* Info: the zero-knowledge note (fill, ink, glyph), and the blue tag and status well. */ --ds-color-status-info: #1C7390; --ds-color-status-info-bg: #C9EBF6; --ds-color-status-info-fg: #0D4152; --ds-color-tag-blue: #BEE3F8; --ds-color-tag-blue-fg: #2A4365; --ds-color-tag-purple: #E9D8FD; --ds-color-tag-purple-fg: #6D5C9B; --ds-color-tag-gray: #EDF2F7; --ds-color-chart-series-1: #74629D; --ds-color-chart-series-2: #F9B1B5; --ds-color-chart-series-3: #5063B1; --ds-color-chart-series-4: #1C7390; --ds-color-chart-series-5: #1CA721; /* Type: Inter for everything, IBM Plex Mono for keys, hosts and IDs, the system monospace in code blocks. Body copy is 14px regular; medium for tabs and card titles; bold only for an empty state's title. No tracking. */ --ds-font-sans: var(--font-inter, "Inter"), ui-sans-serif, system-ui, sans-serif; --ds-font-display: var(--font-inter, "Inter"), ui-sans-serif, system-ui, sans-serif; --ds-font-mono: var(--font-plex-mono, "IBM Plex Mono"), ui-monospace, SFMono-Regular, Menlo, monospace; /* Code blocks (the event catalog's JSON) use the system monospace, as the editor theme does. */ --ds-font-code: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; --ds-font-weight-regular: 400; --ds-font-weight-medium: 500; --ds-font-weight-semibold: 600; --ds-font-weight-bold: 700; --ds-font-weight-heading: 400; /* Page and dialog titles are 20px regular; an empty state's title 22px bold; the rest 11 to 16px. */ --ds-text-display: 2rem; --ds-text-h1: 1.5rem; --ds-text-h2: 1.375rem; --ds-text-h3: 1.25rem; --ds-text-h4: 1rem; --ds-text-body-lg: 1rem; --ds-text-body: 0.875rem; --ds-text-body-sm: 0.8125rem; --ds-text-caption: 0.75rem; --ds-text-micro: 0.6875rem; --ds-leading-tight: 1.2; --ds-leading-heading: 1.4; --ds-leading-display: 1.25; --ds-leading-snug: 1.25; --ds-leading-body: 1.5; --ds-leading-relaxed: 1.6; --ds-leading-code: 1.5; --ds-tracking-normal: 0; /* Nothing is tracked; the engine's heading tracking resolves to none. */ --ds-tracking-tight: 0; --ds-tracking-caps: 0.02em; /* Space (rem, so it follows the root); the design's spacing variables are 4, 8, 12, 16 and 24. */ --ds-space-0: 0; --ds-space-px: 1px; --ds-space-0-5: 0.125rem; --ds-space-1: 0.25rem; --ds-space-1-5: 0.375rem; --ds-space-2: 0.5rem; --ds-space-2-5: 0.625rem; --ds-space-3: 0.75rem; --ds-space-3-5: 0.875rem; --ds-space-4: 1rem; --ds-space-5: 1.25rem; --ds-space-6: 1.5rem; --ds-space-7: 1.75rem; --ds-space-8: 2rem; --ds-space-9: 2.25rem; --ds-space-9-5: 2.375rem; --ds-space-10: 2.5rem; --ds-space-10-5: 2.625rem; --ds-space-12: 3rem; --ds-space-14: 3.5rem; --ds-space-16: 4rem; --ds-space-20: 5rem; --ds-space-24: 6rem; /* Layout: a 288px rail, a 288px second rail, a 64px page head with 24px gutters. */ --ds-layout-column: 75rem; --ds-layout-gutter: 1.5rem; --ds-layout-text: 42.5rem; --ds-layout-heading: 42rem; --ds-layout-header: 4rem; --ds-layout-rail: 18rem; --ds-frame-column: min(100% - 2 * var(--ds-layout-gutter), var(--ds-layout-column)); /* Shape: pill buttons and compact controls, 6px details, 8px tiles, 12px content panels, 16px popup panels, table heads and banners, and 24px cards and multi-select fields. */ --ds-radius-xs: 0.375rem; --ds-radius-sm: 0.5rem; --ds-radius-control: 0.75rem; --ds-radius-panel: 1rem; --ds-radius-dialog: 1.5rem; --ds-radius-pill: 9999px; /* Shadow/S: a raised white panel (a selected tab, a table's body, a select, a menu): a 1px ring and a short drop. */ --ds-shadow-s: 0 2px 4px rgb(23 47 41 / 0.08), 0 0 0 1px rgb(23 47 41 / 0.12); /* A card: the same raised panel. */ --ds-shadow-card: 0 2px 4px rgb(23 47 41 / 0.08), 0 0 0 1px rgb(23 47 41 / 0.12); /* Line/S Bottom: the rule under a table row. */ --ds-shadow-line: 0 1px 0 rgb(102 102 99 / 0.12); /* A floating panel (a menu, a popover, a toast): Shadow/S with a softer, longer drop. */ --ds-shadow-floating: 0 0 0 1px rgb(23 47 41 / 0.12), 0 2px 4px rgb(23 47 41 / 0.08), 0 12px 24px -8px rgb(62 42 83 / 0.16); /* A dialog sits on the scrim with no drop of its own; a sheet gets one where it meets the page. */ --ds-shadow-dialog: 0 24px 48px -16px rgb(62 42 83 / 0.16); --ds-shadow-knob: 0 1px 2px rgb(62 42 83 / 0.24), 0 0 0 0.5px rgb(62 42 83 / 0.12); --ds-shadow-focus: 0 0 0 3px rgb(116 98 157 / 0.24); --ds-blur-scrim: 4px; /* Motion: a strong ease-out for every hover, press and entrance; in-out only for what travels on screen (a tab thumb, a switch knob). Never ease-in. */ --ds-duration-fast: 150ms; --ds-duration-base: 200ms; --ds-duration-moderate: 250ms; --ds-duration-slow: 300ms; --ds-easing-out: cubic-bezier(0.23, 1, 0.32, 1); --ds-easing-in-out: cubic-bezier(0.77, 0, 0.175, 1); --ds-easing-drawer: cubic-bezier(0.32, 0.72, 0, 1); } ``` styles/theme.css ```css /* Written by inkbox-ds from its brand files. Do not edit; run the CLI again to update. */ @theme inline { --color-brand: var(--ds-color-brand); --color-black: var(--ds-color-black); --color-white: var(--ds-color-white); --color-primary: var(--ds-color-action-primary); --color-primary-hover: var(--ds-color-action-primary-hover); --color-on-primary: var(--ds-color-action-primary-fg); --color-focus-ring: var(--ds-color-focus-ring); --color-surface: var(--ds-color-bg-default); --color-surface-brand: var(--ds-color-bg-brand); --color-subtle: var(--ds-color-bg-subtle); --color-frame: var(--ds-color-bg-frame); --color-canvas: var(--ds-color-bg-canvas); --color-tint: var(--ds-color-bg-tint); --color-inverse: var(--ds-color-bg-inverse); --color-dark: var(--ds-color-bg-dark); --color-code: var(--ds-color-bg-code); --color-scrim: var(--ds-color-bg-scrim); --color-fg: var(--ds-color-text-default); --color-fg-secondary: var(--ds-color-text-secondary); --color-fg-muted: var(--ds-color-text-muted); --color-fg-subtle: var(--ds-color-text-subtle); --color-fg-faint: var(--ds-color-text-faint); --color-fg-nav: var(--ds-color-text-nav); --color-fg-inverse: var(--ds-color-text-inverse); --color-fg-brand: var(--ds-color-text-brand); --color-on-brand: var(--ds-color-text-on-brand); --color-line: var(--ds-color-border-default); --color-line-subtle: var(--ds-color-border-subtle); --color-line-strong: var(--ds-color-border-strong); --color-success: var(--ds-color-status-success); --color-warning: var(--ds-color-status-warning); --color-danger: var(--ds-color-status-danger); --color-ink: var(--ds-color-ink); --color-lilac: var(--ds-color-lilac); --color-pink: var(--ds-color-pink); --color-rose: var(--ds-color-rose); --color-avatar: var(--ds-color-avatar); --color-page: var(--ds-color-bg-page); --color-subtle-hover: var(--ds-color-bg-subtle-hover); --color-code-head: var(--ds-color-bg-code-head); --color-off: var(--ds-color-bg-off); --color-fg-label: var(--ds-color-text-label); --color-icon-default: var(--ds-color-icon-default); --color-icon-secondary: var(--ds-color-icon-secondary); --color-code-key: var(--ds-color-code-key); --color-code-string: var(--ds-color-code-string); --color-code-line: var(--ds-color-code-line); --color-code-brace: var(--ds-color-code-brace); --color-code-text: var(--ds-color-code-text); --color-code-punct: var(--ds-color-code-punct); --color-code-line-active: var(--ds-color-code-line-active); --color-code-head-fg: var(--ds-color-code-head-fg); --color-code-keyword: var(--ds-color-code-keyword); --color-code-comment: var(--ds-color-code-comment); --color-code-number: var(--ds-color-code-number); --color-code-function: var(--ds-color-code-function); --color-code-type: var(--ds-color-code-type); --color-code-tag: var(--ds-color-code-tag); --color-code-attr: var(--ds-color-code-attr); --color-line-field: var(--ds-color-border-field); --color-line-row: var(--ds-color-border-row); --color-line-ring: var(--ds-color-border-ring); --color-shadow-drop: var(--ds-color-shadow-drop); --color-line-brand: var(--ds-color-border-brand); --color-line-pink: var(--ds-color-border-pink); --color-line-code: var(--ds-color-border-code); --color-wash-hover: var(--ds-color-wash-hover); --color-ring-tint: var(--ds-color-ring-tint); --color-success-bg: var(--ds-color-status-success-bg); --color-warning-bg: var(--ds-color-status-warning-bg); --color-warning-fg: var(--ds-color-status-warning-fg); --color-warning-muted: var(--ds-color-status-warning-muted); --color-danger-strong: var(--ds-color-status-danger-strong); --color-info: var(--ds-color-status-info); --color-info-bg: var(--ds-color-status-info-bg); --color-info-fg: var(--ds-color-status-info-fg); --color-tag-blue: var(--ds-color-tag-blue); --color-tag-blue-fg: var(--ds-color-tag-blue-fg); --color-tag-purple: var(--ds-color-tag-purple); --color-tag-purple-fg: var(--ds-color-tag-purple-fg); --color-tag-gray: var(--ds-color-tag-gray); --color-series-1: var(--ds-color-chart-series-1); --color-series-2: var(--ds-color-chart-series-2); --color-series-3: var(--ds-color-chart-series-3); --color-series-4: var(--ds-color-chart-series-4); --color-series-5: var(--ds-color-chart-series-5); --font-sans: var(--ds-font-sans); --font-display: var(--ds-font-display); --font-mono: var(--ds-font-mono); --font-code: var(--ds-font-code); --font-weight-normal: var(--ds-font-weight-regular); --font-weight-medium: var(--ds-font-weight-medium); --font-weight-semibold: var(--ds-font-weight-semibold); --font-weight-bold: var(--ds-font-weight-bold); --font-weight-heading: var(--ds-font-weight-heading); --spacing: var(--ds-space-1); --spacing-0: var(--ds-space-0); --spacing-px: var(--ds-space-px); --spacing-0\.5: var(--ds-space-0-5); --spacing-1: var(--ds-space-1); --spacing-1\.5: var(--ds-space-1-5); --spacing-2: var(--ds-space-2); --spacing-2\.5: var(--ds-space-2-5); --spacing-3: var(--ds-space-3); --spacing-3\.5: var(--ds-space-3-5); --spacing-4: var(--ds-space-4); --spacing-5: var(--ds-space-5); --spacing-6: var(--ds-space-6); --spacing-7: var(--ds-space-7); --spacing-8: var(--ds-space-8); --spacing-9: var(--ds-space-9); --spacing-9\.5: var(--ds-space-9-5); --spacing-10: var(--ds-space-10); --spacing-10\.5: var(--ds-space-10-5); --spacing-12: var(--ds-space-12); --spacing-14: var(--ds-space-14); --spacing-16: var(--ds-space-16); --spacing-20: var(--ds-space-20); --spacing-24: var(--ds-space-24); --spacing-layout-column: var(--ds-layout-column); --spacing-layout-gutter: var(--ds-layout-gutter); --spacing-layout-text: var(--ds-layout-text); --spacing-layout-heading: var(--ds-layout-heading); --spacing-layout-header: var(--ds-layout-header); --spacing-layout-rail: var(--ds-layout-rail); --container-frame-column: var(--ds-frame-column); --blur-scrim: var(--ds-blur-scrim); --transition-duration-fast: var(--ds-duration-fast); --transition-duration-150: var(--ds-duration-fast); --transition-duration-base: var(--ds-duration-base); --transition-duration-200: var(--ds-duration-base); --transition-duration-moderate: var(--ds-duration-moderate); --transition-duration-250: var(--ds-duration-moderate); --transition-duration-slow: var(--ds-duration-slow); --transition-duration-300: var(--ds-duration-slow); --default-transition-duration: var(--ds-duration-fast); --default-transition-timing-function: var(--ds-easing-out); --text-display: var(--ds-text-display); --text-h1: var(--ds-text-h1); --text-h2: var(--ds-text-h2); --text-h3: var(--ds-text-h3); --text-h4: var(--ds-text-h4); --text-body-lg: var(--ds-text-body-lg); --text-body: var(--ds-text-body); --text-body-sm: var(--ds-text-body-sm); --text-caption: var(--ds-text-caption); --text-micro: var(--ds-text-micro); --leading-tight: var(--ds-leading-tight); --leading-heading: var(--ds-leading-heading); --leading-display: var(--ds-leading-display); --leading-snug: var(--ds-leading-snug); --leading-body: var(--ds-leading-body); --leading-relaxed: var(--ds-leading-relaxed); --leading-code: var(--ds-leading-code); --tracking-normal: var(--ds-tracking-normal); --tracking-tight: var(--ds-tracking-tight); --tracking-caps: var(--ds-tracking-caps); --radius-xs: var(--ds-radius-xs); --radius-sm: var(--ds-radius-sm); --radius-control: var(--ds-radius-control); --radius-panel: var(--ds-radius-panel); --radius-dialog: var(--ds-radius-dialog); --radius-pill: var(--ds-radius-pill); --shadow-s: var(--ds-shadow-s); --shadow-card: var(--ds-shadow-card); --shadow-line: var(--ds-shadow-line); --shadow-floating: var(--ds-shadow-floating); --shadow-dialog: var(--ds-shadow-dialog); --shadow-knob: var(--ds-shadow-knob); --shadow-focus: var(--ds-shadow-focus); --ease-out: var(--ds-easing-out); --ease-in-out: var(--ds-easing-in-out); --ease-drawer: var(--ds-easing-drawer); } @theme { --animate-pop-in: ds-pop-in var(--ds-duration-base) var(--ds-easing-out); --animate-pop-out: ds-pop-out var(--ds-duration-fast) var(--ds-easing-out) forwards; --animate-fade-in: ds-fade-in var(--ds-duration-fast) var(--ds-easing-out); --animate-fade-out: ds-fade-out var(--ds-duration-fast) var(--ds-easing-out) forwards; --animate-dialog-in: ds-dialog-in var(--ds-duration-moderate) var(--ds-easing-out); --animate-dialog-out: ds-dialog-out var(--ds-duration-fast) var(--ds-easing-out) forwards; --animate-sheet-in-right: ds-sheet-in-right var(--ds-duration-slow) var(--ds-easing-drawer); --animate-sheet-out-right: ds-sheet-out-right var(--ds-duration-base) var(--ds-easing-drawer) forwards; --animate-sheet-in-left: ds-sheet-in-left var(--ds-duration-slow) var(--ds-easing-drawer); --animate-sheet-out-left: ds-sheet-out-left var(--ds-duration-base) var(--ds-easing-drawer) forwards; --animate-sheet-in-top: ds-sheet-in-top var(--ds-duration-slow) var(--ds-easing-drawer); --animate-sheet-out-top: ds-sheet-out-top var(--ds-duration-base) var(--ds-easing-drawer) forwards; --animate-sheet-in-bottom: ds-sheet-in-bottom var(--ds-duration-slow) var(--ds-easing-drawer); --animate-sheet-out-bottom: ds-sheet-out-bottom var(--ds-duration-base) var(--ds-easing-drawer) forwards; --animate-content-in: ds-content-in var(--ds-duration-base) var(--ds-easing-out); --animate-accordion-down: ds-accordion-down var(--ds-duration-base) var(--ds-easing-out); --animate-accordion-up: ds-accordion-up var(--ds-duration-fast) var(--ds-easing-out); --animate-collapsible-down: ds-collapsible-down var(--ds-duration-base) var(--ds-easing-out); --animate-collapsible-up: ds-collapsible-up var(--ds-duration-fast) var(--ds-easing-out); --animate-pulse-soft: ds-pulse-soft 1.6s var(--ds-easing-in-out) infinite; --animate-spin-steady: spin 0.8s linear infinite; --animate-caret-blink: ds-caret-blink 1.1s steps(1) infinite; --animate-chart-line: ds-chart-line 700ms var(--ds-easing-out) backwards; --animate-chart-bar: ds-chart-bar 400ms var(--ds-easing-out) backwards; @keyframes ds-pop-in { from { opacity: 0; transform: scale(0.96); } } @keyframes ds-pop-out { to { opacity: 0; transform: scale(0.96); } } @keyframes ds-fade-in { from { opacity: 0; } } @keyframes ds-fade-out { to { opacity: 0; } } @keyframes ds-dialog-in { from { opacity: 0; transform: scale(0.96); } } @keyframes ds-dialog-out { to { opacity: 0; transform: scale(0.97); } } @keyframes ds-sheet-in-right { from { transform: translateX(100%); } } @keyframes ds-sheet-out-right { to { transform: translateX(100%); } } @keyframes ds-sheet-in-left { from { transform: translateX(-100%); } } @keyframes ds-sheet-out-left { to { transform: translateX(-100%); } } @keyframes ds-sheet-in-top { from { transform: translateY(-100%); } } @keyframes ds-sheet-out-top { to { transform: translateY(-100%); } } @keyframes ds-sheet-in-bottom { from { transform: translateY(100%); } } @keyframes ds-sheet-out-bottom { to { transform: translateY(100%); } } @keyframes ds-content-in { from { opacity: 0; transform: translateY(4px); } } @keyframes ds-accordion-down { from { height: 0; opacity: 0; } to { height: var(--radix-accordion-content-height); } } @keyframes ds-accordion-up { from { height: var(--radix-accordion-content-height); } to { height: 0; opacity: 0; } } @keyframes ds-collapsible-down { from { height: 0; opacity: 0; } to { height: var(--radix-collapsible-content-height); } } @keyframes ds-collapsible-up { from { height: var(--radix-collapsible-content-height); } to { height: 0; opacity: 0; } } @keyframes ds-pulse-soft { 50% { opacity: 0.55; } } @keyframes ds-caret-blink { 50% { opacity: 0; } } @keyframes ds-chart-line { from { stroke-dashoffset: 1; } } @keyframes ds-chart-bar { from { transform: scaleY(0.04); } } } @media (prefers-reduced-motion: reduce) { @keyframes ds-pop-in { from { opacity: 0; } } @keyframes ds-pop-out { to { opacity: 0; } } @keyframes ds-dialog-in { from { opacity: 0; } } @keyframes ds-dialog-out { to { opacity: 0; } } @keyframes ds-content-in { from { opacity: 0; } } @keyframes ds-sheet-in-right { from { opacity: 0; } } @keyframes ds-sheet-out-right { to { opacity: 0; } } @keyframes ds-sheet-in-left { from { opacity: 0; } } @keyframes ds-sheet-out-left { to { opacity: 0; } } @keyframes ds-sheet-in-top { from { opacity: 0; } } @keyframes ds-sheet-out-top { to { opacity: 0; } } @keyframes ds-sheet-in-bottom { from { opacity: 0; } } @keyframes ds-sheet-out-bottom { to { opacity: 0; } } @keyframes ds-chart-line { from { opacity: 0; } } @keyframes ds-chart-bar { from { opacity: 0; } } } /* Shared interaction rules for installed CSS and the component theme. */ @layer base { :where(button, [role="button"], a[href], input, select, textarea, summary) { touch-action: manipulation; } } /* Press feedback is immediate; the action still activates through the native click/keyboard path. */ :where(button, [role="button"], a[href], summary):active { transition-duration: 0s !important; transition-delay: 0s !important; } /* Keyboard feedback and explicitly high-frequency surfaces never wait for motion. */ :where(button, [role="button"], a[href], input, select, textarea, summary):focus-visible, :where(button, [role="button"], a[href], input, select, textarea, summary):focus-visible *, :where([data-ds-motion="instant"], [data-instant="true"], [data-slot^="command"]), :where([data-ds-motion="instant"], [data-instant="true"], [data-slot^="command"]) *, :where([data-ds-motion="instant"], [data-instant="true"], [data-slot^="command"]) *::before, :where([data-ds-motion="instant"], [data-instant="true"], [data-slot^="command"]) *::after, :where([data-ds-motion="instant"], [data-instant="true"], [data-slot^="command"])::before, :where([data-ds-motion="instant"], [data-instant="true"], [data-slot^="command"])::after { animation: none !important; transition: none !important; scroll-behavior: auto !important; } @media (prefers-reduced-motion: reduce) { *, ::before, ::after { animation: none !important; transition: none !important; scroll-behavior: auto !important; } } ``` # Accordion Sections that open in place: 14px ink labels with a caret at the right, ruled by the faint lilac line. Install: npx inkbox-ds add accordion Requires: theme, utils Packages: @chakra-ui/react, @phosphor-icons/react Exports: Accordion, AccordionContent, AccordionItem, AccordionTrigger Client boundary: yes Reference: Figma 427:2715: Create Agent Identity (Advanced settings - tunnel mode, phone state, line sharing) ## Usage Use for settings or answers people open one at a time (advanced options, FAQs). Pass type="single" collapsible for one open at a time, type="multiple" for several. Keep the label a short phrase; the content is 14px secondary text or a group of fields. - Avoid: Bold or purple trigger labels - Avoid: A chevron at the left - Avoid: Boxing each item in a card - Avoid: Opening animation longer than 200ms ## Examples Example components: npx inkbox-ds add accordion [Preview](https://inkbox-ds.vercel.app/preview/accordion/Preview) | [Multiple](https://inkbox-ds.vercel.app/preview/accordion/Multiple) examples/accordion.tsx ```tsx import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"; export function Preview() { return ( What does the tunnel do? It gives each agent a stable hostname (acme.inkboxwire.com) and routes inbound traffic to wherever the agent runs, with TLS at the edge or passed through. Can one agent have a phone number and iMessage? Yes. Turn on Phone Number for calls and SMS, and iMessage for a shared or dedicated line. Each channel keeps its own contact rules. Who can read the vault? Only people with the vault key. Encryption and decryption happen in the browser, so the server never sees your secrets. ); } export function Multiple() { return ( Email acme-support@inboxmail.com receives and sends mail for this agent. Calls and texts Any state. Calls and SMS are on; RCS needs a dedicated line. Agent2Agent (not enabled) Turn on Agent2Agent to publish this identity's Agent Card. ); } ``` ## Source components/ui/accordion.tsx ```tsx "use client"; import * as React from "react"; import { Accordion as ChakraAccordion } from "@chakra-ui/react"; import { CaretDownIcon } from "@phosphor-icons/react/ssr"; import { cn } from "@/lib/utils"; // Sections that open in place, drawn as the dialog's Advanced settings row (Figma 427:2715, Create Agent Identity): a // 14px regular ink label with a 20px caret at the right, items ruled by the faint lilac line. The panel opens with its // height (200ms, the strong ease-out; closing is quicker) and the caret turns to point up. type AccordionProps = Omit, "multiple" | "collapsible" | "value" | "defaultValue"> & { type?: "single" | "multiple"; collapsible?: boolean; value?: string | string[]; defaultValue?: string | string[] }; function Accordion({ type = "single", collapsible, value, defaultValue, ...props }: AccordionProps) { return ; } function AccordionItem({ className, ...props }: React.ComponentProps) { return ; } function AccordionTrigger({ className, children, ...props }: React.ComponentProps) { return ( {children} ); } function AccordionContent({ className, children, ...props }: React.ComponentProps) { return ( {children} ); } export { Accordion, AccordionContent, AccordionItem, AccordionTrigger }; ``` # Alert Dialog The console's dialog for a question that must be answered: title, a secondary line and two pills. Install: npx inkbox-ds add alert-dialog Requires: theme, button, surface, utils Packages: radix-ui Exports: AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogMedia, AlertDialogOverlay, AlertDialogPortal, AlertDialogTitle, AlertDialogTrigger Client boundary: yes Reference: Figma 427:2715: Modal - Create Agent (panel, scrim, title) with the console's pill buttons ## Usage Use before an action that cannot be undone (delete an agent, rotate an API key) or when the person must acknowledge something. Name the action on its button (Delete agent), use variant="destructive" for it, and keep Cancel beside it. - Avoid: OK and Cancel as labels - Avoid: A close glyph on an alert dialog - Avoid: Closing on a click outside - Avoid: More than two actions ## Examples Example components: npx inkbox-ds add alert-dialog button Example packages: @phosphor-icons/react [Preview](https://inkbox-ds.vercel.app/preview/alert-dialog/Preview) | [Small](https://inkbox-ds.vercel.app/preview/alert-dialog/Small) | [Open](https://inkbox-ds.vercel.app/preview/alert-dialog/Open) examples/alert-dialog.tsx ```tsx "use client"; import { KeyIcon, TrashIcon } from "@phosphor-icons/react/ssr"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogMedia, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; function DeleteAgent() { return ( Delete @acme? Its mailbox, phone line and tunnel are deleted with it. Messages it sent stay with their recipients. This cannot be undone. Cancel Delete agent ); } export function Preview() { return (
); } export function Small() { return (
Rotate the API key? Agents using the old key stop working until you give them the new one. Cancel Rotate
); } export function Open() { return (
); } ``` ## Source components/ui/alert-dialog.tsx ```tsx "use client"; import * as React from "react"; import { AlertDialog as AlertDialogPrimitive } from "radix-ui"; import { buttonVariants } from "@/components/ui/button"; import { usePortalContainer } from "@/lib/surface"; import { cn } from "@/lib/utils"; // A confirmation, drawn as the console's dialog (Figma 427:2715: Create Agent Identity, @test-agent is ready!): a white // panel with 16px corners over the blurred, ink-tinted page, a 20px regular title and a 14px secondary line, 24px of // padding, then the actions: the cancel as the lilac pill and the action as the purple pill (red for a destructive // one), side by side at the end, stacked full width on phones. `size="sm"` is a 320px card with the title centered // under an optional media tile (AlertDialogMedia). It has no close glyph: the person answers it. Opens from 96 percent // with a fade, centered; closes faster. function AlertDialog(props: React.ComponentProps) { return ; } function AlertDialogTrigger(props: React.ComponentProps) { return ; } function AlertDialogPortal(props: React.ComponentProps) { const container = usePortalContainer(); return ; } function AlertDialogOverlay({ className, ...props }: React.ComponentProps) { return ( ); } function AlertDialogContent({ className, size = "default", ...props }: React.ComponentProps & { size?: "default" | "sm" }) { return ( ); } function AlertDialogHeader({ className, ...props }: React.ComponentProps<"div">) { return
; } function AlertDialogFooter({ className, ...props }: React.ComponentProps<"div">) { return (
*]:w-full", className)} {...props} /> ); } /** A 48px tile above the title (the console's pink channel tile): put a filled glyph inside. */ function AlertDialogMedia({ className, ...props }: React.ComponentProps<"div">) { return
; } function AlertDialogTitle({ className, ...props }: React.ComponentProps) { return ; } function AlertDialogDescription({ className, ...props }: React.ComponentProps) { return ; } function AlertDialogAction({ className, variant = "default", ...props }: React.ComponentProps & { variant?: "default" | "destructive" }) { return ; } function AlertDialogCancel({ className, ...props }: React.ComponentProps) { return ; } export { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogMedia, AlertDialogOverlay, AlertDialogPortal, AlertDialogTitle, AlertDialogTrigger, }; ``` # Alert The vault box on the lilac, the pale cyan info note, the sand warning banner with a note at the right, and a red error. Install: npx inkbox-ds add alert Requires: theme, utils Packages: class-variance-authority Exports: Alert, AlertAction, AlertDescription, AlertTitle, alertVariants Client boundary: not required by this entry file Reference: Figma 427:2715: Vault (Initialize vault, zero-knowledge note), Modal - Created (Copy this API key now) ## Usage Use default for a panel that introduces a setup step (Initialize vault: glyph, 16px title, muted description). Use info for a fact the person should know (The vault is zero-knowledge), warning for something to act on now (Copy this API key now, with the reason in AlertAction), destructive for a failure. Lead with a filled Phosphor glyph. - Avoid: A dark or saturated alert background - Avoid: More than one alert stacked for one message - Avoid: Dismissable warnings for security notes - Avoid: A bold title ## Examples Example components: npx inkbox-ds add alert button Example packages: @phosphor-icons/react [Preview](https://inkbox-ds.vercel.app/preview/alert/Preview) | [Info](https://inkbox-ds.vercel.app/preview/alert/Info) | [Warning](https://inkbox-ds.vercel.app/preview/alert/Warning) | [Destructive](https://inkbox-ds.vercel.app/preview/alert/Destructive) examples/alert.tsx ```tsx import { InfoIcon, LockIcon, WarningIcon, XCircleIcon } from "@phosphor-icons/react/ssr"; import { Alert, AlertAction, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; export function Preview() { return ( Initialize vault

Create an encrypted vault for your organization. The vault can store login credentials including MFA one-time passwords (OTP), SSH keys, API keys and other secrets for your agents.

The vault is zero-knowledge: all encryption and decryption happens in your browser. The server never sees your secrets.
); } export function Info() { return ( The vault is zero-knowledge: all encryption and decryption happens in your browser. The server never sees your secrets. ); } export function Warning() { return ( Copy this API key now For security, the full key cannot be shown again. ); } export function Destructive() { return ( Webhook delivery failed https://hooks.example.com/inkbox returned 502 three times. Deliveries are paused until it responds. ); } ``` ## Source components/ui/alert.tsx ```tsx import * as React from "react"; import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "@/lib/utils"; // The console's notes (Figma 427:2715). `default` is the vault box: a lilac panel with 8px corners, a 22px filled ink // glyph beside a 16px regular title, then a 14px muted description across the full width (it can hold an info note). `info` is the zero-knowledge note: pale cyan, 8px corners, // a filled info glyph and 14px deep teal text. `warning` is the copy-this-key banner: pale sand, 10px corners, a filled // warning glyph, a 14px medium title and a 13px note at the right (AlertAction). `destructive` borrows the failed // field's red. Put a Phosphor glyph first; the title and description line up beside it. const alertVariants = cva( "@container/alert group/alert relative grid w-full grid-cols-[minmax(0,1fr)_auto] items-start gap-x-2.5 gap-y-0.5 font-sans text-body leading-body has-[>svg]:grid-cols-[auto_minmax(0,1fr)_auto] [&>svg]:shrink-0", { variants: { variant: { default: "items-center rounded-control bg-subtle px-3.5 py-3 text-fg [&>svg]:size-6 [&>svg]:text-icon-default [&>[data-slot=alert-description]]:col-span-full [&>[data-slot=alert-description]]:col-start-1 [&>[data-slot=alert-description]]:text-fg-muted [&>[data-slot=alert-title]]:text-body-lg [&>[data-slot=alert-title]]:leading-snug", info: "rounded-control bg-info-bg px-4 py-2 text-info-fg [&>svg]:size-5 [&>svg]:text-info", warning: "rounded-panel bg-warning-bg px-4 py-2 text-warning-fg [&>svg]:size-5.5 [&>svg]:text-warning [&>[data-slot=alert-action]]:text-warning-muted [&>[data-slot=alert-description]]:text-warning-muted [&>[data-slot=alert-title]]:font-medium", destructive: "rounded-control border border-danger bg-surface px-3.5 py-3 text-danger [&>svg]:size-5.5 [&>svg]:text-danger-strong [&>[data-slot=alert-description]]:text-fg-secondary [&>[data-slot=alert-title]]:font-medium", }, }, defaultVariants: { variant: "default" }, }, ); function Alert({ className, variant, ...props }: React.ComponentProps<"div"> & VariantProps) { return
; } function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { return (
svg]/alert:col-start-2", className, )} {...props} /> ); } function AlertDescription({ className, ...props }: React.ComponentProps<"div">) { return (
svg]/alert:col-start-2 [&_p]:leading-body", className, )} {...props} /> ); } /** What sits at the right of an alert: a note (For security, the full key cannot be shown again) or a button. */ function AlertAction({ className, ...props }: React.ComponentProps<"div">) { return (
svg]/alert:col-start-3 @max-sm/alert:col-start-1 @max-sm/alert:col-end-[-1] @max-sm/alert:row-start-3 @max-sm/alert:row-span-1 @max-sm/alert:text-left @max-sm/alert:group-has-[>svg]/alert:col-start-2", className)} {...props} /> ); } export { Alert, AlertAction, AlertDescription, AlertTitle, alertVariants }; ``` # Aspect Ratio A frame that holds media at a fixed shape. Install: npx inkbox-ds add aspect-ratio Requires: theme Packages: radix-ui Exports: AspectRatio Client boundary: yes Reference: Extrapolated from the Inkbox console (Figma 427:2715): the 8px corners of its cards and wells ## Usage Wrap a screenshot, an attachment preview or a video in AspectRatio with ratio={16 / 9} (or 4 / 3, 1) so it keeps its shape at every width. Round and clip it with rounded-control and overflow-hidden. - Avoid: Stretching an image to fill without object-cover - Avoid: Corners other than the card's 8px on media inside cards ## Examples Example components: npx inkbox-ds add aspect-ratio [Preview](https://inkbox-ds.vercel.app/preview/aspect-ratio/Preview) | [Square](https://inkbox-ds.vercel.app/preview/aspect-ratio/Square) examples/aspect-ratio.tsx ```tsx import { AspectRatio } from "@/components/ui/aspect-ratio"; export function Preview() { return (
{/* eslint-disable-next-line @next/next/no-img-element */} The Inkbox octopus
); } export function Square() { return (
1:1
); } ``` ## Source components/ui/aspect-ratio.tsx ```tsx "use client"; import * as React from "react"; import { AspectRatio as AspectRatioPrimitive } from "radix-ui"; // Holds media at a fixed shape (a screenshot, an attachment preview). The frame takes no style of its own; round and // clip the media inside it with the card's 8px corners. function AspectRatio(props: React.ComponentProps) { return ; } export { AspectRatio }; ``` # Attachment A white file attachment with the field edge: horizontal rows and their pink file icons are fully rounded; vertical cards retain rounded corners. Names use 14px medium ink and sizes use IBM Plex Mono. Install: npx inkbox-ds add attachment Requires: theme, button, utils Packages: @chakra-ui/react, class-variance-authority Exports: Attachment, AttachmentAction, AttachmentActions, AttachmentContent, AttachmentDescription, AttachmentGroup, AttachmentMedia, AttachmentTitle, AttachmentTrigger Client boundary: yes Reference: Extrapolated from the Inkbox console (Figma 427:2715): the webhook cards (white, field edge, 8px corners) and the pink channel tiles of Create Agent Identity ## Usage Show a file attached to an email, a message or a draft. Set state to idle while it waits, uploading or processing while it moves, error when it failed (say why in AttachmentDescription), and done when it is ready. Add AttachmentTrigger to open the file and AttachmentAction for remove or retry. Lay several out in an AttachmentGroup, which scrolls sideways. - Avoid: A colored card behind the file - Avoid: Sizes in Inter - Avoid: A spinner over the whole card - Avoid: An icon that moves on hover ## Examples Example components: npx inkbox-ds add attachment Example packages: @phosphor-icons/react [Preview](https://inkbox-ds.vercel.app/preview/attachment/Preview) | [States](https://inkbox-ds.vercel.app/preview/attachment/States) | [Vertical](https://inkbox-ds.vercel.app/preview/attachment/Vertical) | [Sizes](https://inkbox-ds.vercel.app/preview/attachment/Sizes) examples/attachment.tsx ```tsx "use client"; import * as React from "react"; import { ArrowClockwiseIcon, DownloadSimpleIcon, FileCsvIcon, FilePdfIcon, FileTextIcon, ImageIcon, XIcon } from "@phosphor-icons/react/ssr"; import { Attachment, AttachmentAction, AttachmentActions, AttachmentContent, AttachmentDescription, AttachmentGroup, AttachmentMedia, AttachmentTitle, AttachmentTrigger } from "@/components/ui/attachment"; export function Preview() { const [files, setFiles] = React.useState([ { name: "invoice-2026-08.pdf", size: "248 KB", icon: FilePdfIcon }, { name: "contacts-export.csv", size: "1.2 MB", icon: FileCsvIcon }, { name: "agent-card.json", size: "3 KB", icon: FileTextIcon }, ]); return ( {files.map(({ name, size, icon: Icon }) => ( {name} {size} setFiles((all) => all.filter((f) => f.name !== name))}> ))} ); } export function States() { return (
quickstart.md Waiting contract-signed.pdf 62% of 4.8 MB leads.csv Larger than 25 MB
); } export function Vertical() { return (
{/* eslint-disable-next-line @next/next/no-img-element */} octopus.png 94 KB screenshot.png Scanning
); } export function Sizes() { return (
{(["default", "sm", "xs"] as const).map((size) => ( invoice.pdf 248 KB ))}
); } ``` ## Source components/ui/attachment.tsx ```tsx "use client"; import * as React from "react"; import { chakra } from "@chakra-ui/react"; import { cva, type VariantProps } from "class-variance-authority"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; // A file attached to a message or an email, drawn as the console's cards (Figma 427:2715): white with the field edge // and pill corners on horizontal rows. Vertical cards keep their rounded corners. The file sits in a pink tile // with a rose glyph; its name is 14px medium ink and its size or status 12px muted in IBM Plex Mono. A waiting file has a // dashed edge, one uploading pulses its name softly, one that failed takes the red edge and a red line. const attachmentVariants = cva( "group/attachment relative flex w-fit max-w-full min-w-0 shrink-0 flex-wrap rounded-control border border-line-field bg-surface text-fg transition-[background-color,border-color] duration-150 ease-out has-[>[data-slot=attachment-trigger]]:hover:bg-subtle has-[>[data-slot=attachment-trigger]:focus-visible]:outline-2 has-[>[data-slot=attachment-trigger]:focus-visible]:outline-offset-2 has-[>[data-slot=attachment-trigger]:focus-visible]:outline-focus-ring data-[state=error]:border-danger data-[state=idle]:border-dashed motion-reduce:transition-none", { variants: { size: { default: "gap-3 text-body has-data-[slot=attachment-content]:px-3 has-data-[slot=attachment-content]:py-2.5 has-data-[slot=attachment-media]:p-2.5", sm: "gap-2.5 text-body has-data-[slot=attachment-content]:px-2.5 has-data-[slot=attachment-content]:py-2 has-data-[slot=attachment-media]:p-2", xs: "gap-2 rounded-sm text-body-sm has-data-[slot=attachment-content]:px-2 has-data-[slot=attachment-content]:py-1.5 has-data-[slot=attachment-media]:p-1.5", }, orientation: { horizontal: "min-w-48 items-center rounded-pill", vertical: "w-28 flex-col has-data-[slot=attachment-content]:w-36", }, }, defaultVariants: { size: "default", orientation: "horizontal" }, }, ); type AttachmentState = "idle" | "uploading" | "processing" | "error" | "done"; function Attachment({ className, state = "done", size = "default", orientation = "horizontal", ...props }: React.ComponentProps<"div"> & VariantProps & { state?: AttachmentState }) { return
; } const attachmentMediaVariants = cva( "relative flex aspect-square w-10 shrink-0 items-center justify-center overflow-hidden rounded-pill bg-pink text-rose group-data-[orientation=vertical]/attachment:w-full group-data-[size=sm]/attachment:w-8 group-data-[size=xs]/attachment:w-7 group-data-[orientation=vertical]/attachment:rounded-sm group-data-[orientation=vertical]/attachment:group-data-[size=xs]/attachment:rounded-xs group-data-[state=error]/attachment:bg-subtle group-data-[state=error]/attachment:text-danger [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-5.5 group-data-[orientation=vertical]/attachment:[&_svg:not([class*='size-'])]:size-7 group-data-[size=xs]/attachment:[&_svg:not([class*='size-'])]:size-4", { variants: { variant: { icon: "", // A photo shows itself; it stays faint until it has uploaded. image: "bg-subtle opacity-60 group-data-[state=done]/attachment:opacity-100 group-data-[state=idle]/attachment:opacity-100 *:[img]:aspect-square *:[img]:w-full *:[img]:object-cover", }, }, defaultVariants: { variant: "icon" }, }, ); function AttachmentMedia({ className, variant = "icon", ...props }: React.ComponentProps<"div"> & VariantProps) { return
; } function AttachmentContent({ className, ...props }: React.ComponentProps<"div">) { return
; } function AttachmentTitle({ className, ...props }: React.ComponentProps<"span">) { return ( ); } function AttachmentDescription({ className, ...props }: React.ComponentProps<"span">) { return ; } function AttachmentActions({ className, ...props }: React.ComponentProps<"div">) { return
; } /** A row action on the attachment (remove, retry, download): a ghost glyph button. */ function AttachmentAction({ className, variant, size = "icon-sm", ...props }: React.ComponentProps) { return
); } ``` ## Source components/ui/bubble.tsx ```tsx "use client"; import * as React from "react"; import { chakra } from "@chakra-ui/react"; import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "@/lib/utils"; // A message bubble in an agent conversation, in the console's colors (Figma 427:2715, iMessage and the inbox): an // incoming message on the lilac with ink text, an outgoing one in the action purple with white text. 14px text on a 21px // line, 14px by 10px of padding, 16px corners with the tail corner (bottom left for incoming, bottom right for outgoing) // cut to 4px. In a BubbleGroup only the last bubble keeps the tail. Bubbles never animate in: a conversation is read // all day. function BubbleGroup({ className, ...props }: React.ComponentProps<"div">) { return
[data-slot=bubble]:not(:last-child)>[data-slot=bubble-content]]:rounded-dialog", className)} {...props} />; } const bubbleVariants = cva( "group/bubble relative flex w-fit max-w-[80%] min-w-0 flex-col gap-1 group-data-[align=end]/message:self-end data-[align=end]:self-end data-[align=start]:*:data-[slot=bubble-content]:rounded-bl-xs data-[align=end]:*:data-[slot=bubble-content]:rounded-br-xs data-[variant=ghost]:max-w-full", { variants: { variant: { // Outgoing: the purple, as the main action. default: "*:data-[slot=bubble-content]:bg-primary *:data-[slot=bubble-content]:text-on-primary [&>[data-slot=bubble-content]:is(button,a):hover]:bg-primary-hover", // Incoming: the lilac. secondary: "*:data-[slot=bubble-content]:bg-subtle *:data-[slot=bubble-content]:text-fg [&>[data-slot=bubble-content]:is(button,a):hover]:bg-subtle-hover", // A system or quoted message: the cool gray of a neutral tag. muted: "*:data-[slot=bubble-content]:bg-tag-gray *:data-[slot=bubble-content]:text-fg-secondary [&>[data-slot=bubble-content]:is(button,a):hover]:bg-subtle-hover", // Another agent (Agent2Agent): the pale purple of a Signed tag. tinted: "*:data-[slot=bubble-content]:bg-tag-purple *:data-[slot=bubble-content]:text-fg [&>[data-slot=bubble-content]:is(button,a):hover]:bg-subtle-hover", outline: "*:data-[slot=bubble-content]:border-line-field *:data-[slot=bubble-content]:bg-surface *:data-[slot=bubble-content]:text-fg [&>[data-slot=bubble-content]:is(button,a):hover]:bg-subtle", ghost: "*:data-[slot=bubble-content]:rounded-none *:data-[slot=bubble-content]:bg-transparent *:data-[slot=bubble-content]:p-0 *:data-[slot=bubble-content]:text-fg", // A message that failed to send. destructive: "*:data-[slot=bubble-content]:border-danger *:data-[slot=bubble-content]:bg-surface *:data-[slot=bubble-content]:text-danger [&>[data-slot=bubble-content]:is(button,a):hover]:bg-subtle", }, }, defaultVariants: { variant: "default" }, }, ); function Bubble({ variant = "default", align = "start", className, ...props }: React.ComponentProps<"div"> & VariantProps & { align?: "start" | "end" }) { return
; } function BubbleContent({ asChild = false, className, ...props }: React.ComponentProps<"div"> & { asChild?: boolean }) { const Comp = asChild ? chakra.div : "div"; // Chakra's factory renders its child in its place only when told to. const slotProps = asChild ? { asChild: true } : {}; return ( ); } const bubbleReactionsVariants = cva( "absolute z-10 flex w-fit shrink-0 items-center justify-center gap-1 rounded-pill bg-surface px-1.5 py-0.5 text-body-sm leading-tight text-fg shadow-s has-[button]:p-0", { variants: { side: { top: "top-0 -translate-y-3/4", bottom: "bottom-0 translate-y-3/4" }, align: { start: "left-3", end: "right-3" }, }, defaultVariants: { side: "bottom", align: "end" }, }, ); /** Reactions pinned to a bubble's edge: a white pill with Shadow/S. */ function BubbleReactions({ side = "bottom", align = "end", className, ...props }: React.ComponentProps<"div"> & { align?: "start" | "end"; side?: "top" | "bottom" }) { return
; } export { Bubble, BubbleContent, BubbleGroup, BubbleReactions, bubbleVariants }; ``` # Button Group Buttons, fields and text segments joined into one bar with shared edges and outer corners only. Install: npx inkbox-ds add button-group Requires: theme, utils Packages: @chakra-ui/react, class-variance-authority Exports: ButtonGroup, ButtonGroupSeparator, ButtonGroupText, buttonGroupVariants Client boundary: yes Reference: Extrapolated from the Inkbox console (Figma 427:2715): the outline keys of the created dialog and the Email channel card's split field ## Usage Use to join related actions into one control (View as formatted beside Copy as markdown), a field with an action, or a field with a fixed part (the @inboxmail.com suffix as ButtonGroupText). Nest groups to keep space between sets. - Avoid: Joining a purple pill with an outline key - Avoid: More than five buttons in a group - Avoid: Joining unrelated actions ## Examples Example components: npx inkbox-ds add button-group button input Example packages: @phosphor-icons/react [Preview](https://inkbox-ds.vercel.app/preview/button-group/Preview) | [WithField](https://inkbox-ds.vercel.app/preview/button-group/WithField) | [Split](https://inkbox-ds.vercel.app/preview/button-group/Split) | [Vertical](https://inkbox-ds.vercel.app/preview/button-group/Vertical) examples/button-group.tsx ```tsx import { CaretDownIcon, CopyIcon, FileTextIcon, MinusIcon, PlusIcon } from "@phosphor-icons/react/ssr"; import { Button } from "@/components/ui/button"; import { ButtonGroup, ButtonGroupSeparator, ButtonGroupText } from "@/components/ui/button-group"; import { Input } from "@/components/ui/input"; export function Preview() { return ( ); } export function WithField() { return (
@inboxmail.com
); } export function Split() { return (
2
); } export function Vertical() { return ( ); } ``` ## Source components/ui/button-group.tsx ```tsx "use client"; import * as React from "react"; import { chakra } from "@chakra-ui/react"; import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "@/lib/utils"; // Buttons joined into one control (extrapolated from the Inkbox console, Figma 427:2715: the outline keys of the // created dialog and the split field of the Email channel card). Inner corners square off and the shared edges merge, // so outline keys read as one 36px bar with fully rounded outer corners; a field, select or text segment can sit in the bar. // ButtonGroupText is the lilac segment with muted 14px text (@inboxmail.com); ButtonGroupSeparator is a 1px rule // between filled buttons. const buttonGroupVariants = cva( "group/button-group flex w-fit items-stretch *:focus-visible:relative *:focus-visible:z-10 has-[>[data-slot=button-group]]:gap-2 has-[>input]:[&>[data-slot=button]]:h-10 has-[>input]:[&>[data-slot=button-group-text]]:h-10 [&>input]:flex-1", { variants: { orientation: { horizontal: "[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none", vertical: "flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none", }, }, defaultVariants: { orientation: "horizontal" }, }, ); function ButtonGroup({ className, orientation, ...props }: React.ComponentProps<"div"> & VariantProps) { return
; } function ButtonGroupText({ className, asChild = false, ...props }: React.ComponentProps<"div"> & { asChild?: boolean }) { const Comp = asChild ? chakra.div : "div"; // Chakra's factory renders its child in its place only when told to. const slotProps = asChild ? { asChild: true } : {}; return ( ); } function ButtonGroupSeparator({ className, orientation = "vertical", ...props }: React.ComponentProps<"div"> & { orientation?: "horizontal" | "vertical" }) { return (
); } export { ButtonGroup, ButtonGroupSeparator, ButtonGroupText, buttonGroupVariants }; ``` # Button The purple pill for the main action, the lilac pill, the white outline key, the soft lilac key, ghost glyphs and text links. Install: npx inkbox-ds add button Requires: theme, utils Packages: @chakra-ui/react, class-variance-authority Exports: Button, buttonVariants Client boundary: yes Reference: Figma 427:2715: Create Agent, Enable iMessage, Log in, Continue with GitHub, View as formatted, Tunnel quickstart ## Usage Use one default (purple pill) button per view for the main action, secondary for the alternatives beside it (Continue with GitHub), outline for actions inside content (View as formatted), soft for an action in a page head (Tunnel quickstart), ghost with size icon for row actions, and link for an inline action in text. Use type="submit" in forms, or asChild with a link for navigation. Put a Phosphor glyph first for an action that adds or creates. - Avoid: Two purple pills side by side - Avoid: A square or 8px-cornered main action - Avoid: Bold or caps labels - Avoid: An icon that moves on hover ## Examples Example components: npx inkbox-ds add button Example packages: @phosphor-icons/react [Preview](https://inkbox-ds.vercel.app/preview/button/Preview) | [Variants](https://inkbox-ds.vercel.app/preview/button/Variants) | [States](https://inkbox-ds.vercel.app/preview/button/States) examples/button.tsx ```tsx import { CopyIcon, FileTextIcon, GithubLogoIcon, LightningIcon, PencilSimpleIcon, PlusIcon, TrashIcon } from "@phosphor-icons/react/ssr"; import { Button } from "@/components/ui/button"; export function Preview() { return (
); } export function Variants() { return (
); } export function States() { return (
); } ``` ## Source components/ui/button.tsx ```tsx "use client"; import * as React from "react"; import { Button as ChakraButton } from "@chakra-ui/react"; import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "@/lib/utils"; // The console's buttons (Figma 427:2715). The main action is a 40px purple pill with a 14px regular white label and a // 20px glyph 8px from it (Create Agent, Enable iMessage, Log in). `secondary` is the same pill in lilac with ink text // (Continue with GitHub). `outline` is the 36px white key with fully rounded corners, the field edge and a medium purple label // (View as formatted, Copy as markdown). `soft` is the 38px lilac key in a page head (Tunnel quickstart). `ghost` is a // bare glyph or label that washes on hover (a row's edit pencil). Press feedback changes the fill immediately without resizing. const buttonVariants = cva( "inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap font-sans text-body leading-tight outline-none select-none transition-[background-color,color] duration-150 ease-out focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus-ring active:brightness-95 active:transition-none disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-5.5", { variants: { variant: { default: "rounded-pill bg-primary px-5 text-on-primary hover:bg-primary-hover active:bg-primary-hover", secondary: "rounded-pill bg-subtle px-5 text-fg hover:bg-subtle-hover active:bg-subtle-hover", destructive: "rounded-pill bg-danger px-5 text-white hover:bg-danger-strong active:bg-danger-strong", outline: "rounded-pill border border-line-field bg-surface px-2.5 font-medium text-fg-brand hover:bg-subtle active:bg-subtle", soft: "rounded-pill bg-subtle px-3 text-fg hover:bg-subtle-hover active:bg-subtle-hover [&_svg:not([class*='size-'])]:size-6", ghost: "rounded-pill text-icon-secondary hover:bg-subtle active:bg-subtle hover:text-fg data-[state=open]:bg-subtle", link: "h-auto rounded-pill px-0 text-fg-brand underline-offset-4 hover:underline", }, size: { default: "h-10", sm: "h-9 [&_svg:not([class*='size-'])]:size-5.5", lg: "h-11 px-6", icon: "size-8 rounded-pill px-0", "icon-sm": "size-7 rounded-pill px-0 [&_svg:not([class*='size-'])]:size-4.5", "icon-lg": "size-10 px-0", }, }, compoundVariants: [ { variant: "outline", size: "default", className: "h-9" }, { variant: "soft", size: "default", className: "h-9.5 gap-2.5" }, { variant: "ghost", size: "default", className: "h-8 px-2" }, { variant: "link", size: "default", className: "h-auto" }, ], defaultVariants: { variant: "default", size: "default" }, }, ); type ButtonProps = Omit, "variant" | "size"> & VariantProps & { asChild?: boolean }; function Button({ className, variant, size, asChild = false, type, ...props }: ButtonProps) { return ; } export { Button, buttonVariants }; ``` # Calendar A month grid of 36px keys: today in purple, selected days as purple keys, a range on a lilac band. Install: npx inkbox-ds add calendar Requires: theme, utils Packages: @phosphor-icons/react Exports: Calendar, CalendarDayButton, CalendarProps, DateRange Client boundary: yes Reference: Extrapolated from the Inkbox console (Figma 427:2715): 8px keys, the lilac wash and the purple action ## Usage Use to pick a day, several days or a range (a delivery log window, a report period) when the month view helps. Set mode to single, multiple or range, pass selected and onSelect, and disable days that cannot be picked. For a field that opens a calendar, use Date Picker. - Avoid: Round day keys - Avoid: A dark selected day - Avoid: Animating between months ## Examples Example components: npx inkbox-ds add calendar [Preview](https://inkbox-ds.vercel.app/preview/calendar/Preview) | [Range](https://inkbox-ds.vercel.app/preview/calendar/Range) | [Multiple](https://inkbox-ds.vercel.app/preview/calendar/Multiple) | [Disabled](https://inkbox-ds.vercel.app/preview/calendar/Disabled) | [Dropdown](https://inkbox-ds.vercel.app/preview/calendar/Dropdown) examples/calendar.tsx ```tsx "use client"; import * as React from "react"; import { Calendar, type DateRange } from "@/components/ui/calendar"; const TODAY = new Date(2026, 7, 8); const FRAME = "w-fit rounded-control border border-line-field bg-surface"; export function Preview() { const [date, setDate] = React.useState(TODAY); return ; } export function Range() { const [range, setRange] = React.useState({ from: new Date(2026, 7, 4), to: new Date(2026, 7, 12) }); return ; } export function Multiple() { const [days, setDays] = React.useState([new Date(2026, 7, 3), new Date(2026, 7, 10), new Date(2026, 7, 17)]); return ; } export function Disabled() { const [date, setDate] = React.useState(); return ; } export function Dropdown() { const [date, setDate] = React.useState(new Date(1994, 4, 21)); return ; } ``` ## Source components/ui/calendar.tsx ```tsx "use client"; import * as React from "react"; import { CaretDownIcon, CaretLeftIcon, CaretRightIcon } from "@phosphor-icons/react/ssr"; import { cn } from "@/lib/utils"; // A month grid (extrapolated from the Inkbox console, Figma 427:2715: its 8px keys, the lilac wash and the purple // action). The caption is 14px medium ink between two ghost caret keys; weekdays are 12px muted; days are 36px keys with // 8px corners and 14px ink. Today is purple and medium; a selected day is the purple key with white text; the days // inside a range sit on a lilac band between its two purple ends; days of the next and last month are faint. Arrow keys // move a day or a week, Home and End go to the week's ends, Page Up and Page Down change the month (with Shift, the // year), Enter or Space selects. Changing month is instant: it happens many times in a row. type DateRange = { from: Date | undefined; to?: Date | undefined }; type Matcher = Date | Date[] | ((date: Date) => boolean) | { before?: Date; after?: Date } | { from: Date; to: Date } | { dayOfWeek: number[] }; type CommonProps = { /** The month shown first (controlled). */ month?: Date; defaultMonth?: Date; onMonthChange?: (month: Date) => void; numberOfMonths?: number; /** Show the days of the months before and after in the grid (default true). */ showOutsideDays?: boolean; /** 0 is Sunday (default), 1 is Monday. */ weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6; disabled?: Matcher | Matcher[]; /** "label" (the default) or "dropdown": month and year selects in the caption. */ captionLayout?: "label" | "dropdown"; fromYear?: number; toYear?: number; /** Today, for the marker (defaults to the current date). */ today?: Date; locale?: string; className?: string; autoFocus?: boolean; }; type SingleProps = { mode?: "single"; selected?: Date; onSelect?: (date: Date | undefined) => void; required?: boolean }; type MultipleProps = { mode: "multiple"; selected?: Date[]; onSelect?: (dates: Date[]) => void; max?: number }; type RangeProps = { mode: "range"; selected?: DateRange; onSelect?: (range: DateRange | undefined) => void; min?: number; max?: number }; type CalendarProps = CommonProps & (SingleProps | MultipleProps | RangeProps); const day0 = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()); const sameDay = (a?: Date, b?: Date) => !!a && !!b && a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate(); const addDays = (d: Date, n: number) => new Date(d.getFullYear(), d.getMonth(), d.getDate() + n); const addMonths = (d: Date, n: number) => { const target = new Date(d.getFullYear(), d.getMonth() + n, 1); const last = new Date(target.getFullYear(), target.getMonth() + 1, 0).getDate(); return new Date(target.getFullYear(), target.getMonth(), Math.min(d.getDate(), last)); }; const startOfMonth = (d: Date) => new Date(d.getFullYear(), d.getMonth(), 1); const diffDays = (a: Date, b: Date) => Math.round((day0(a).getTime() - day0(b).getTime()) / 86_400_000); function matches(date: Date, matcher: Matcher): boolean { if (matcher instanceof Date) return sameDay(date, matcher); if (Array.isArray(matcher)) return matcher.some((d) => sameDay(date, d)); if (typeof matcher === "function") return matcher(date); if ("dayOfWeek" in matcher) return matcher.dayOfWeek.includes(date.getDay()); if ("from" in matcher && "to" in matcher) return diffDays(date, matcher.from) >= 0 && diffDays(matcher.to, date) >= 0; const m = matcher as { before?: Date; after?: Date }; return (!!m.before && diffDays(date, m.before) < 0) || (!!m.after && diffDays(date, m.after) > 0); } /** The weeks shown for a month: whole weeks from the one holding the 1st. */ function weeksOf(month: Date, weekStartsOn: number) { const first = startOfMonth(month); const start = addDays(first, -((first.getDay() - weekStartsOn + 7) % 7)); const weeks: Date[][] = []; for (let w = 0; w < 6; w++) { const week = Array.from({ length: 7 }, (_, i) => addDays(start, w * 7 + i)); if (w > 3 && week[0].getMonth() !== month.getMonth()) break; weeks.push(week); } return weeks; } function Calendar(props: CalendarProps) { const { month: monthProp, defaultMonth, onMonthChange, numberOfMonths = 1, showOutsideDays = true, weekStartsOn = 0, disabled, captionLayout = "label", fromYear, toYear, locale, className, autoFocus } = props; const today = React.useMemo(() => day0(props.today ?? new Date()), [props.today]); const initial = (() => { if (props.mode === "range") return props.selected?.from; if (props.mode === "multiple") return props.selected?.[0]; return props.selected; })(); const [monthState, setMonthState] = React.useState(() => startOfMonth(defaultMonth ?? initial ?? today)); const month = monthProp ? startOfMonth(monthProp) : monthState; const setMonth = (m: Date) => { const next = startOfMonth(m); if (!monthProp) setMonthState(next); onMonthChange?.(next); }; const [focused, setFocused] = React.useState(undefined); const grid = React.useRef(null); const focusPending = React.useRef(!!autoFocus); const isDisabled = (d: Date) => (disabled ? (Array.isArray(disabled) && !(disabled[0] instanceof Date) ? (disabled as Matcher[]).some((m) => matches(d, m)) : matches(d, disabled as Matcher)) : false); const state = (d: Date) => { if (props.mode === "range") { const { from, to } = props.selected ?? { from: undefined }; const start = sameDay(d, from); const end = sameDay(d, to ?? from); const middle = !!from && !!to && diffDays(d, from) > 0 && diffDays(to, d) > 0; return { selected: start || end || middle, start: start && !!to && !sameDay(from, to), end: end && !!to && !sameDay(from, to), middle }; } if (props.mode === "multiple") return { selected: !!props.selected?.some((s) => sameDay(s, d)), start: false, end: false, middle: false }; return { selected: sameDay(props.selected, d), start: false, end: false, middle: false }; }; const select = (d: Date) => { if (isDisabled(d)) return; if (props.mode === "range") { const cur = props.selected; if (!cur?.from || cur.to) { props.onSelect?.({ from: d, to: undefined }); return; } const [from, to] = diffDays(d, cur.from) < 0 ? [d, cur.from] : [cur.from, d]; const length = diffDays(to, from); if ((props.max && length > props.max) || (props.min && length < props.min)) { props.onSelect?.({ from: d, to: undefined }); return; } props.onSelect?.({ from, to }); } else if (props.mode === "multiple") { const cur = props.selected ?? []; const has = cur.some((s) => sameDay(s, d)); if (has) props.onSelect?.(cur.filter((s) => !sameDay(s, d))); else if (!props.max || cur.length < props.max) props.onSelect?.([...cur, d]); } else { const p = props as SingleProps; p.onSelect?.(sameDay(p.selected, d) && !p.required ? undefined : d); } if (d.getMonth() !== month.getMonth() && numberOfMonths === 1) setMonth(d); }; // The day that takes Tab focus in the grid: the focused day, else the selection, else today, else the 1st. const months = Array.from({ length: numberOfMonths }, (_, i) => addMonths(month, i)); const inView = (d: Date) => months.some((m) => m.getMonth() === d.getMonth() && m.getFullYear() === d.getFullYear()); const tabDay = [focused, initial, today, month].find((d) => d && inView(d))!; // Focus the moved-to day, or with autoFocus the day that takes Tab focus (the selection, else today) on mount. const target = focused ?? tabDay; React.useEffect(() => { if (!focusPending.current || !target) return; focusPending.current = false; grid.current?.querySelector(`[data-day="${target.toDateString()}"]`)?.focus(); }, [target, month]); const move = (d: Date) => { if (!inView(d)) setMonth(diffDays(d, month) < 0 ? d : addMonths(d, -(numberOfMonths - 1))); focusPending.current = true; setFocused(d); }; const onKeyDown = (event: React.KeyboardEvent, d: Date) => { const weekStart = addDays(d, -((d.getDay() - weekStartsOn + 7) % 7)); const keys: Record Date> = { ArrowLeft: () => addDays(d, -1), ArrowRight: () => addDays(d, 1), ArrowUp: () => addDays(d, -7), ArrowDown: () => addDays(d, 7), Home: () => weekStart, End: () => addDays(weekStart, 6), PageUp: () => addMonths(d, event.shiftKey ? -12 : -1), PageDown: () => addMonths(d, event.shiftKey ? 12 : 1), }; const next = keys[event.key]; if (!next) return; event.preventDefault(); move(next()); }; const fmt = (opts: Intl.DateTimeFormatOptions) => new Intl.DateTimeFormat(locale, opts); const weekdayNames = Array.from({ length: 7 }, (_, i) => addDays(new Date(2023, 0, 1 + weekStartsOn), i)); const years = (() => { const from = fromYear ?? today.getFullYear() - 100; const to = toYear ?? today.getFullYear() + 10; return Array.from({ length: to - from + 1 }, (_, i) => from + i); })(); return (
{months.map((m, index) => (
{index === 0 && ( )} {captionLayout === "dropdown" && index === 0 ? (
setMonth(new Date(m.getFullYear(), v, 1))} options={Array.from({ length: 12 }, (_, i) => ({ value: i, label: fmt({ month: "short" }).format(new Date(2023, i, 1)) }))} /> setMonth(new Date(v, m.getMonth(), 1))} options={years.map((y) => ({ value: y, label: String(y) }))} />
) : (

{fmt({ month: "long", year: "numeric" }).format(m)}

)} {index === months.length - 1 && ( )}
{weekdayNames.map((w) => ( ))} {weeksOf(m, weekStartsOn).map((week) => ( {week.map((d) => { const outside = d.getMonth() !== m.getMonth(); if (outside && !showOutsideDays) return ); })} ))}
{fmt({ weekday: "short" }).format(w).slice(0, 2)}
; const s = state(d); const off = isDisabled(d); return ( select(d)} onFocus={() => setFocused(d)} onKeyDown={(e) => onKeyDown(e, d)} />
))}
); } const NAV_BUTTON = "inline-flex size-8 items-center justify-center rounded-pill text-icon-secondary outline-none transition-[background-color,color] duration-150 ease-out hover:bg-subtle active:bg-subtle hover:text-fg active:brightness-95 active:transition-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus-ring motion-reduce:transition-none [&_svg]:size-4"; function CaptionSelect({ label, value, options, onChange }: { label: string; value: number; options: { value: number; label: string }[]; onChange: (value: number) => void }) { return ( ); } type DayButtonProps = Omit, "children"> & { date: Date; label: string; today?: boolean; outside?: boolean; selected?: boolean; middle?: boolean }; /** One day: a 36px key with 8px corners. Exported so an app can render a day its own way inside the same grid. */ function CalendarDayButton({ date, label, today, outside, selected, middle, disabled, className, ...props }: DayButtonProps) { return ( ); } export { Calendar, CalendarDayButton }; export type { CalendarProps, DateRange }; ``` # Card White cards with the field edge, raised white panels, and lilac channel cards, all with 24px corners. Install: npx inkbox-ds add card Requires: theme, utils Packages: class-variance-authority Exports: Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, cardVariants Client boundary: not required by this entry file Reference: Figma 427:2715: Webhooks - Event catalog (Trigger, Delivery, Response contract), Create Agent Identity (Email, Phone Number channel cards) ## Usage Use default for a small fact block in a grid (Trigger, Delivery, Response contract): a glyph-led 14px title and a muted description. Use raised for a white panel that sits on lilac or holds rows. Use muted for a grouped setting inside a dialog (a channel with its toggle in CardAction). Put a button or toggle in CardAction at the top right. - Avoid: Drop shadows on default cards - Avoid: Corners other than rounded-dialog - Avoid: A colored header bar - Avoid: Nesting cards more than one level ## Examples Example components: npx inkbox-ds add card badge button Example packages: @phosphor-icons/react [Preview](https://inkbox-ds.vercel.app/preview/card/Preview) | [Channel](https://inkbox-ds.vercel.app/preview/card/Channel) | [WithFooter](https://inkbox-ds.vercel.app/preview/card/WithFooter) | [Small](https://inkbox-ds.vercel.app/preview/card/Small) examples/card.tsx ```tsx import { BellRingingIcon, CheckCircleIcon, EnvelopeIcon, LightningIcon, PhoneIcon, PlugsIcon } from "@phosphor-icons/react/ssr"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; export function Preview() { return (
Trigger Triggered after an inbound email is accepted and persisted to the mailbox. Delivery Fire-and-forget POST. Delivery failures are logged and do not block mail processing. Response contract Any response body is ignored.
); } function Tile({ children }: { children: React.ReactNode }) { return {children}; } export function Channel() { return (
Email Included

acme-support@inboxmail.com

Phone Number
); } export function WithFooter() { return ( Custom domain Send and receive as you@yourcompany.com. Add the DNS records, then verify.

mail.yourcompany.com

); } export function Small() { return ( Last connected Aug 8, 2026, 07:16 PM ); } ``` ## Source components/ui/card.tsx ```tsx import * as React from "react"; import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "@/lib/utils"; // The console's cards (Figma 427:2715). `default` is the webhook Trigger and Delivery card: white, a 1px #D2C9DB edge, // 24px corners, 14px in; a 14px ink title led by a 16px filled glyph and a 14px muted description. `raised` is the white // panel lifted by Shadow/S (a table's body). `muted` is the dialog's channel card: lilac, 24px corners, no edge, 12px above and // below, a 16px medium title (52px tall with a 28px glyph tile). Size sm tightens the insides to 12px. const cardVariants = cva("group/card flex flex-col gap-3 rounded-dialog py-3.5 font-sans text-body leading-body text-fg data-[size=sm]:gap-2 data-[size=sm]:py-3", { variants: { variant: { default: "border border-line-field bg-surface", raised: "bg-surface shadow-s", muted: "bg-subtle py-3", }, }, defaultVariants: { variant: "default" }, }); function Card({ className, variant, size = "default", ...props }: React.ComponentProps<"div"> & VariantProps & { size?: "default" | "sm" }) { return
; } function CardHeader({ className, ...props }: React.ComponentProps<"div">) { return (
); } function CardTitle({ className, ...props }: React.ComponentProps<"div">) { return (
); } function CardDescription({ className, ...props }: React.ComponentProps<"div">) { return
; } function CardAction({ className, ...props }: React.ComponentProps<"div">) { return
; } function CardContent({ className, ...props }: React.ComponentProps<"div">) { return
; } function CardFooter({ className, ...props }: React.ComponentProps<"div">) { return
; } export { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, cardVariants }; ``` # Carousel A row of slides that snap into place, with white round previous and next keys outside it. Install: npx inkbox-ds add carousel Requires: theme, utils Packages: @phosphor-icons/react Exports: Carousel, CarouselContent, CarouselItem, CarouselNext, CarouselPrevious, useCarousel, CarouselApi Client boundary: yes Reference: Extrapolated from the Inkbox console (Figma 427:2715): white keys with Shadow/S ## Usage Use to page through a set of similar cards when space is short (agent templates, integrations). Give each CarouselItem a basis for how many show at once. Leave room either side for the arrow keys, or place them yourself. - Avoid: Autoplay - Avoid: Dots as the only way to move - Avoid: Slides of different heights in one row ## Examples Example components: npx inkbox-ds add carousel [Preview](https://inkbox-ds.vercel.app/preview/carousel/Preview) | [Sizes](https://inkbox-ds.vercel.app/preview/carousel/Sizes) | [Vertical](https://inkbox-ds.vercel.app/preview/carousel/Vertical) examples/carousel.tsx ```tsx import { Carousel, CarouselContent, CarouselItem, CarouselNext, CarouselPrevious } from "@/components/ui/carousel"; const TEMPLATES = [ { name: "Support desk", text: "Answers inbound email and hands hard cases to a person." }, { name: "Scheduler", text: "Books calls over iMessage and confirms by email." }, { name: "Researcher", text: "Reads replies and files notes to the shared vault." }, { name: "Recruiter", text: "Screens candidates by phone and logs each call." }, { name: "Concierge", text: "Greets new contacts and routes them to the right agent." }, ]; function Slide({ name, text }: { name: string; text: string }) { return (

{name}

{text}

); } export function Preview() { return (
{TEMPLATES.map((t) => ( ))}
); } export function Sizes() { return (
{TEMPLATES.map((t) => ( ))}
); } export function Vertical() { return (
{TEMPLATES.map((t) => ( ))}
); } ``` ## Source components/ui/carousel.tsx ```tsx "use client"; import * as React from "react"; import { ArrowLeftIcon, ArrowRightIcon } from "@phosphor-icons/react/ssr"; import { cn } from "@/lib/utils"; // A row of slides that snap into place (extrapolated from the Inkbox console, Figma 427:2715: its white keys with // Shadow/S and 8px corners). Slides scroll natively (touch, trackpad and wheel) and snap; the previous and next keys // are 32px white circles with Shadow/S and an ink arrow, outside the row. Arrow keys move a slide when the carousel has // focus. The scroll is smooth (the browser's own easing) unless reduced motion is set. type CarouselEvent = "select" | "reInit"; type CarouselApi = { scrollPrev: () => void; scrollNext: () => void; scrollTo: (index: number) => void; canScrollPrev: () => boolean; canScrollNext: () => boolean; selectedScrollSnap: () => number; scrollSnapList: () => number[]; on: (event: CarouselEvent, callback: (api: CarouselApi) => void) => void; off: (event: CarouselEvent, callback: (api: CarouselApi) => void) => void; }; type CarouselOptions = { loop?: boolean; align?: "start" | "center" | "end" }; type CarouselProps = { opts?: CarouselOptions; orientation?: "horizontal" | "vertical"; setApi?: (api: CarouselApi) => void; }; type CarouselContextProps = { viewport: React.RefObject; orientation: "horizontal" | "vertical"; align: "start" | "center" | "end"; scrollPrev: () => void; scrollNext: () => void; canScrollPrev: boolean; canScrollNext: boolean; update: () => void; }; const CarouselContext = React.createContext(null); function useCarousel() { const context = React.useContext(CarouselContext); if (!context) throw new Error("useCarousel must be used within a "); return context; } function Carousel({ orientation = "horizontal", opts, setApi, className, children, ...props }: React.ComponentProps<"div"> & CarouselProps) { const viewport = React.useRef(null); const [edges, setEdges] = React.useState({ prev: false, next: false, index: 0 }); const listeners = React.useRef void>>>({ select: new Set(), reInit: new Set() }); const vertical = orientation === "vertical"; const loop = !!opts?.loop; const slides = React.useCallback(() => Array.from(viewport.current?.querySelectorAll(":scope > [data-slot=carousel-content] > [data-slot=carousel-item]") ?? []), []); const offsetOf = React.useCallback((el: HTMLElement) => (vertical ? el.offsetTop : el.offsetLeft), [vertical]); const current = React.useCallback(() => { const el = viewport.current; if (!el) return 0; const pos = vertical ? el.scrollTop : el.scrollLeft; const list = slides(); let best = 0; list.forEach((s, i) => { if (Math.abs(offsetOf(s) - pos) < Math.abs(offsetOf(list[best]) - pos)) best = i; }); return best; }, [offsetOf, slides, vertical]); const scrollTo = React.useCallback((index: number) => { const el = viewport.current; const list = slides(); if (!el || !list.length) return; const i = loop ? (index + list.length) % list.length : Math.max(0, Math.min(list.length - 1, index)); const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches; el.scrollTo({ [vertical ? "top" : "left"]: offsetOf(list[i]), behavior: reduce ? "auto" : "smooth" }); }, [loop, offsetOf, slides, vertical]); const api = React.useMemo(() => { const self: CarouselApi = { scrollPrev: () => scrollTo(current() - 1), scrollNext: () => scrollTo(current() + 1), scrollTo, canScrollPrev: () => { const el = viewport.current; return !!el && (loop || (vertical ? el.scrollTop : el.scrollLeft) > 1); }, canScrollNext: () => { const el = viewport.current; if (!el) return false; return loop || (vertical ? el.scrollTop + el.clientHeight < el.scrollHeight - 1 : el.scrollLeft + el.clientWidth < el.scrollWidth - 1); }, selectedScrollSnap: current, scrollSnapList: () => slides().map((_, i) => i), on: (event, cb) => void listeners.current[event].add(cb), off: (event, cb) => void listeners.current[event].delete(cb), }; return self; }, [current, loop, scrollTo, slides, vertical]); const update = React.useCallback(() => { const next = { prev: api.canScrollPrev(), next: api.canScrollNext(), index: api.selectedScrollSnap() }; setEdges((e) => { if (e.index !== next.index) listeners.current.select.forEach((cb) => cb(api)); return e.prev === next.prev && e.next === next.next && e.index === next.index ? e : next; }); }, [api]); React.useEffect(() => { setApi?.(api); const el = viewport.current; if (!el) return; update(); const observer = new ResizeObserver(() => { update(); listeners.current.reInit.forEach((cb) => cb(api)); }); observer.observe(el); el.addEventListener("scroll", update, { passive: true }); return () => { observer.disconnect(); el.removeEventListener("scroll", update); }; }, [api, setApi, update]); const onKeyDown = (event: React.KeyboardEvent) => { const back = vertical ? "ArrowUp" : "ArrowLeft"; const forward = vertical ? "ArrowDown" : "ArrowRight"; if (event.key === back) { event.preventDefault(); api.scrollPrev(); } else if (event.key === forward) { event.preventDefault(); api.scrollNext(); } }; return (
{children}
); } function CarouselContent({ className, ...props }: React.ComponentProps<"div">) { const { viewport, orientation } = useCarousel(); const vertical = orientation === "vertical"; return (
); } function CarouselItem({ className, ...props }: React.ComponentProps<"div">) { const { orientation, align } = useCarousel(); return (
); } const ARROW = "absolute inline-flex size-8 items-center justify-center rounded-pill bg-surface text-fg shadow-s outline-none transition-[background-color,opacity] duration-150 ease-out hover:bg-subtle active:bg-subtle active:brightness-95 active:transition-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus-ring disabled:pointer-events-none disabled:opacity-40 motion-reduce:transition-none [&_svg]:size-4"; function CarouselPrevious({ className, ...props }: React.ComponentProps<"button">) { const { orientation, scrollPrev, canScrollPrev } = useCarousel(); return ( ); } function CarouselNext({ className, ...props }: React.ComponentProps<"button">) { const { orientation, scrollNext, canScrollNext } = useCarousel(); return ( ); } export { Carousel, CarouselContent, CarouselItem, CarouselNext, CarouselPrevious, useCarousel }; export type { CarouselApi }; ``` # Chart Line, area, bar and pie charts in SVG with lilac rules, 12px muted labels and a white floating tooltip. Install: npx inkbox-ds add chart Requires: theme, utils Packages: none Exports: ChartConfig, AreaChart, BarChart, ChartContainer, ChartLegend, ChartLegendContent, ChartStyle, ChartTooltip, ChartTooltipContent, LineChart, PieChart, useChart Client boundary: yes Reference: Extrapolated from the Inkbox console (Figma 427:2715): muted labels, lilac rules, floating panels and the status colors ## Usage Use to show volume over time (messages sent, calls, webhook deliveries) or a share (deliveries by status). Name and color each series in a ChartConfig, wrap the chart in ChartContainer, and add ChartTooltip and ChartLegend. Keep to five series; the colors follow the series order. - Avoid: Gradients other than the area fade - Avoid: 3D or shadowed marks - Avoid: More than five series - Avoid: Colors outside the series tokens ## Examples Example components: npx inkbox-ds add chart [Preview](https://inkbox-ds.vercel.app/preview/chart/Preview) | [Line](https://inkbox-ds.vercel.app/preview/chart/Line) | [Area](https://inkbox-ds.vercel.app/preview/chart/Area) | [Pie](https://inkbox-ds.vercel.app/preview/chart/Pie) examples/chart.tsx ```tsx "use client"; import { AreaChart, BarChart, type ChartConfig, ChartContainer, ChartLegend, ChartLegendContent, ChartTooltip, ChartTooltipContent, LineChart, PieChart } from "@/components/ui/chart"; const MESSAGES = [ { month: "January", email: 186, imessage: 80 }, { month: "February", email: 305, imessage: 200 }, { month: "March", email: 237, imessage: 120 }, { month: "April", email: 173, imessage: 190 }, { month: "May", email: 209, imessage: 130 }, { month: "June", email: 264, imessage: 140 }, ]; const config = { email: { label: "Email" }, imessage: { label: "iMessage" }, } satisfies ChartConfig; const short = (v: string) => v.slice(0, 3); export function Preview() { return ( } /> } /> ); } export function Line() { return ( } /> } /> ); } export function Area() { return ( } /> ); } const DELIVERIES = [ { status: "delivered", count: 1240 }, { status: "retrying", count: 86 }, { status: "failed", count: 32 }, ]; const deliveryConfig = { delivered: { label: "Delivered", color: "var(--ds-color-chart-series-1)" }, retrying: { label: "Retrying", color: "var(--ds-color-chart-series-2)" }, failed: { label: "Failed", color: "var(--ds-color-chart-series-3)" }, } satisfies ChartConfig; export function Pie() { return ( } /> } /> ); } ``` ## Source components/ui/chart.tsx ```tsx "use client"; import * as React from "react"; import { cn } from "@/lib/utils"; // Charts drawn in SVG (extrapolated from the Inkbox console, Figma 427:2715: its 12px muted labels, lilac rules, white // floating panels and the purple, pink, blue, teal and green of its states). ChartContainer takes a config that names // and colors each series (series-1 purple, then pink, avatar blue, info teal, success green) and holds the chart, its // tooltip and its legend. Lines draw in from the left and bars rise from the axis once, on mount (700ms and 400ms, the // strong ease-out); with reduced motion they fade in. Hovering or moving with the arrow keys shows the tooltip. export type ChartConfig = Record; color?: string }>; type Datum = Record; type Active = { index: number; x: number; y: number } | null; type ChartContextProps = { config: ChartConfig; data: Datum[]; setData: (data: Datum[], index: string, keys: string[]) => void; index: string; keys: string[]; active: Active; setActive: (active: Active) => void; hidden: Set; toggle: (key: string) => void; }; const ChartContext = React.createContext(null); function useChart() { const context = React.useContext(ChartContext); if (!context) throw new Error("useChart must be used within a "); return context; } const SERIES = ["var(--ds-color-chart-series-1)", "var(--ds-color-chart-series-2)", "var(--ds-color-chart-series-3)", "var(--ds-color-chart-series-4)", "var(--ds-color-chart-series-5)"]; const colorOf = (config: ChartConfig, key: string, i: number) => `var(--color-${key}, ${config[key]?.color ?? SERIES[i % SERIES.length]})`; /** Kept for API parity: ChartContainer already sets each series' color as --color- on itself. */ function ChartStyle(_props: { id?: string; config: ChartConfig }) { return null; } function ChartContainer({ id, className, children, config, ...props }: React.ComponentProps<"div"> & { config: ChartConfig }) { const [data, setDataState] = React.useState([]); const [index, setIndex] = React.useState(""); const [keys, setKeys] = React.useState([]); const [active, setActive] = React.useState(null); const [hidden, setHidden] = React.useState>(new Set()); const setData = React.useCallback((d: Datum[], i: string, k: string[]) => { setDataState(d); setIndex(i); setKeys(k); }, []); const toggle = React.useCallback((key: string) => setHidden((h) => { const next = new Set(h); if (next.has(key)) next.delete(key); else next.add(key); return next; }), []); const style = Object.fromEntries(Object.entries(config).map(([key, v], i) => [`--color-${key}`, v.color ?? SERIES[i % SERIES.length]])) as React.CSSProperties; return (
{children}
); } function useSize() { const ref = React.useRef(null); const [size, setSize] = React.useState({ width: 0, height: 0 }); React.useLayoutEffect(() => { const el = ref.current; if (!el) return; const observer = new ResizeObserver(([entry]) => setSize({ width: entry.contentRect.width, height: entry.contentRect.height })); observer.observe(el); return () => observer.disconnect(); }, []); return [ref, size] as const; } const PAD = { top: 12, right: 12, bottom: 28, left: 12 }; function niceMax(max: number) { if (max <= 0) return 1; const step = 10 ** Math.floor(Math.log10(max)); return Math.ceil(max / step) * step; } /** A smooth path through the points (monotone in x), or straight segments. */ function pathOf(points: [number, number][], curve: "monotone" | "linear") { if (!points.length) return ""; if (curve === "linear" || points.length < 3) return points.map(([x, y], i) => `${i ? "L" : "M"}${x},${y}`).join(""); let d = `M${points[0][0]},${points[0][1]}`; for (let i = 0; i < points.length - 1; i++) { const [x0, y0] = points[i - 1] ?? points[i]; const [x1, y1] = points[i]; const [x2, y2] = points[i + 1]; const [x3, y3] = points[i + 2] ?? points[i + 1]; const c1 = [x1 + (x2 - x0) / 6, y1 + (y2 - y0) / 6]; const c2 = [x2 - (x3 - x1) / 6, y2 - (y3 - y1) / 6]; d += `C${c1[0]},${c1[1]} ${c2[0]},${c2[1]} ${x2},${y2}`; } return d; } type CartesianProps = { data: Datum[]; /** The key of each datum's label on the x axis (month, day). */ index: string; /** The series to draw, in order; defaults to every key in the config. */ keys?: string[]; showGrid?: boolean; showXAxis?: boolean; showYAxis?: boolean; /** Format the x labels (e.g. the first three letters of a month). */ tickFormatter?: (value: string) => string; valueFormatter?: (value: number) => string; className?: string; "aria-label"?: string; }; function useCartesian(kind: "line" | "area" | "bar", { data, index, keys: keysProp, showYAxis }: CartesianProps, stacked: boolean) { const chart = useChart(); const keys = React.useMemo(() => keysProp ?? Object.keys(chart.config), [keysProp, chart.config]); const { setData } = chart; React.useEffect(() => setData(data, index, keys), [data, index, keys, setData]); const [ref, size] = useSize(); const visible = keys.filter((k) => !chart.hidden.has(k)); const values = data.map((d) => (stacked ? visible.reduce((s, k) => s + Number(d[k] ?? 0), 0) : Math.max(0, ...visible.map((k) => Number(d[k] ?? 0))))); const max = niceMax(Math.max(0, ...values)); const left = PAD.left + (showYAxis ? 32 : 0); const innerW = Math.max(0, size.width - left - PAD.right); // Lines and areas keep their first and last points (and labels) clear of the edges. const inset = kind === "bar" ? 0 : 16; const innerH = Math.max(0, size.height - PAD.top - PAD.bottom); const band = data.length ? innerW / data.length : 0; const xAt = (i: number) => (kind === "bar" ? left + band * i + band / 2 : left + inset + (data.length > 1 ? ((innerW - inset * 2) * i) / (data.length - 1) : innerW / 2 - inset)); const yAt = (v: number) => PAD.top + innerH - (innerH * v) / max; return { chart, keys, visible, ref, size, max, left, innerW, innerH, band, xAt, yAt }; } function Frame({ kind, props, geometry, children }: { kind: "line" | "area" | "bar"; props: CartesianProps; geometry: ReturnType; children: React.ReactNode }) { const { chart, ref, size, max, left, innerW, innerH, band, xAt, yAt } = geometry; const { data, index, showGrid = true, showXAxis = true, showYAxis = false, tickFormatter = (v: string) => v, valueFormatter = (v: number) => v.toLocaleString() } = props; const ticks = [0, 0.25, 0.5, 0.75, 1].map((t) => max * t); const pick = (clientX: number, rect: DOMRect) => { const x = clientX - rect.left; let best = 0; for (let i = 0; i < data.length; i++) if (Math.abs(xAt(i) - x) < Math.abs(xAt(best) - x)) best = i; return best; }; const activate = (i: number) => chart.setActive({ index: i, x: xAt(i), y: PAD.top }); const labelStep = Math.max(1, Math.ceil(data.length / Math.max(1, Math.floor(innerW / 56)))); return (
{size.width > 0 && ( activate(pick(e.clientX, e.currentTarget.getBoundingClientRect()))} onPointerLeave={() => chart.setActive(null)} onBlur={() => chart.setActive(null)} onFocus={() => activate(chart.active?.index ?? 0)} onKeyDown={(e) => { if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return; e.preventDefault(); const current = chart.active?.index ?? 0; activate(Math.min(data.length - 1, Math.max(0, current + (e.key === "ArrowRight" ? 1 : -1)))); }} > {showGrid && ticks.map((t) => )} {showYAxis && ticks.map((t) => ( {valueFormatter(t)} ))} {chart.active && kind !== "bar" && } {chart.active && kind === "bar" && } {children} {showXAxis && data.map((d, i) => (i % labelStep === 0 ? ( {tickFormatter(String(d[index]))} ) : null))} )}
); } function LineChart(props: CartesianProps & { curve?: "monotone" | "linear"; dots?: boolean }) { const g = useCartesian("line", props, false); const { curve = "monotone", dots = false } = props; return ( {g.visible.map((key) => { const i = g.keys.indexOf(key); const points = props.data.map((d, n) => [g.xAt(n), g.yAt(Number(d[key] ?? 0))] as [number, number]); return ( {(dots || g.chart.active) && points.map(([x, y], n) => (dots || g.chart.active?.index === n ? : null))} ); })} ); } function AreaChart(props: CartesianProps & { curve?: "monotone" | "linear"; stacked?: boolean }) { const { curve = "monotone", stacked = false } = props; const g = useCartesian("area", props, stacked); const base = props.data.map(() => 0); const id = React.useId().replace(/:/g, ""); return ( {g.visible.map((key) => ( ))} {g.visible.map((key) => { const i = g.keys.indexOf(key); const lows = stacked ? [...base] : props.data.map(() => 0); const highs = props.data.map((d, n) => lows[n] + Number(d[key] ?? 0)); if (stacked) highs.forEach((h, n) => (base[n] = h)); const top = highs.map((v, n) => [g.xAt(n), g.yAt(v)] as [number, number]); const bottom = lows.map((v, n) => [g.xAt(n), g.yAt(v)] as [number, number]).reverse(); const line = pathOf(top, curve); const area = `${line}L${bottom.map(([x, y]) => `${x},${y}`).join("L")}Z`; return ( ); })} ); } function BarChart(props: CartesianProps & { stacked?: boolean; radius?: number }) { const { stacked = false, radius = 4 } = props; const g = useCartesian("bar", props, stacked); const gap = 4; const groupW = Math.max(0, g.band * 0.72); const barW = stacked ? groupW : Math.max(2, (groupW - gap * (g.visible.length - 1)) / Math.max(1, g.visible.length)); return ( {props.data.map((d, n) => { let acc = 0; return ( {g.visible.map((key, k) => { const value = Number(d[key] ?? 0); const x = stacked ? g.xAt(n) - groupW / 2 : g.xAt(n) - groupW / 2 + k * (barW + gap); const y0 = g.yAt(acc); const y1 = g.yAt(acc + value); if (stacked) acc += value; const top = !stacked || k === g.visible.length - 1; return ( ); })} ); })} ); } function PieChart({ data, index, value, innerRadius = 0.6, className, ...props }: { data: Datum[]; index: string; value: string; innerRadius?: number; className?: string; "aria-label"?: string }) { const chart = useChart(); const { setData } = chart; React.useEffect(() => setData(data, index, [value]), [data, index, value, setData]); const [ref, size] = useSize(); const shown = data.filter((d) => !chart.hidden.has(String(d[index]))); const total = shown.reduce((s, d) => s + Number(d[value] ?? 0), 0) || 1; const r = Math.max(0, Math.min(size.width, size.height) / 2 - 8); const cx = size.width / 2; const cy = size.height / 2; let angle = -Math.PI / 2; return (
{r > 0 && ( chart.setActive(null)}> {shown.map((d) => { const key = String(d[index]); const n = data.indexOf(d); const sweep = (Number(d[value] ?? 0) / total) * Math.PI * 2; const a0 = angle; const a1 = angle + sweep; angle = a1; const ri = r * innerRadius; const large = sweep > Math.PI ? 1 : 0; const p = (a: number, rr: number) => `${cx + rr * Math.cos(a)},${cy + rr * Math.sin(a)}`; const dPath = sweep >= Math.PI * 2 - 1e-6 ? `M${cx - r},${cy}a${r},${r} 0 1,0 ${r * 2},0a${r},${r} 0 1,0 ${-r * 2},0${ri ? `M${cx - ri},${cy}a${ri},${ri} 0 1,1 ${ri * 2},0a${ri},${ri} 0 1,1 ${-ri * 2},0` : ""}` : `M${p(a0, r)}A${r},${r} 0 ${large} 1 ${p(a1, r)}${ri ? `L${p(a1, ri)}A${ri},${ri} 0 ${large} 0 ${p(a0, ri)}` : `L${cx},${cy}`}Z`; return ( { const rect = e.currentTarget.ownerSVGElement!.getBoundingClientRect(); chart.setActive({ index: n, x: e.clientX - rect.left, y: e.clientY - rect.top }); }} /> ); })} )}
); } type TooltipItem = { key: string; name: React.ReactNode; value: number; color: string }; /** Where the tooltip shows: pass `content` (usually ). It follows the hovered point. */ function ChartTooltip({ content, cursor: _cursor }: { content?: React.ReactElement; cursor?: boolean }) { const chart = useChart(); if (!chart.active || !chart.data.length) return null; const datum = chart.data[chart.active.index]; if (!datum) return null; const pie = chart.keys.length === 1 && !(chart.keys[0] in chart.config) && String(datum[chart.index]) in chart.config; const payload: TooltipItem[] = pie ? [{ key: String(datum[chart.index]), name: chart.config[String(datum[chart.index])]?.label ?? String(datum[chart.index]), value: Number(datum[chart.keys[0]] ?? 0), color: colorOf(chart.config, String(datum[chart.index]), chart.active.index) }] : chart.keys.filter((k) => !chart.hidden.has(k)).map((k, i) => ({ key: k, name: chart.config[k]?.label ?? k, value: Number(datum[k] ?? 0), color: colorOf(chart.config, k, chart.keys.indexOf(k) ?? i) })); const element = content ?? ; return (
160 ? "calc(-100% - 12px)" : "12px"}, 0)` }} > {React.cloneElement(element, { payload, label: pie ? undefined : String(datum[chart.index]) })}
); } type ChartTooltipContentProps = React.ComponentProps<"div"> & { payload?: TooltipItem[]; label?: string; indicator?: "dot" | "line" | "dashed"; hideLabel?: boolean; hideIndicator?: boolean; labelFormatter?: (label: string) => React.ReactNode; formatter?: (value: number, name: React.ReactNode, key: string) => React.ReactNode; }; /** The tooltip's panel: white, 16px corners, the floating shadow, the label in 13px medium ink, a row per series. */ function ChartTooltipContent({ payload = [], label, indicator = "dot", hideLabel = false, hideIndicator = false, labelFormatter, formatter, className, ...props }: ChartTooltipContentProps) { return (
{!hideLabel && label !== undefined &&

{labelFormatter ? labelFormatter(label) : label}

}
{payload.map((item) => (
{!hideIndicator && ( )} {formatter ? formatter(item.value, item.name, item.key) : ( <> {item.name} {item.value.toLocaleString()} )}
))}
); } /** Where the legend shows: pass `content` (usually ). */ function ChartLegend({ content, verticalAlign = "bottom" }: { content?: React.ReactElement>; verticalAlign?: "top" | "bottom" }) { const element = content ?? ; return
{element}
; } /** A row of 12px muted labels with their color keys; each toggles its series when `interactive`. */ function ChartLegendContent({ className, hideIcon = false, interactive = true, ...props }: React.ComponentProps<"div"> & { hideIcon?: boolean; interactive?: boolean }) { const chart = useChart(); const keys = chart.keys.length === 1 && !(chart.keys[0] in chart.config) ? chart.data.map((d) => String(d[chart.index])) : chart.keys; return (
{keys.map((key, i) => { const item = chart.config[key]; const Icon = item?.icon; const off = chart.hidden.has(key); const body = ( <> {Icon && !hideIcon ? : } {item?.label ?? key} ); return interactive ? ( ) : ( {body} ); })}
); } export { AreaChart, BarChart, ChartContainer, ChartLegend, ChartLegendContent, ChartStyle, ChartTooltip, ChartTooltipContent, LineChart, PieChart, useChart }; ``` # Checkbox An 18px box with 4px corners and a gray edge that fills purple with a white check. Install: npx inkbox-ds add checkbox Requires: theme, utils Packages: @chakra-ui/react, @phosphor-icons/react Exports: Checkbox Client boundary: yes Reference: Extrapolated from the Inkbox console (Figma 427:2715): the radios' 18px size and gray edge, the action purple and the white checks ## Usage Use for independent on or off choices that apply when a form is submitted (event subscriptions, consent). Pair each checkbox with a Label 8px to its right. Use the indeterminate state for a parent that covers some of its children. For a setting that applies at once use Switch. - Avoid: A pink checked box - Avoid: A round checkbox - Avoid: A checkbox that acts immediately like a switch ## Examples Example components: npx inkbox-ds add checkbox label [Preview](https://inkbox-ds.vercel.app/preview/checkbox/Preview) | [States](https://inkbox-ds.vercel.app/preview/checkbox/States) examples/checkbox.tsx ```tsx "use client"; import * as React from "react"; import { Checkbox } from "@/components/ui/checkbox"; import { Label } from "@/components/ui/label"; const EVENTS = ["message.received", "message.sent", "message.delivered"]; export function Preview() { const [chosen, setChosen] = React.useState(["message.received"]); const all = chosen.length === EVENTS.length ? true : chosen.length ? "indeterminate" : false; return (
setChosen(v === true ? EVENTS : [])} />
{EVENTS.map((e) => (
setChosen((c) => (v === true ? [...c, e] : c.filter((x) => x !== e)))} />
))}
); } export function States() { return (
); } ``` ## Source components/ui/checkbox.tsx ```tsx "use client"; import * as React from "react"; import { Checkbox as ChakraCheckbox } from "@chakra-ui/react"; import { CheckIcon, MinusIcon } from "@phosphor-icons/react/ssr"; import { cn } from "@/lib/utils"; // Extrapolated from the console's radios and checks (Figma 427:2715): an 18px box with 4px corners and the radio's // 1.5px gray edge on white; checked or mixed it fills with the action purple and shows a bold white check (or dash). // Its label sits 8px away in 14px ink. type CheckboxProps = Omit, "onCheckedChange"> & { onCheckedChange?: (checked: boolean | "indeterminate") => void }; function Checkbox({ className, onCheckedChange, ...props }: CheckboxProps) { return ( onCheckedChange?.(details.checked)} {...props} > ); } export { Checkbox }; ``` # Code Block The event catalog's code viewer: a gray head with the language and a copy glyph over an off-white body with line numbers. Install: npx inkbox-ds add code-block Requires: theme, utils Packages: @phosphor-icons/react Exports: CodeBlock, CodeBlockActions, CodeBlockContent, CodeBlockCopyButton, CodeBlockHeader, CodeBlockTitle, tokenize Client boundary: yes Reference: Figma 427:2715: Webhooks - Event catalog (Signature headers, Payload example) ## Usage Use to show a payload, headers or a snippet someone will read or copy (Signature headers, Payload example). Give it a CodeBlockHeader with the language and a CodeBlockCopyButton, then CodeBlockContent with the code. Pass wrap for long single values such as signatures. - Avoid: A dark code block - Avoid: Code in Inter - Avoid: Highlight colors outside the editor theme ## Examples Example components: npx inkbox-ds add code-block Example packages: @inkbox/sdk [Preview](https://inkbox-ds.vercel.app/preview/code-block/Preview) | [Script](https://inkbox-ds.vercel.app/preview/code-block/Script) examples/code-block.tsx ```tsx import { CodeBlock, CodeBlockActions, CodeBlockContent, CodeBlockCopyButton, CodeBlockHeader, CodeBlockTitle } from "@/components/ui/code-block"; const HEADERS = `{ "X-Inkbox-Request-ID": "b4f64d39-7cf0-4c89-b2fa-3522091e979d", "X-Inkbox-Timestamp": "1764875501", "X-Inkbox-Signature": "sha256=2f8d6b7f37b7d6461f7b8a91f4f495eb54f60af8f4a1f9d3edfdc7d2adf237f2" }`; const SNIPPET = `import { Inkbox } from "@inkbox/sdk"; // Send the agent's first email. const inkbox = new Inkbox({ apiKey: process.env.INKBOX_API_KEY }); await inkbox.mail.send({ to: "you@example.com", subject: "Hello" });`; export function Preview() { return ( JSON ); } export function Script() { return ( send.ts ); } ``` ## Source components/ui/code-block.tsx ```tsx "use client"; import * as React from "react"; import { BracketsCurlyIcon, CheckIcon, CopyIcon } from "@phosphor-icons/react/ssr"; import { cn } from "@/lib/utils"; // The event catalog's code block (Figma 427:2715, Webhooks: Signature headers, Payload example): 8px corners and the // editor's gray edge, a 40px gray head with the language (yellow braces for JSON) and a purple copy glyph, then the // off-white body in the system monospace at 14px on 20px lines, line numbers in the editor's teal (the first line's // darker), and the Light+ colors: red keys, blue values and braces. Long lines scroll, or wrap with `wrap`. function CodeBlock({ className, ...props }: React.ComponentProps<"figure">) { return
; } function CodeBlockHeader({ className, ...props }: React.ComponentProps<"div">) { return
; } function CodeBlockTitle({ className, children, language, ...props }: React.ComponentProps<"figcaption"> & { language?: string }) { return (
{language === "json" && } {children}
); } function CodeBlockActions({ className, ...props }: React.ComponentProps<"div">) { return
; } function CodeBlockCopyButton({ value, className, ...props }: Omit, "value" | "children"> & { value: string }) { const [state, setState] = React.useState<"idle" | "copied" | "failed">("idle"); React.useEffect(() => { if (state === "idle") return; // ds-latency: resets completed clipboard feedback without delaying the copy action. const timeout = setTimeout(() => setState("idle"), 2000); return () => clearTimeout(timeout); }, [state]); return ( <> {state === "copied" ? "Copied" : state === "failed" ? "Copy failed. Select the code and copy it." : ""} ); } type Kind = "key" | "string" | "punct" | "keyword" | "comment" | "number" | "function" | "type" | "tag" | "attr" | "plain"; const COLOR: Record = { key: "text-code-key", string: "text-code-string", punct: "text-code-punct", keyword: "text-code-keyword", comment: "text-code-comment", number: "text-code-number", function: "text-code-function", type: "text-code-type", tag: "text-code-tag", attr: "text-code-attr", plain: undefined, }; const KEYWORDS = /^(?:import|from|export|default|function|return|const|let|var|if|else|for|of|in|new|type|interface|extends|as|async|await|true|false|null|undefined|class|this|throw|try|catch)$/; /** Splits code into colored runs for the Light+ theme. JSON keys are red and values blue; in scripts strings are red. */ function tokenize(code: string, language: string): { kind: Kind; text: string }[] { const out: { kind: Kind; text: string }[] = []; const push = (kind: Kind, text: string) => (text ? out.push({ kind, text }) : undefined); if (language === "json") { for (const m of code.matchAll(/("(?:\\.|[^"\\])*")(\s*:)?|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)|(true|false|null)|([{}[\]])|([^"{}[\]\d\-tfn]+|[\s\S])/g)) { if (m[1]) { push(m[2] ? "key" : "string", m[1]); push("plain", m[2] ?? ""); } else if (m[3]) push("number", m[3]); else if (m[4]) push("keyword", m[4]); else if (m[5]) push("punct", m[5]); else push("plain", m[0]); } return out; } if (language === "bash" || language === "sh" || language === "shell") { for (const line of code.split(/(\n)/)) { if (line === "\n") push("plain", line); else if (/^\s*#/.test(line)) push("comment", line); else for (const [i, m] of [...line.matchAll(/("[^"]*"|'[^']*')|(\s--?[\w-]+)|(\S+|\s+)/g)].entries()) { if (m[1]) push("string", m[1]); else if (m[2]) push("keyword", m[2]); else push(i === 0 || (i === 1 && /^\s/.test(line)) ? "function" : "plain", m[0]); } } return out; } const pattern = /(\/\/[^\n]*|\/\*[\s\S]*?\*\/)|("(?:\\.|[^"\\\n])*"|'(?:\\.|[^'\\\n])*'|`(?:\\.|[^`\\])*`)|(<\/?)([A-Za-z][\w.]*)|([A-Za-z_$][\w-]*)(=)|\b(\d+(?:\.\d+)?)\b|([A-Za-z_$][\w$]*)(?=\s*\()|([A-Z][\w$]*)|([A-Za-z_$][\w$]*)|([\s\S])/g; for (const m of code.matchAll(pattern)) { if (m[1]) push("comment", m[1]); else if (m[2]) push("string", m[2]); else if (m[3]) { push("punct", m[3]); push("tag", m[4]); } else if (m[5]) { push("attr", m[5]); push("plain", m[6]); } else if (m[7]) push("number", m[7]); else if (m[8]) push(KEYWORDS.test(m[8]) ? "keyword" : "function", m[8]); else if (m[9]) push("type", m[9]); else if (m[10]) push(KEYWORDS.test(m[10]) ? "keyword" : "plain", m[10]); else push("plain", m[11]); } return out.map((t) => (t.kind === "string" ? { ...t, kind: "key" as Kind } : t)); } /** Draws the editor's indent guides: each two spaces of leading indentation becomes a cell with a hairline in it. */ function withGuides(line: { kind: Kind; text: string }[]): { kind: Kind | "guide"; text: string }[] { const first = line[0]; const indent = first?.kind === "plain" ? (first.text.match(/^ +/)?.[0].length ?? 0) : 0; const levels = Math.floor(indent / 2); if (!levels) return line; const rest = first.text.slice(levels * 2); return [...Array.from({ length: levels }, () => ({ kind: "guide" as const, text: " " })), ...(rest ? [{ kind: "plain" as Kind, text: rest }] : []), ...line.slice(1)]; } /** * The code itself. Pass the text as `code` and its `language` (json, tsx, ts, css, bash); line numbers show by default. * `wrap` breaks long lines instead of scrolling; `maxHeight` scrolls a long file inside the block. */ function CodeBlockContent({ code, language = "tsx", lineNumbers = true, wrap = false, className, ...props }: Omit, "children"> & { code: string; language?: string; lineNumbers?: boolean; wrap?: boolean }) { const lines = React.useMemo(() => { const rows: { kind: Kind; text: string }[][] = [[]]; for (const token of tokenize(code.replace(/\n$/, ""), language)) { const parts = token.text.split("\n"); parts.forEach((part, i) => { if (i > 0) rows.push([]); if (part) rows[rows.length - 1].push({ kind: token.kind, text: part }); }); } return rows; }, [code, language]); return (
      
        {lines.map((line, i) => (
          
            {lineNumbers && (
              
                {i + 1}
              
            )}
            
              {line.length ? withGuides(line).map((t, j) =>
                t.kind === "guide" ? (
                  
                    
                    {"  "}
                  
                ) : (
                  
                    {t.text}
                  
                ),
              ) : "\u200b"}
            
          
        ))}
      
    
); } export { CodeBlock, CodeBlockActions, CodeBlockContent, CodeBlockCopyButton, CodeBlockHeader, CodeBlockTitle, tokenize }; ``` # Collapsible A part that shows and hides in place, opening with its height. Install: npx inkbox-ds add collapsible Requires: theme, utils Packages: @chakra-ui/react Exports: Collapsible, CollapsibleContent, CollapsibleTrigger Client boundary: yes Reference: Figma 427:2715: Create Agent Identity (Advanced settings - tunnel mode, phone state, line sharing) ## Usage Use for one optional group (Advanced settings under the channels). Make the trigger the whole row: a 14px ink label with a caret at the right that turns when open. For several sections, use Accordion. - Avoid: Hiding required fields in it - Avoid: A trigger without a caret - Avoid: Animating anything but the content height and the caret ## Examples Example components: npx inkbox-ds add collapsible label Example packages: @phosphor-icons/react [Preview](https://inkbox-ds.vercel.app/preview/collapsible/Preview) examples/collapsible.tsx ```tsx "use client"; import * as React from "react"; import { CaretDownIcon } from "@phosphor-icons/react/ssr"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { Label } from "@/components/ui/label"; export function Preview() { const [open, setOpen] = React.useState(false); return ( Advanced settings - tunnel mode, phone state, line sharing

Tunnel TLS mode

); } ``` ## Source components/ui/collapsible.tsx ```tsx "use client"; import * as React from "react"; import { Collapsible as ChakraCollapsible } from "@chakra-ui/react"; import { cn } from "@/lib/utils"; // A part that shows and hides in place (Figma 427:2715: "Advanced settings - tunnel mode, phone state, line sharing" // under the channels). The parts carry no look of their own beyond the motion: the content opens with its height // (200ms, the strong ease-out; closing is quicker). Style the trigger as the row it is (see the example). type CollapsibleProps = Omit, "onOpenChange"> & { onOpenChange?: (open: boolean) => void }; function Collapsible({ onOpenChange, ...props }: CollapsibleProps) { return onOpenChange?.(details.open)} {...props} />; } function CollapsibleTrigger(props: React.ComponentProps) { return ; } function CollapsibleContent({ className, ...props }: React.ComponentProps) { return ; } export { Collapsible, CollapsibleContent, CollapsibleTrigger }; ``` # Combobox A pill trigger for one value, or a field with 24px corners for multiple lilac pill chips, opening a searchable menu with 16px corners. Install: npx inkbox-ds add combobox Requires: theme, surface, utils Packages: radix-ui, cmdk, @phosphor-icons/react Exports: Combobox, ComboboxChip, ComboboxChips, ComboboxChipsInput, ComboboxClear, ComboboxCollection, ComboboxContent, ComboboxEmpty, ComboboxGroup, ComboboxInput, ComboboxItem, ComboboxLabel, ComboboxList, ComboboxSeparator, ComboboxTrigger, ComboboxValue, useCombobox, useComboboxAnchor Client boundary: yes Reference: Extrapolated from the Inkbox console (Figma 427:2715): the select trigger (Any State), the command menu and the lilac handle chip ## Usage Use instead of Select when there are more than about ten choices or people know what they are looking for (an agent, a contact, webhook events). Wrap ComboboxTrigger (or ComboboxChips with ComboboxChip and ComboboxChipsInput for several values) and ComboboxContent with ComboboxInput, ComboboxList, ComboboxItem rows and ComboboxEmpty in a Combobox with value and onValueChange (multiple for several). Pass items so the closed field can show labels. To let people add a new value, render an extra item for the search text from useCombobox(). - Avoid: A combobox for five or fewer choices (use Select or a radio group) - Avoid: Animating the highlight between rows - Avoid: A list without an empty state - Avoid: Chips in the purple ## Examples Example components: npx inkbox-ds add combobox label Example packages: @phosphor-icons/react [Preview](https://inkbox-ds.vercel.app/preview/combobox/Preview) | [Multiple](https://inkbox-ds.vercel.app/preview/combobox/Multiple) | [Creatable](https://inkbox-ds.vercel.app/preview/combobox/Creatable) | [Groups](https://inkbox-ds.vercel.app/preview/combobox/Groups) | [States](https://inkbox-ds.vercel.app/preview/combobox/States) examples/combobox.tsx ```tsx "use client"; import * as React from "react"; import { PlusIcon, RobotIcon } from "@phosphor-icons/react/ssr"; import { Combobox, ComboboxChip, ComboboxChips, ComboboxChipsInput, ComboboxClear, ComboboxContent, ComboboxEmpty, ComboboxGroup, ComboboxInput, ComboboxItem, ComboboxList, ComboboxSeparator, ComboboxTrigger, ComboboxValue, useCombobox, } from "@/components/ui/combobox"; import { Label } from "@/components/ui/label"; const AGENTS = [ { value: "acme", label: "@acme" }, { value: "acme-support", label: "@acme-support" }, { value: "support-desk", label: "@support-desk" }, { value: "ops-runner", label: "@ops-runner" }, { value: "test-agent-233", label: "@test-agent-233" }, { value: "billing-bot", label: "@billing-bot", disabled: true }, ]; const EVENTS = ["message.received", "message.sent", "message.delivered", "message.bounced", "call.started", "call.ended", "imessage.received", "tunnel.connected", "tunnel.disconnected"]; export function Preview() { const [agent, setAgent] = React.useState("acme"); return (
No agent with that handle. {AGENTS.map((a) => ( {a.label} ))}
); } export function Multiple() { const [events, setEvents] = React.useState(["message.received", "message.delivered"]); return (
{events.map((e) => ( ))} {(item) => ( {item.label} )} No such event.
); } // The "Create" row reads the search from the combobox and offers it as a new value; choosing it calls onValueChange. function CreateRow({ known }: { known: string[] }) { const { search } = useCombobox(); const value = search.trim().toLowerCase(); if (!value || known.includes(value)) return null; return ( <> Create “{value}” ); } export function Creatable() { const [tags, setTags] = React.useState(["vip", "customer", "vendor"]); const [tag, setTag] = React.useState(null); return (
{ if (v && !tags.includes(v)) setTags((t) => [...t, v]); setTag(v); }}> {tags.map((t) => ( {t} ))}
); } export function Groups() { const [line, setLine] = React.useState(null); return (
+1 650 474 9720 +1 415 555 0134 Shared iMessage line
); } export function States() { return (
{(a) => {a.label}}

Choose the agent that owns this mailbox.

); } ``` ## Source components/ui/combobox.tsx ```tsx "use client"; import * as React from "react"; import { Popover as PopoverPrimitive } from "radix-ui"; import { Command as CommandPrimitive } from "cmdk"; import { CaretDownIcon, CheckIcon, MagnifyingGlassIcon, XIcon } from "@phosphor-icons/react/ssr"; import { usePortalContainer } from "@/lib/surface"; import { cn } from "@/lib/utils"; // A searchable choice (extrapolated from the Inkbox console, Figma 427:2715: the select trigger "Any State" and the // command menu). The trigger is a white 40px pill with the Shadow/S ring, the value in 14px ink // (the placeholder in the light purple) and the bold caret at the right. It opens the menu panel (white, 16px corners, // the floating shadow) from the trigger: a 44px search row with a magnifying glass, then 36px rows of 14px ink with the // highlighted row on the lilac and a purple check beside the chosen ones; groups are headed in 13px purple. For several // values, ComboboxChips is a field with 24px corners: lilac pill chips with an x, and an input that filters as you // type (Backspace on an empty input removes the last chip). Moving through the rows is instant: it is done by keyboard. type Item = { value: string; label: string; disabled?: boolean }; type ComboboxContextValue = { multiple: boolean; values: string[]; toggle: (value: string) => void; remove: (value: string) => void; clear: () => void; open: boolean; setOpen: (open: boolean) => void; search: string; setSearch: (search: string) => void; labelOf: (value: string) => string; register: (value: string, label: string) => void; items?: Item[]; disabled?: boolean; listRef: React.RefObject; chipsRef: React.RefObject; chips: boolean; setChips: (chips: boolean) => void; contentId: string; }; const ComboboxContext = React.createContext(null); function useCombobox() { const context = React.useContext(ComboboxContext); if (!context) throw new Error("Combobox parts must be used within "); return context; } type ComboboxBaseProps = { /** The choices, for labels while the list is closed and for ComboboxList's render function. */ items?: (Item | string)[]; open?: boolean; defaultOpen?: boolean; onOpenChange?: (open: boolean) => void; disabled?: boolean; /** The submitted name: one hidden input per chosen value. */ name?: string; children?: React.ReactNode; }; type ComboboxSingleProps = ComboboxBaseProps & { multiple?: false; value?: string | null; defaultValue?: string | null; onValueChange?: (value: string | null) => void }; type ComboboxMultipleProps = ComboboxBaseProps & { multiple: true; value?: string[]; defaultValue?: string[]; onValueChange?: (value: string[]) => void }; const normalize = (items?: (Item | string)[]) => items?.map((i) => (typeof i === "string" ? { value: i, label: i } : i)); function Combobox(props: ComboboxSingleProps | ComboboxMultipleProps) { const { open: openProp, defaultOpen = false, onOpenChange, disabled, name, children } = props; const multiple = props.multiple === true; const items = React.useMemo(() => normalize(props.items), [props.items]); const initial = props.defaultValue == null ? [] : Array.isArray(props.defaultValue) ? props.defaultValue : [props.defaultValue]; const [inner, setInner] = React.useState(initial); const controlled = props.value !== undefined; const values = controlled ? (props.value == null ? [] : Array.isArray(props.value) ? props.value : [props.value]) : inner; const [openInner, setOpenInner] = React.useState(defaultOpen); const open = openProp ?? openInner; const [search, setSearch] = React.useState(""); const [chips, setChips] = React.useState(false); const labels = React.useRef(new Map()); const listRef = React.useRef(null); const chipsRef = React.useRef(null); const contentId = React.useId(); const setOpen = React.useCallback( (next: boolean) => { if (openProp === undefined) setOpenInner(next); onOpenChange?.(next); if (!next) setSearch(""); }, [openProp, onOpenChange], ); const commit = (next: string[]) => { if (!controlled) setInner(next); if (multiple) (props as ComboboxMultipleProps).onValueChange?.(next); else (props as ComboboxSingleProps).onValueChange?.(next[0] ?? null); }; const toggle = (value: string) => { if (multiple) { commit(values.includes(value) ? values.filter((v) => v !== value) : [...values, value]); setSearch(""); } else { // Picking the chosen value again keeps it; ComboboxClear empties the field. commit([value]); setOpen(false); } }; const remove = (value: string) => commit(values.filter((v) => v !== value)); const labelOf = (value: string) => labels.current.get(value) ?? items?.find((i) => i.value === value)?.label ?? value; const register = React.useCallback((value: string, label: string) => labels.current.set(value, label), []); return ( commit([]), open, setOpen, search, setSearch, labelOf, register, items, disabled, listRef, chipsRef, chips, setChips, contentId }}> {children} {name && values.map((v) => )} ); } const FIELD = "flex min-h-10 w-full min-w-0 items-center gap-2 rounded-pill bg-surface pr-3 pl-3.5 font-sans text-body leading-tight text-fg shadow-s outline-none transition-[box-shadow] duration-150 ease-out aria-invalid:shadow-[0_0_0_1px_var(--ds-color-status-danger)] motion-reduce:transition-none"; /** The closed field: the chosen value (ComboboxValue) and the caret. Opens the list. */ function ComboboxTrigger({ className, children, ...props }: React.ComponentProps<"button">) { const { open, disabled, values, contentId } = useCombobox(); return ( ); } /** The chosen value's label, the chosen labels joined, or the placeholder. */ function ComboboxValue({ placeholder = "Select", children }: { placeholder?: string; children?: (values: string[]) => React.ReactNode }) { const { values, labelOf } = useCombobox(); if (children) return <>{children(values)}; return {values.length ? values.map(labelOf).join(", ") : placeholder}; } /** Clears every chosen value. Put it inside ComboboxChips or beside a trigger. */ function ComboboxClear({ className, ...props }: React.ComponentProps<"button">) { const { values, clear, disabled } = useCombobox(); if (!values.length) return null; return ( ); } function ComboboxContent({ className, children, align = "start", sideOffset = 6, anchor, onInteractOutside, ...props }: React.ComponentProps & { anchor?: React.RefObject }) { const container = usePortalContainer(); const { search, chips, listRef, chipsRef, contentId, open } = useCombobox(); // With chips cmdk does not filter, so it would keep a hidden row highlighted: highlight the first match instead. const [active, setActive] = React.useState(""); React.useEffect(() => { if (!chips || !open) return; const frame = requestAnimationFrame(() => setActive(listRef.current?.querySelector("[cmdk-item]:not([data-disabled=true])")?.getAttribute("data-value") ?? "")); return () => cancelAnimationFrame(frame); }, [chips, open, search, listRef]); return ( <> {anchor && } />} e.preventDefault() : undefined} onInteractOutside={(e) => { onInteractOutside?.(e); // A press in the chips field keeps the list open: the field is where the typing happens. if (chips && chipsRef.current?.contains(e.target as Node)) e.preventDefault(); }} className={cn( "z-50 w-(--radix-popover-trigger-width) min-w-56 max-w-[calc(100vw-2rem)] origin-(--radix-popover-content-transform-origin) overflow-hidden rounded-panel bg-surface text-fg shadow-floating outline-none data-[state=closed]:animate-fade-out data-[state=open]:animate-fade-in", className, )} {...props} > {children} ); } /** The search row at the top of the list. */ function ComboboxInput({ className, placeholder = "Search", ...props }: Omit, "value" | "onValueChange">) { const { search, setSearch } = useCombobox(); return (
); } /** The rows. Pass items as children, or a function that renders each of the Combobox's items. */ function ComboboxList({ className, children, ...props }: Omit, "children"> & { children?: React.ReactNode | ((item: Item) => React.ReactNode) }) { const { items } = useCombobox(); return ( {typeof children === "function" ? items?.map((item) => {children(item)}) : children} ); } /** Renders a group's own items with a function, inside a ComboboxGroup. */ function ComboboxCollection({ items, children }: { items: (Item | string)[]; children: (item: Item) => React.ReactNode }) { return <>{normalize(items)?.map((item) => {children(item)})}; } function ComboboxItem({ value, label, className, children, disabled, ...props }: Omit, "value" | "onSelect"> & { value: string; label?: string }) { const { values, toggle, register, chips, search } = useCombobox(); const text = label ?? (typeof children === "string" ? children : value); React.useEffect(() => { register(value, text); }, [register, value, text]); // With chips, cmdk does not filter (the typing is outside it): hide rows that do not match here. if (chips && search && !text.toLowerCase().includes(search.toLowerCase())) return null; const selected = values.includes(value); return ( toggle(value)} className={cn( "relative flex h-9 cursor-default items-center gap-2.5 rounded-pill pr-8 pl-2.5 font-sans text-body leading-tight text-fg outline-none select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-[selected=true]:bg-subtle [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-5.5 [&_svg:not([class*='text-'])]:text-icon-secondary", className, )} {...props} > {children ?? text} {selected && } ); } function ComboboxEmpty({ className, children = "No results.", ...props }: React.ComponentProps) { const { chips, search, items, labelOf, values } = useCombobox(); // cmdk's own empty state only knows its filter; with chips, decide here. if (chips) { const any = (items ?? values.map((v) => ({ value: v, label: labelOf(v) }))).some((i) => !search || i.label.toLowerCase().includes(search.toLowerCase())); if (any) return null; return (
{children}
); } return ( {children} ); } function ComboboxGroup({ className, ...props }: React.ComponentProps) { return ( ); } /** A heading inside the list that is not a group's (a note above the rows). */ function ComboboxLabel({ className, ...props }: React.ComponentProps<"div">) { return
; } function ComboboxSeparator({ className, ...props }: React.ComponentProps) { return ; } /** The field for several values: chips and a text input, anchoring the list. */ function ComboboxChips({ className, children, ...props }: React.ComponentProps<"div">) { const { setChips, setOpen, disabled, chipsRef } = useCombobox(); React.useEffect(() => { setChips(true); return () => setChips(false); }, [setChips]); return (
{ if (disabled || e.target !== e.currentTarget) return; e.preventDefault(); e.currentTarget.querySelector("input")?.focus(); setOpen(true); }} className={cn(FIELD, "flex-wrap gap-1.5 rounded-dialog py-1.5 pl-1.5 has-[input:focus-visible]:shadow-[var(--ds-shadow-s),var(--ds-shadow-focus)] data-disabled:bg-subtle data-disabled:text-fg-muted", className)} {...props} > {children}
); } function ComboboxChip({ value, className, children, ...props }: React.ComponentProps<"span"> & { value: string }) { const { remove, labelOf, disabled } = useCombobox(); return ( {children ?? labelOf(value)} ); } /** The typing part of ComboboxChips: filters the list; arrows and Enter work the list; Backspace removes the last chip. */ function ComboboxChipsInput({ className, onKeyDown, ...props }: React.ComponentProps<"input">) { const { search, setSearch, setOpen, open, values, remove, listRef, disabled, contentId } = useCombobox(); return ( { setSearch(e.target.value); if (!open) setOpen(true); }} onFocus={() => setOpen(true)} onKeyDown={(e) => { onKeyDown?.(e); if (e.defaultPrevented) return; if (e.key === "Backspace" && !search && values.length) remove(values[values.length - 1]); else if (["ArrowDown", "ArrowUp", "Enter", "Home", "End"].includes(e.key) && open && listRef.current) { e.preventDefault(); // Hand the key to the list, which moves the highlight or picks the highlighted row. listRef.current.dispatchEvent(new KeyboardEvent("keydown", { key: e.key, bubbles: true, cancelable: true })); } else if (e.key === "ArrowDown" && !open) setOpen(true); }} className={cn("h-7 min-w-24 flex-1 bg-transparent font-sans text-body leading-tight text-fg outline-none placeholder:text-fg-subtle disabled:cursor-not-allowed", className)} {...props} /> ); } /** A ref for an element the list should open from, when it is not the trigger (pass it to ComboboxContent's anchor). */ function useComboboxAnchor() { return React.useRef(null); } export { Combobox, ComboboxChip, ComboboxChips, ComboboxChipsInput, ComboboxClear, ComboboxCollection, ComboboxContent, ComboboxEmpty, ComboboxGroup, ComboboxInput, ComboboxItem, ComboboxLabel, ComboboxList, ComboboxSeparator, ComboboxTrigger, ComboboxValue, useCombobox, useComboboxAnchor, }; ``` # Command A search row over grouped 36px results with purple group headings, in a panel or a dialog with the shared 16px popup corners. Install: npx inkbox-ds add command Requires: theme, surface, utils Packages: cmdk, radix-ui, @phosphor-icons/react Exports: Command, CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator, CommandShortcut Client boundary: yes Reference: Extrapolated from the Inkbox console (Figma 427:2715): the nav rows, section labels (Channels, Shared) and the dialog ## Usage Use as a command palette (CommandDialog, opened with Command K) to jump to agents, mailboxes and pages, or inline as a searchable list. Give every item a filled glyph and the group a heading. - Avoid: Animating the palette open or closed - Avoid: A palette without an empty state - Avoid: Results without groups once there are more than ten ## Examples Example components: npx inkbox-ds add command button Example packages: @phosphor-icons/react [Preview](https://inkbox-ds.vercel.app/preview/command/Preview) | [Dialog](https://inkbox-ds.vercel.app/preview/command/Dialog) | [Open](https://inkbox-ds.vercel.app/preview/command/Open) examples/command.tsx ```tsx "use client"; import * as React from "react"; import { BookOpenTextIcon, EnvelopeSimpleIcon, GearSixIcon, KeyIcon, LockKeyIcon, PhoneIcon, PlusIcon, RobotIcon, WebhooksLogoIcon } from "@phosphor-icons/react/ssr"; import { Button } from "@/components/ui/button"; import { Command, CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator, CommandShortcut } from "@/components/ui/command"; function Items({ onRun }: { onRun?: () => void }) { return ( <> No results. @acme @acme-support Create Agent ⌘N Email Phone Agent2Agent (beta) Vault API Keys ⌘K Settings ⌘, Read the docs ); } export function Preview() { return ( ); } export function Dialog() { const [open, setOpen] = React.useState(false); React.useEffect(() => { const onKey = (event: KeyboardEvent) => { if (event.key === "k" && (event.metaKey || event.ctrlKey)) { event.preventDefault(); setOpen((o) => !o); } }; document.addEventListener("keydown", onKey); return () => document.removeEventListener("keydown", onKey); }, []); return (

Or press ⌘K

setOpen(false)} />
); } export function Open() { return (
); } ``` ## Source components/ui/command.tsx ```tsx "use client"; import * as React from "react"; import { Command as CommandPrimitive } from "cmdk"; import { Dialog as DialogPrimitive } from "radix-ui"; import { MagnifyingGlassIcon } from "@phosphor-icons/react/ssr"; import { usePortalContainer } from "@/lib/surface"; import { cn } from "@/lib/utils"; // A command menu, extrapolated from the console (Figma 427:2715): a white panel with 16px corners; the search row is // 48px with a 20px magnifying glass in muted purple and 14px ink text over the rail's hairline; groups open with 13px // headings in the label purple ("Channels"), items are 36px pill rows, a 20px glyph in the action-glyph // purple and the lilac under the selected row; shortcuts are 12px muted. CommandDialog puts it in the console's dialog // (16px corners, the blurred ink-tinted page) and opens without motion: it is summoned by keyboard many times a day. function Command({ className, ...props }: React.ComponentProps) { return ; } function CommandDialog({ title = "Command menu", description = "Search for a command to run", children, className, showCloseButton = false, ...props }: React.ComponentProps & { title?: string; description?: string; className?: string; showCloseButton?: boolean }) { const container = usePortalContainer(); return ( {title} {description} {children} {showCloseButton && Close} ); } function CommandInput({ className, ...props }: React.ComponentProps) { return (
); } function CommandList({ className, ...props }: React.ComponentProps) { return ; } function CommandEmpty({ className, ...props }: React.ComponentProps) { return ; } function CommandGroup({ className, ...props }: React.ComponentProps) { return ( ); } function CommandSeparator({ className, ...props }: React.ComponentProps) { return ; } function CommandItem({ className, ...props }: React.ComponentProps) { return ( ); } function CommandShortcut({ className, ...props }: React.ComponentProps<"span">) { return ; } export { Command, CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator, CommandShortcut }; ``` # Context Menu The dropdown menu's panel and rows, opened with a right click or a long press on an area. Install: npx inkbox-ds add context-menu Requires: theme, surface, utils Packages: radix-ui, @phosphor-icons/react Exports: ContextMenu, ContextMenuCheckboxItem, ContextMenuContent, ContextMenuGroup, ContextMenuItem, ContextMenuLabel, ContextMenuPortal, ContextMenuRadioGroup, ContextMenuRadioItem, ContextMenuSeparator, ContextMenuShortcut, ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuTrigger Client boundary: yes Reference: Extrapolated from the Inkbox console (Figma 427:2715): the email list items, nav rows and the raised panel ## Usage Use for shortcuts to actions on an item in a list (an email in the inbox, a contact). Every action must also be reachable another way, such as a row's menu button. - Avoid: Actions that exist only in the context menu - Avoid: A context menu on text people need to select - Avoid: Nested submenus beyond one level ## Examples Example components: npx inkbox-ds add context-menu Example packages: @phosphor-icons/react [Preview](https://inkbox-ds.vercel.app/preview/context-menu/Preview) examples/context-menu.tsx ```tsx "use client"; import * as React from "react"; import { ArchiveIcon, ArrowBendUpLeftIcon, ArrowBendUpRightIcon, EnvelopeOpenIcon, ProhibitIcon, TrashIcon, WarningIcon } from "@phosphor-icons/react/ssr"; import { ContextMenu, ContextMenuCheckboxItem, ContextMenuContent, ContextMenuItem, ContextMenuLabel, ContextMenuRadioGroup, ContextMenuRadioItem, ContextMenuSeparator, ContextMenuShortcut, ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuTrigger, } from "@/components/ui/context-menu"; export function Preview() { const [starred, setStarred] = React.useState(false); const [folder, setFolder] = React.useState("inbox"); return ( Ray Liao Yesterday Inkbox A2A: publish your Agent Card Hello @acme, This is Ray, cofounder of Inkbox. Right-click or long-press the email Reply R Forward F Mark as unread setStarred(v === true)}> Starred Move to Folders Inbox Sent Spam Report spam Block sender Delete ⌫ ); } ``` ## Source components/ui/context-menu.tsx ```tsx "use client"; import * as React from "react"; import { ContextMenu as ContextMenuPrimitive } from "radix-ui"; import { CaretRightIcon, CheckIcon } from "@phosphor-icons/react/ssr"; import { usePortalContainer } from "@/lib/surface"; import { cn } from "@/lib/utils"; // A right-click menu, extrapolated from the console (Figma 427:2715), drawn as the dropdown menu: the panel is a raised white panel (16px corners, the floating // shadow) with 4px of padding; items are 36px rows with fully rounded corners like the rail's nav rows, a 20px glyph in the // action-glyph purple 10px from a 14px ink label, and the lilac under the highlighted row. Section labels are 13px in // the label purple ("Channels"), separators the rail's hairline, shortcuts 12px muted, destructive items red. The // panel grows out of its trigger from 96 percent with a fade; highlighting a row is instant (menus are used by keyboard // and many times a day). const CONTENT = "z-50 max-h-(--radix-context-menu-content-available-height) min-w-48 overflow-x-hidden overflow-y-auto rounded-panel bg-surface p-1 text-fg shadow-floating outline-none data-[state=closed]:animate-fade-out data-[state=open]:animate-fade-in"; const ITEM = "relative flex h-9 cursor-default items-center gap-2.5 rounded-pill px-2.5 font-sans text-body leading-tight text-fg outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[highlighted]:bg-subtle data-[inset]:pl-10.5 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-5.5 [&_svg:not([class*='text-'])]:text-icon-secondary"; function ContextMenu(props: React.ComponentProps) { return ; } function ContextMenuPortal(props: React.ComponentProps) { const container = usePortalContainer(); return ; } function ContextMenuTrigger({ className, ...props }: React.ComponentProps) { return ; } function ContextMenuContent({ className, ...props }: React.ComponentProps) { return ( ); } function ContextMenuGroup(props: React.ComponentProps) { return ; } function ContextMenuItem({ className, inset, variant = "default", ...props }: React.ComponentProps & { inset?: boolean; variant?: "default" | "destructive" }) { return ( ); } function ContextMenuCheckboxItem({ className, children, checked, ...props }: React.ComponentProps) { return ( {children} ); } function ContextMenuRadioGroup(props: React.ComponentProps) { return ; } // The selected choice wears the console's radio mark: a pink ring around white. function ContextMenuRadioItem({ className, children, ...props }: React.ComponentProps) { return ( {children} ); } function ContextMenuLabel({ className, inset, ...props }: React.ComponentProps & { inset?: boolean }) { return ; } function ContextMenuSeparator({ className, ...props }: React.ComponentProps) { return ; } function ContextMenuShortcut({ className, ...props }: React.ComponentProps<"span">) { return ; } function ContextMenuSub(props: React.ComponentProps) { return ; } function ContextMenuSubTrigger({ className, inset, children, ...props }: React.ComponentProps & { inset?: boolean }) { return ( {children} ); } function ContextMenuSubContent({ className, ...props }: React.ComponentProps) { return ( ); } export { ContextMenu, ContextMenuCheckboxItem, ContextMenuContent, ContextMenuGroup, ContextMenuItem, ContextMenuLabel, ContextMenuPortal, ContextMenuRadioGroup, ContextMenuRadioItem, ContextMenuSeparator, ContextMenuShortcut, ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuTrigger, }; ``` # Data Table The console's table with a filter field, sortable heads, row checkboxes, a Columns menu and the lilac pagination bar. Install: npx inkbox-ds add data-table Requires: theme, button, checkbox, dropdown-menu, input-group, pagination, select, table, utils Packages: @phosphor-icons/react Exports: DataTable, DataTableColumnHeader, DataTablePagination, DataTableViewOptions, DataTableColumn, DataTableProps, SortState Client boundary: yes Reference: Figma 427:2715: Email (Mailboxes table and its pagination bar), iMessage (Agents and lines) ## Usage Use for a list of records people manage (mailboxes, agents and lines, webhook subscriptions): pass columns (id, header, an accessor for sorting and filtering, a cell renderer), data and getRowId. Turn on selectable for bulk actions and put those actions in toolbar; set pageSize and pageSizes for long lists. For a short fixed list with no sorting or paging, use Table on its own. Keep row actions as ghost icon buttons in the last column. - Avoid: Zebra stripes - Avoid: A border around the whole table instead of the three panels - Avoid: Animating rows when sorting or paging - Avoid: More than one purple pill in the toolbar ## Examples Example components: npx inkbox-ds add data-table badge button Example packages: @phosphor-icons/react [Preview](https://inkbox-ds.vercel.app/preview/data-table/Preview) | [Selection](https://inkbox-ds.vercel.app/preview/data-table/Selection) | [Empty](https://inkbox-ds.vercel.app/preview/data-table/Empty) examples/data-table.tsx ```tsx "use client"; import * as React from "react"; import { ArrowsClockwiseIcon, PencilSimpleIcon, TrashIcon } from "@phosphor-icons/react/ssr"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { DataTable, type DataTableColumn } from "@/components/ui/data-table"; type Mailbox = { address: string; agent: string; webhooks: number; storage: number; created: Date; status: "active" | "paused" }; const AGENTS = ["acme", "support-desk", "ops-runner", "acme-support", "test-agent-233", "billing-bot"]; const MAILBOXES: Mailbox[] = Array.from({ length: 23 }, (_, i) => { const agent = AGENTS[i % AGENTS.length]; return { address: `${agent}${i > 5 ? `-${i}` : ""}@inkboxmail.com`, agent, webhooks: (i * 7) % 4, storage: (i * 13) % 61, created: new Date(2026, 6, 27 + (i % 20)), status: i % 5 === 3 ? "paused" : "active" }; }); const date = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric" }); const COLUMNS: DataTableColumn[] = [ { id: "address", header: "Email address", accessor: (m) => m.address, sortable: true, hideable: false }, { id: "agent", header: "Agent", accessor: (m) => m.agent, sortable: true, cell: (m) => @{m.agent} }, { id: "webhooks", header: "Webhooks", accessor: (m) => m.webhooks, sortable: true, cell: (m) => (m.webhooks ? m.webhooks : "None") }, { id: "storage", header: "Storage", accessor: (m) => m.storage, sortable: true, cell: (m) => `${m.storage}%` }, { id: "status", header: "Status", accessor: (m) => m.status, hidden: true, cell: (m) => {m.status === "active" ? "Active" : "Paused"} }, { id: "created", header: "Created", accessor: (m) => m.created, sortable: true, cell: (m) => date.format(m.created) }, { id: "actions", header: Actions, hideable: false, align: "end", className: "w-14", cell: (m) => ( ), }, ]; export function Preview() { return m.address} rowName="mailbox" pageSize={5} filter={{ placeholder: "Filter mailboxes" }} defaultSort={{ id: "created", desc: false }} empty="No mailbox matches." />; } export function Selection() { const [rows, setRows] = React.useState(MAILBOXES.slice(0, 8)); const [selected, setSelected] = React.useState([]); return ( m.address} rowName="mailbox" selectable selection={selected} onSelectionChange={setSelected} pageSize={5} pageSizes={[5, 10, 20]} filter={{ placeholder: "Filter mailboxes", columns: ["address", "agent"] }} toolbar={ <> {selected.length > 0 && ( )} } /> ); } export function Empty() { return m.address} filter={false} hideViewOptions empty="No mailboxes yet. Mailboxes are created with their agent identity." />; } ``` ## Source components/ui/data-table.tsx ```tsx "use client"; import * as React from "react"; import { ArrowDownIcon, ArrowUpIcon, CaretUpDownIcon, ColumnsIcon, MagnifyingGlassIcon } from "@phosphor-icons/react/ssr"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group"; import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious } from "@/components/ui/pagination"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { cn } from "@/lib/utils"; // A table of records people sort, filter, select and page through (the console's Mailboxes table, Figma 427:2715, // Email). It keeps the table's three panels: the 48px lilac head with 14px muted labels (a sortable label is a ghost key // with an arrow for its direction), the white Shadow/S body with 64px rows (a selected row on the lilac) and, 8px below, // the 42px lilac pagination bar with the selection count at its left and the pages at its right. Above it, a toolbar: // a 40px filter field with a magnifying glass and the outline Columns key that opens a menu of checkboxes. Sorting, // filtering and paging are instant (no motion): they are repeated many times in a row. type Value = string | number | Date | boolean | null | undefined; type DataTableColumn = { id: string; /** The column's label, in its head and in the Columns menu. */ header: React.ReactNode; /** What the cell shows; defaults to the accessor's value. */ cell?: (row: T) => React.ReactNode; /** The value used to sort and filter. */ accessor?: (row: T) => Value; sortable?: boolean; /** Whether the Columns menu can hide it (default true). */ hideable?: boolean; /** Hidden until turned on in the Columns menu. */ hidden?: boolean; align?: "start" | "end"; className?: string; }; type SortState = { id: string; desc: boolean } | null; const text = (v: Value) => (v instanceof Date ? v.toISOString() : v == null ? "" : String(v)); function compare(a: Value, b: Value) { if (a == null && b == null) return 0; if (a == null) return 1; if (b == null) return -1; if (a instanceof Date && b instanceof Date) return a.getTime() - b.getTime(); if (typeof a === "number" && typeof b === "number") return a - b; return text(a).localeCompare(text(b), undefined, { numeric: true, sensitivity: "base" }); } /** A sortable head label: a ghost key whose arrow shows the direction (both carets while unsorted). */ function DataTableColumnHeader({ title, sorted, onSort, className }: { title: React.ReactNode; sorted: false | "asc" | "desc"; onSort?: () => void; className?: string }) { if (!onSort) return {title}; const Icon = sorted === "asc" ? ArrowUpIcon : sorted === "desc" ? ArrowDownIcon : CaretUpDownIcon; return ( ); } /** The Columns key and its menu of checkboxes, one per column that can be hidden. */ function DataTableViewOptions({ columns, visible, onVisibleChange }: { columns: { id: string; header: React.ReactNode }[]; visible: Record; onVisibleChange: (id: string, on: boolean) => void }) { return ( Show columns {columns.map((c) => ( onVisibleChange(c.id, on === true)} onSelect={(e) => e.preventDefault()}> {c.header} ))} ); } /** The page numbers around the current one: the ends, its neighbors and ellipses between. */ function pageList(page: number, count: number): (number | "gap")[] { if (count <= 7) return Array.from({ length: count }, (_, i) => i); const set = new Set([0, count - 1, page - 1, page, page + 1].filter((p) => p >= 0 && p < count)); const sorted = [...set].sort((a, b) => a - b); return sorted.flatMap((p, i) => (i && p - sorted[i - 1] > 1 ? (["gap", p] as const) : [p])); } /** The lilac bar under the table: the selection count, rows per page and the pages. */ function DataTablePagination({ page, pageCount, onPageChange, pageSize, pageSizes, onPageSizeChange, selected, total, className }: { page: number; pageCount: number; onPageChange: (page: number) => void; pageSize?: number; pageSizes?: number[]; onPageSizeChange?: (size: number) => void; selected?: number; total?: number; className?: string }) { const go = (p: number) => (e: React.MouseEvent) => { e.preventDefault(); if (p >= 0 && p < pageCount) onPageChange(p); }; return (
{selected !== undefined && total !== undefined && ( {selected} of {total} selected )} {pageSizes && pageSize && onPageSizeChange && ( Rows per page )}
{pageList(page, pageCount).map((p, i) => p === "gap" ? ( ) : ( {p + 1} ), )} = pageCount - 1 || undefined} tabIndex={page >= pageCount - 1 ? -1 : undefined} />
); } type DataTableProps = { columns: DataTableColumn[]; data: T[]; getRowId: (row: T) => string; /** A filter field over these columns' values (all filterable columns when omitted). */ filter?: { placeholder?: string; columns?: string[] } | false; /** A checkbox column for choosing rows. */ selectable?: boolean; selection?: string[]; onSelectionChange?: (ids: string[]) => void; /** Hide the Columns key. */ hideViewOptions?: boolean; pageSize?: number; /** Offer these page sizes in the bar. */ pageSizes?: number[]; defaultSort?: SortState; /** Shown in the body when nothing matches. */ empty?: React.ReactNode; /** Actions at the toolbar's right, before the Columns key. */ toolbar?: React.ReactNode; /** What a row is called in labels ("mailbox"). */ rowName?: string; className?: string; }; function DataTable({ columns, data, getRowId, filter = {}, selectable = false, selection: selectionProp, onSelectionChange, hideViewOptions = false, pageSize: pageSizeProp = 10, pageSizes, defaultSort = null, empty = "No results.", toolbar, rowName = "row", className }: DataTableProps) { const [query, setQuery] = React.useState(""); const [sort, setSort] = React.useState(defaultSort); const [page, setPage] = React.useState(0); const [pageSize, setPageSize] = React.useState(pageSizeProp); const [visible, setVisible] = React.useState>(() => Object.fromEntries(columns.map((c) => [c.id, !c.hidden]))); const [selectionInner, setSelectionInner] = React.useState([]); const selection = selectionProp ?? selectionInner; const setSelection = (ids: string[]) => { if (!selectionProp) setSelectionInner(ids); onSelectionChange?.(ids); }; const shown = columns.filter((c) => visible[c.id] !== false); const filterColumns = filter ? columns.filter((c) => c.accessor && (!filter.columns || filter.columns.includes(c.id))) : []; const q = query.trim().toLowerCase(); let rows = q ? data.filter((row) => filterColumns.some((c) => text(c.accessor!(row)).toLowerCase().includes(q))) : data; const sortColumn = sort && columns.find((c) => c.id === sort.id); if (sort && sortColumn?.accessor) { const get = sortColumn.accessor; rows = [...rows].sort((a, b) => compare(get(a), get(b)) * (sort.desc ? -1 : 1)); } const pageCount = Math.max(1, Math.ceil(rows.length / pageSize)); const current = Math.min(page, pageCount - 1); const pageRows = rows.slice(current * pageSize, current * pageSize + pageSize); const pageIds = pageRows.map(getRowId); const allOnPage = pageIds.length > 0 && pageIds.every((id) => selection.includes(id)); const someOnPage = pageIds.some((id) => selection.includes(id)); const toggleSort = (id: string) => setSort((s) => (s?.id !== id ? { id, desc: false } : s.desc ? null : { id, desc: true })); return (
{(filter || !hideViewOptions || toolbar) && (
{filter && ( { setQuery(e.target.value); setPage(0); }} placeholder={filter.placeholder ?? "Filter"} aria-label={filter.placeholder ?? "Filter"} /> )}
{toolbar} {!hideViewOptions && ( c.hideable !== false)} visible={visible} onVisibleChange={(id, on) => setVisible((v) => ({ ...v, [id]: on }))} /> )}
)}
{selectable && ( setSelection(on === true ? [...new Set([...selection, ...pageIds])] : selection.filter((id) => !pageIds.includes(id)))} /> )} {shown.map((c) => ( toggleSort(c.id) : undefined} /> ))} {pageRows.length ? ( pageRows.map((row) => { const id = getRowId(row); const selected = selection.includes(id); return ( {selectable && ( setSelection(on === true ? [...selection, id] : selection.filter((s) => s !== id))} /> )} {shown.map((c) => ( {c.cell ? c.cell(row) : text(c.accessor?.(row))} ))} ); }) ) : ( {empty} )}
{ setPageSize(s); setPage(0); }} selected={selectable ? selection.length : undefined} total={selectable ? data.length : undefined} />
); } export { DataTable, DataTableColumnHeader, DataTablePagination, DataTableViewOptions }; export type { DataTableColumn, DataTableProps, SortState }; ``` # Date Picker The select trigger with a calendar glyph that opens the calendar in a white panel, for one date or a range, with optional presets. Install: npx inkbox-ds add date-picker Requires: theme, calendar, popover, utils Packages: @phosphor-icons/react Exports: DatePicker, DateRangePicker, formatDate, formatRange, DatePickerProps, DateRangePickerProps Client boundary: yes Reference: Extrapolated from the Inkbox console (Figma 427:2715): the select trigger (Any State) and the menu panel, with the calendar ## Usage Use DatePicker for one date (a key's expiry, when to send) and DateRangePicker for a period (the delivery log's range). Pass value and onValueChange, a placeholder, and presets for common choices (Today, Last 7 days). Pass calendar props such as disabled days through calendar. Give it a name to submit the date as YYYY-MM-DD with a form. - Avoid: A text field people must type a date into - Avoid: A range picker for a single date - Avoid: More than six presets - Avoid: Closing a range before both ends are picked ## Examples Example components: npx inkbox-ds add date-picker calendar label [Preview](https://inkbox-ds.vercel.app/preview/date-picker/Preview) | [Range](https://inkbox-ds.vercel.app/preview/date-picker/Range) | [Presets](https://inkbox-ds.vercel.app/preview/date-picker/Presets) | [States](https://inkbox-ds.vercel.app/preview/date-picker/States) examples/date-picker.tsx ```tsx "use client"; import * as React from "react"; import type { DateRange } from "@/components/ui/calendar"; import { DatePicker, DateRangePicker } from "@/components/ui/date-picker"; import { Label } from "@/components/ui/label"; // A fixed today, so the examples read the same on every visit. const TODAY = new Date(2026, 7, 8); const days = (n: number) => new Date(TODAY.getFullYear(), TODAY.getMonth(), TODAY.getDate() + n); export function Preview() { const [date, setDate] = React.useState(); return (
); } export function Range() { const [range, setRange] = React.useState({ from: days(-6), to: TODAY }); return (
); } export function Presets() { const [send, setSend] = React.useState(days(1)); const [range, setRange] = React.useState(); return (
); } export function States() { return (

Choose a start date.

); } ``` ## Source components/ui/date-picker.tsx ```tsx "use client"; import * as React from "react"; import { CalendarBlankIcon, CaretDownIcon } from "@phosphor-icons/react/ssr"; import { Calendar, type CalendarProps, type DateRange } from "@/components/ui/calendar"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { cn } from "@/lib/utils"; // A date field (extrapolated from the Inkbox console, Figma 427:2715: the select trigger "Any State" and its menu // panel). The trigger is the select's white 40px key with 8px corners and the Shadow/S ring: a 24px calendar glyph in // the softer purple, the date in 14px ink (the placeholder in the light purple) and the bold caret at the right. The // calendar opens under it in the popover's white panel, growing from the trigger. Presets sit in a column of 36px rows // at the panel's left, the chosen one on the lilac. A single date closes the panel when picked; a range closes once both // ends are set. Opening puts focus on the chosen day (or today), so the arrow keys work at once. const TRIGGER = "flex h-10 w-full min-w-0 cursor-pointer items-center gap-2.5 rounded-pill bg-surface pr-3 pl-3 font-sans text-body leading-tight whitespace-nowrap text-fg shadow-s outline-none transition-[box-shadow] duration-150 ease-out focus-visible:shadow-[var(--ds-shadow-s),var(--ds-shadow-focus)] disabled:cursor-not-allowed disabled:bg-subtle disabled:text-fg-muted aria-invalid:shadow-[0_0_0_1px_var(--ds-color-status-danger)] data-[empty=true]:text-fg-subtle motion-reduce:transition-none"; type Preset = { label: string; value: T }; function formatDate(date: Date, locale?: string, options: Intl.DateTimeFormatOptions = { month: "short", day: "numeric", year: "numeric" }) { return new Intl.DateTimeFormat(locale, options).format(date); } function formatRange(range: DateRange | undefined, locale?: string) { if (!range?.from) return undefined; if (!range.to) return `${formatDate(range.from, locale)} -`; const sameYear = range.from.getFullYear() === range.to.getFullYear(); return `${formatDate(range.from, locale, sameYear ? { month: "short", day: "numeric" } : undefined)} - ${formatDate(range.to, locale)}`; } function DatePickerTrigger({ empty, label, className, children, ...props }: React.ComponentProps<"button"> & { empty: boolean; label?: string }) { return ( ); } function Presets({ presets, isActive, onPick }: { presets: Preset[]; isActive: (p: Preset) => boolean; onPick: (value: T) => void }) { return (
{presets.map((p) => ( ))}
); } const sameDay = (a?: Date, b?: Date) => !!a && !!b && a.toDateString() === b.toDateString(); type CalendarPassThrough = Omit; type DatePickerProps = { value?: Date; defaultValue?: Date; onValueChange?: (date: Date | undefined) => void; placeholder?: string; disabled?: boolean; /** Shortcuts beside the calendar (Today, In a week). */ presets?: Preset[]; /** Props for the calendar inside (disabled days, the first day of the week, today). */ calendar?: CalendarPassThrough; locale?: string; id?: string; name?: string; className?: string; "aria-invalid"?: boolean; "aria-label"?: string; }; function DatePicker({ value: valueProp, defaultValue, onValueChange, placeholder = "Pick a date", disabled, presets, calendar, locale, id, name, className, ...aria }: DatePickerProps) { const [open, setOpen] = React.useState(false); const [inner, setInner] = React.useState(defaultValue); const value = valueProp !== undefined ? valueProp : inner; const set = (d: Date | undefined) => { if (valueProp === undefined) setInner(d); onValueChange?.(d); }; return ( {value ? formatDate(value, locale) : placeholder} {name && } e.preventDefault()} className="w-auto gap-0 overflow-hidden p-0">
{presets && ( sameDay(p.value, value)} onPick={(d) => { set(d); setOpen(false); }} /> )} { set(d); if (d) setOpen(false); }} />
); } type DateRangePickerProps = Omit & { value?: DateRange; defaultValue?: DateRange; onValueChange?: (range: DateRange | undefined) => void; presets?: Preset[]; /** How many months show side by side (two by default; one on a phone). */ numberOfMonths?: number; }; function DateRangePicker({ value: valueProp, defaultValue, onValueChange, placeholder = "Pick a date range", disabled, presets, calendar, locale, numberOfMonths = 2, id, name, className, ...aria }: DateRangePickerProps) { const [open, setOpen] = React.useState(false); const [inner, setInner] = React.useState(defaultValue); const value = valueProp !== undefined ? valueProp : inner; const set = (r: DateRange | undefined) => { if (valueProp === undefined) setInner(r); onValueChange?.(r); }; const [narrow, setNarrow] = React.useState(false); React.useEffect(() => { const query = window.matchMedia("(max-width: 39.99rem)"); const update = () => setNarrow(query.matches); update(); query.addEventListener("change", update); return () => query.removeEventListener("change", update); }, []); const label = formatRange(value, locale); return ( {label ?? placeholder} {name && ( <> )} e.preventDefault()} className="w-auto max-w-[calc(100vw-2rem)] gap-0 overflow-hidden p-0">
{presets && ( sameDay(p.value.from, value?.from) && sameDay(p.value.to, value?.to)} onPick={(r) => { set(r); setOpen(false); }} /> )} { set(r); if (r?.from && r.to) setOpen(false); }} />
); } export { DatePicker, DateRangePicker, formatDate, formatRange }; export type { DatePickerProps, DateRangePickerProps }; ``` # Dialog A centered white panel with 16px corners over the blurred, ink-tinted page. Install: npx inkbox-ds add dialog Requires: theme, surface, utils Packages: @chakra-ui/react, @phosphor-icons/react Exports: Dialog, DialogBody, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger Client boundary: yes Reference: Figma 427:2715: Modal - Create Agent, Modal - Created, modal-bg ## Usage Use for a short task that needs the person's full attention (Create Agent Identity) or a result they must read before moving on (the new API key). Put the title in DialogHeader, the content in DialogBody and a full-width default Button at the end of the body for the one action. - Avoid: A dark scrim - Avoid: A dialog that opens another dialog - Avoid: A drop shadow under the panel - Avoid: Scaling from zero ## Examples Example components: npx inkbox-ds add dialog button input label [Preview](https://inkbox-ds.vercel.app/preview/dialog/Preview) examples/dialog.tsx ```tsx "use client"; import { Button } from "@/components/ui/button"; import { Dialog, DialogBody, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; export function Preview() { return ( Create Agent Identity Choose a handle and a display name for the new agent.
); } ``` ## Source components/ui/dialog.tsx ```tsx "use client"; import * as React from "react"; import { Dialog as ChakraDialog, Portal } from "@chakra-ui/react"; import { XIcon } from "@phosphor-icons/react/ssr"; import { usePortalContainer } from "@/lib/surface"; import { cn } from "@/lib/utils"; // The console's dialog (Create Agent Identity, @test-agent is ready!): a white panel with 16px corners on the page // behind it, which takes the ink at 12 percent over a light blur. A 68px head holds the 20px regular title 24px in from // the left and a faint 20px close glyph 24px from the right; the body keeps the same 24px sides. It stays centered and // opens from 96 percent with a fade (250ms, the strong ease-out); it closes faster than it opens. type DialogProps = Omit, "onOpenChange"> & { onOpenChange?: (open: boolean) => void }; function Dialog({ onOpenChange, ...props }: DialogProps) { return onOpenChange?.(details.open)} {...props} />; } function DialogTrigger(props: React.ComponentProps) { return ; } function DialogPortal(props: React.ComponentProps) { const container = usePortalContainer(); const portalRef = React.useMemo(() => container ? { current: container } : undefined, [container]); return ; } function DialogClose(props: React.ComponentProps) { return ; } function DialogOverlay({ className, ...props }: React.ComponentProps) { return ( ); } function DialogContent({ className, children, showClose = true, ...props }: React.ComponentProps & { showClose?: boolean }) { return ( {children} {showClose && ( Close )} ); } function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { return
; } /** The scrolling middle of a dialog, with the head's 24px sides. */ function DialogBody({ className, ...props }: React.ComponentProps<"div">) { return
; } function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { return
; } function DialogTitle({ className, ...props }: React.ComponentProps) { return ; } function DialogDescription({ className, ...props }: React.ComponentProps) { return ; } export { Dialog, DialogBody, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger }; ``` # Direction Sets left-to-right or right-to-left for every component inside it. Install: npx inkbox-ds add direction Requires: theme Packages: radix-ui Exports: DirectionProvider, useDirection Client boundary: yes Reference: Extrapolated from the Inkbox console (Figma 427:2715): no visual of its own ## Usage Wrap an app or a section in DirectionProvider dir="rtl" when its language reads right to left, and set dir on the element too. Menus, sliders, tabs and carousels then flip their keyboard and layout direction. - Avoid: Mixing directions inside one menu or form - Avoid: Mirroring logos or code ## Examples Example components: npx inkbox-ds add direction tabs [Preview](https://inkbox-ds.vercel.app/preview/direction/Preview) examples/direction.tsx ```tsx import { DirectionProvider } from "@/components/ui/direction"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; export function Preview() { return (

صندوق البريد

صناديق البريد قواعد الاتصال النطاقات
); } ``` ## Source components/ui/direction.tsx ```tsx "use client"; import * as React from "react"; import { Direction } from "radix-ui"; // Sets the reading direction (ltr or rtl) for every Radix-based component inside it, so menus, sliders and tabs flip // for right-to-left text. It renders nothing of its own; set dir on the element you wrap too. function DirectionProvider({ dir, direction, children }: React.ComponentProps & { direction?: React.ComponentProps["dir"] }) { return {children}; } const useDirection = Direction.useDirection; export { DirectionProvider, useDirection }; ``` # Drawer A panel pulled up from the bottom edge with a handle, dragged away with a flick or a pull. Install: npx inkbox-ds add drawer Requires: theme, surface, utils Packages: radix-ui Exports: Drawer, DrawerClose, DrawerContent, DrawerDescription, DrawerFooter, DrawerHeader, DrawerOverlay, DrawerPortal, DrawerTitle, DrawerTrigger Client boundary: yes Reference: Extrapolated from the Inkbox console (Figma 427:2715): Modal - Create Agent (panel, title, scrim) and the field-edge lilac ## Usage Use on phones for a task or a choice from the bottom of the screen (choose a phone line, confirm a key). Pair it with Dialog on larger screens when the same task is needed. Set direction for other edges. - Avoid: A drawer without its handle - Avoid: Dragging that fights scrolling content (mark scroll areas data-drawer-no-drag) - Avoid: A drawer on desktop for long forms ## Examples Example components: npx inkbox-ds add drawer button Example packages: @phosphor-icons/react [Preview](https://inkbox-ds.vercel.app/preview/drawer/Preview) | [Directions](https://inkbox-ds.vercel.app/preview/drawer/Directions) | [Open](https://inkbox-ds.vercel.app/preview/drawer/Open) examples/drawer.tsx ```tsx "use client"; import * as React from "react"; import { CheckCircleIcon } from "@phosphor-icons/react/ssr"; import { Button } from "@/components/ui/button"; import { Drawer, DrawerClose, DrawerContent, DrawerDescription, DrawerFooter, DrawerHeader, DrawerTitle, DrawerTrigger } from "@/components/ui/drawer"; const LINES = [ { id: "shared", title: "Shared", body: "Assigned when someone connects to this agent." }, { id: "dedicated", title: "Dedicated", body: "A number only this agent uses." }, ]; function ChooseLine({ onChoose }: { onChoose?: () => void }) { const [line, setLine] = React.useState("shared"); return ( <> iMessage line Choose how recipients connect to @acme.
{LINES.map((l) => ( ))}
); } export function Preview() { const [open, setOpen] = React.useState(false); return (
setOpen(false)} />
); } export function Directions() { const directions = ["bottom", "top", "right", "left"] as const; return (
{directions.map((direction) => ( Drawer from the {direction} Drag it back toward its edge, or flick it, to close. ))}
); } export function Open() { return (
); } ``` ## Source components/ui/drawer.tsx ```tsx "use client"; import * as React from "react"; import { Dialog as DrawerPrimitive } from "radix-ui"; import { usePortalContainer } from "@/lib/surface"; import { cn } from "@/lib/utils"; // A drawer, drawn as the console's dialog (Figma 427:2715: Create Agent Identity) and pulled from an edge: white over // the blurred, ink-tinted page, 16px corners on its inner edge, a 40 by 4px handle in the field-edge lilac, a 20px // regular title and 24px sides. It slides in on the drawer curve and can be dragged away: a flick (faster than 0.11px // per ms) or a pull past a third of its size closes it, a shorter pull springs back on the same curve, and pulling the // other way meets growing resistance instead of a wall. The drag follows the pointer even off the panel (pointer // capture) and ignores a second finger. type Direction = "bottom" | "top" | "left" | "right"; const DrawerContext = React.createContext<{ direction: Direction; setOpen: (open: boolean) => void }>({ direction: "bottom", setOpen: () => {} }); function Drawer({ open: openProp, defaultOpen = false, onOpenChange, direction = "bottom", ...props }: React.ComponentProps & { direction?: Direction }) { const [uncontrolled, setUncontrolled] = React.useState(defaultOpen); const open = openProp ?? uncontrolled; const setOpen = React.useCallback( (next: boolean) => { if (openProp === undefined) setUncontrolled(next); onOpenChange?.(next); }, [openProp, onOpenChange], ); return ( ); } function DrawerTrigger(props: React.ComponentProps) { return ; } function DrawerPortal(props: React.ComponentProps) { const container = usePortalContainer(); return ; } function DrawerClose(props: React.ComponentProps) { return ; } function DrawerOverlay({ className, ...props }: React.ComponentProps) { return ( ); } const PLACE: Record = { bottom: "inset-x-0 bottom-0 max-h-[85dvh] rounded-t-panel data-[state=closed]:animate-sheet-out-bottom data-[state=open]:animate-sheet-in-bottom", top: "inset-x-0 top-0 max-h-[85dvh] flex-col-reverse rounded-b-panel data-[state=closed]:animate-sheet-out-top data-[state=open]:animate-sheet-in-top", right: "inset-y-0 right-0 w-3/4 flex-row-reverse rounded-l-panel sm:max-w-96 data-[state=closed]:animate-sheet-out-right data-[state=open]:animate-sheet-in-right", left: "inset-y-0 left-0 w-3/4 flex-row rounded-r-panel sm:max-w-96 data-[state=closed]:animate-sheet-out-left data-[state=open]:animate-sheet-in-left", }; // Where a pull closes the drawer: toward its own edge (positive for bottom and right). const AXIS: Record = { bottom: { axis: "y", sign: 1 }, top: { axis: "y", sign: -1 }, right: { axis: "x", sign: 1 }, left: { axis: "x", sign: -1 } }; const VELOCITY = 0.11; const SKIP = "button, a, input, textarea, select, [role=slider], [contenteditable=true], [data-drawer-no-drag]"; function useDrag(direction: Direction, close: () => void) { const panel = React.useRef(null); const drag = React.useRef<{ id: number; start: number; at: number; time: number } | null>(null); const { axis, sign } = AXIS[direction]; const translate = (px: number) => (axis === "y" ? `translateY(${px}px)` : `translateX(${px}px)`); const onPointerDown = (event: React.PointerEvent) => { if (drag.current || event.button !== 0) return; // one pointer only if ((event.target as Element).closest(SKIP)) return; if (window.getSelection()?.toString()) return; const start = axis === "y" ? event.clientY : event.clientX; drag.current = { id: event.pointerId, start, at: 0, time: performance.now() }; event.currentTarget.setPointerCapture(event.pointerId); if (panel.current) panel.current.style.transition = "none"; }; const onPointerMove = (event: React.PointerEvent) => { const d = drag.current; if (!d || d.id !== event.pointerId || !panel.current) return; const raw = (axis === "y" ? event.clientY : event.clientX) - d.start; const toward = raw * sign; // Toward the edge it follows the pointer; away from it, resistance grows with distance. const moved = toward >= 0 ? toward : -Math.sqrt(-toward) * 2; d.at = moved; panel.current.style.transform = translate(moved * sign); }; const end = (event: React.PointerEvent) => { const d = drag.current; if (!d || d.id !== event.pointerId || !panel.current) return; drag.current = null; const el = panel.current; const size = axis === "y" ? el.offsetHeight : el.offsetWidth; const velocity = Math.abs(d.at) / Math.max(1, performance.now() - d.time); if (d.at > 0 && (d.at > size / 3 || velocity > VELOCITY)) { el.style.transition = ""; close(); return; } el.style.transition = "transform 500ms var(--ds-easing-drawer)"; el.style.transform = ""; const reset = () => { el.style.transition = ""; el.removeEventListener("transitionend", reset); }; el.addEventListener("transitionend", reset); }; return { panel, handlers: { onPointerDown, onPointerMove, onPointerUp: end, onPointerCancel: end } }; } function DrawerContent({ className, children, ...props }: React.ComponentProps) { const { direction, setOpen } = React.useContext(DrawerContext); const { panel, handlers } = useDrag(direction, () => setOpen(false)); const vertical = direction === "bottom" || direction === "top"; return (
{children}
); } function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) { return
; } function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) { return
; } function DrawerTitle({ className, ...props }: React.ComponentProps) { return ; } function DrawerDescription({ className, ...props }: React.ComponentProps) { return ; } export { Drawer, DrawerClose, DrawerContent, DrawerDescription, DrawerFooter, DrawerHeader, DrawerOverlay, DrawerPortal, DrawerTitle, DrawerTrigger }; ``` # Dropdown Menu A raised white menu of 36px rows with filled glyphs, lilac highlight, purple section labels and checks. Install: npx inkbox-ds add dropdown-menu Requires: theme, surface, utils Packages: @chakra-ui/react, @phosphor-icons/react Exports: DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuPortal, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger Client boundary: yes Reference: Extrapolated from the Inkbox console (Figma 427:2715): nav rows (38px, 8px corners, filled glyphs, lilac current row), section labels (Channels), the raised panel ## Usage Use for actions on one thing behind a single control (an agent's row actions, the account menu), and for small option sets with DropdownMenuCheckboxItem or DropdownMenuRadioItem. Mark a destructive action with variant="destructive" and keep it last, after a separator. - Avoid: Navigation between pages in a dropdown - Avoid: More than one level of submenu - Avoid: Animating the highlighted row - Avoid: Icons that move on hover ## Examples Example components: npx inkbox-ds add dropdown-menu button Example packages: @phosphor-icons/react [Preview](https://inkbox-ds.vercel.app/preview/dropdown-menu/Preview) | [Open](https://inkbox-ds.vercel.app/preview/dropdown-menu/Open) | [RowActions](https://inkbox-ds.vercel.app/preview/dropdown-menu/RowActions) | [Options](https://inkbox-ds.vercel.app/preview/dropdown-menu/Options) examples/dropdown-menu.tsx ```tsx "use client"; import * as React from "react"; import { BuildingsIcon, DotsThreeIcon, GearSixIcon, KeyIcon, PencilSimpleIcon, QuestionIcon, SignOutIcon, TrashIcon, UsersThreeIcon } from "@phosphor-icons/react/ssr"; import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; function AccountItems() { return ( <> Robert Ligthart My Organisation ⇧⌘O API Keys ⌘K Invite people By email Copy invite link Settings Help Billing (admins only) Log out ); } export function Preview() { return (
); } export function Open() { return (
); } export function RowActions() { return (
Edit agent Rotate API key Delete agent
); } export function Options() { const [channels, setChannels] = React.useState({ email: true, phone: true, imessage: false }); const [mode, setMode] = React.useState("edge"); return (
Channels setChannels((c) => ({ ...c, email: v === true }))}> Email setChannels((c) => ({ ...c, phone: v === true }))}> Phone setChannels((c) => ({ ...c, imessage: v === true }))}> iMessage Tunnel TLS mode Edge (default) Passthrough
); } ``` ## Source components/ui/dropdown-menu.tsx ```tsx "use client"; import * as React from "react"; import { Menu as ChakraMenu, Portal } from "@chakra-ui/react"; import { CaretRightIcon, CheckIcon } from "@phosphor-icons/react/ssr"; import { usePortalContainer } from "@/lib/surface"; import { cn } from "@/lib/utils"; // A menu, extrapolated from the console (Figma 427:2715): the panel is a raised white panel (16px corners, the floating // shadow) with 4px of padding; items are 36px rows with fully rounded corners like the rail's nav rows, a 20px glyph in the // action-glyph purple 10px from a 14px ink label, and the lilac under the highlighted row. Section labels are 13px in // the label purple ("Channels"), separators the rail's hairline, shortcuts 12px muted, destructive items red. The // panel grows out of its trigger from 96 percent with a fade; highlighting a row is instant (menus are used by keyboard // and many times a day). const CONTENT = "z-50 max-h-(--available-height) min-w-48 overflow-x-hidden overflow-y-auto rounded-panel bg-surface p-1 text-fg shadow-floating outline-none data-[state=closed]:animate-fade-out data-[state=open]:animate-fade-in"; const ITEM = "relative flex h-9 cursor-default items-center gap-2.5 rounded-pill px-2.5 font-sans text-body leading-tight text-fg outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[highlighted]:bg-subtle data-[inset]:pl-10.5 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-5.5 [&_svg:not([class*='text-'])]:text-icon-secondary"; // The trigger, so focus can go back to it when the menu closes. const TriggerRef = React.createContext | null>(null); type MenuProps = Omit, "onOpenChange"> & { onOpenChange?: (open: boolean) => void }; function DropdownMenu({ onOpenChange, onExitComplete, ...props }: MenuProps) { const trigger = React.useRef(null); const restore = React.useCallback(() => { if (document.activeElement === document.body || document.activeElement === null) trigger.current?.focus(); }, []); return ( { onOpenChange?.(details.open); // Closing right after a keyboard open can leave focus on the page body (the menu's own first-item focus lands // in the panel as it leaves). Hand it back to the trigger then; focus moved on purpose (a dialog an item opened) // is left alone. if (!details.open) requestAnimationFrame(() => requestAnimationFrame(restore)); }} // And again once the panel has left: its unmount can drop focus after the close. onExitComplete={() => { onExitComplete?.(); restore(); }} {...props} /> ); } function DropdownMenuPortal(props: React.ComponentProps) { const container = usePortalContainer(); const portalRef = React.useMemo(() => container ? { current: container } : undefined, [container]); return ; } function DropdownMenuTrigger({ ref, ...props }: React.ComponentProps) { const trigger = React.useContext(TriggerRef); const setRef = React.useCallback( (node: HTMLButtonElement | null) => { if (trigger) trigger.current = node; if (typeof ref === "function") ref(node); else if (ref) ref.current = node; }, [trigger, ref], ); return ; } function DropdownMenuContent({ className, sideOffset = 6, align, onFocus, ...props }: Omit, "align"> & { sideOffset?: number; align?: "start" | "center" | "end" }) { const trigger = React.useContext(TriggerRef); return ( { onFocus?.(event); // A keyboard open focuses the panel a moment later; if the menu closed first (a quick Escape), that focus // would land in a panel that is leaving. Send it back to the trigger. if (event.currentTarget.getAttribute("data-state") === "closed") trigger?.current?.focus(); }} {...props} /> ); } function DropdownMenuGroup(props: React.ComponentProps) { return ; } function DropdownMenuItem({ className, inset, variant = "default", value, children, ...props }: Omit, "value"> & { value?: string; inset?: boolean; variant?: "default" | "destructive" }) { return ( {children} ); } function DropdownMenuCheckboxItem({ className, children, checked, onCheckedChange, value, ...props }: Omit, "onCheckedChange" | "value"> & { value?: string; onCheckedChange?: (checked: boolean) => void }) { return ( {children} ); } type RadioMenuProps = Omit, "onValueChange"> & { onValueChange?: (value: string) => void }; function DropdownMenuRadioGroup({ onValueChange, ...props }: RadioMenuProps) { return onValueChange?.(details.value)} {...props} />; } // The selected choice wears the console's radio mark: a pink ring around white. function DropdownMenuRadioItem({ className, children, value, ...props }: Omit, "value"> & { value?: string }) { return ( {children} ); } // A heading above items: shadcn's label stands alone, so it is a plain element rather than Chakra's group label (which // must sit inside a group). function DropdownMenuLabel({ className, inset, ...props }: React.ComponentProps<"div"> & { inset?: boolean }) { return
; } function DropdownMenuSeparator({ className, ...props }: React.ComponentProps) { return ; } function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) { return ; } function DropdownMenuSub({ onOpenChange, ...props }: MenuProps) { return onOpenChange?.(details.open)} {...props} />; } function DropdownMenuSubTrigger({ className, inset, children, ...props }: React.ComponentProps & { inset?: boolean }) { return ( {children} ); } function DropdownMenuSubContent({ className, ...props }: React.ComponentProps) { return ( ); } export { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuPortal, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, }; ``` # Empty The centered empty state: the 3D octopus under an arc of glyph circles, a 22px bold title, a short description and a purple pill. Install: npx inkbox-ds add empty Requires: theme, utils Packages: class-variance-authority Exports: Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMascot, EmptyMedia, EmptyOrbit, EmptyTitle Client boundary: not required by this entry file Reference: Figma 427:2715: Dashboard - empty state (Create Your First Agent), Agent Workspace - Inbox - Not selected (No email selected) ## Usage Use where a list or pane has nothing yet (Create Your First Agent, No email selected). Put EmptyOrbit (up to five filled glyphs of what the area holds) and EmptyMascot in an EmptyMedia variant="mascot", then the title, a one-sentence description and one default Button with a plus. For a smaller pane, use EmptyMedia variant="icon" with one glyph. - Avoid: More than one action - Avoid: Animating the orbit - Avoid: A title longer than five words - Avoid: Gray stock illustrations ## Examples Example components: npx inkbox-ds add empty button Example packages: @phosphor-icons/react [Preview](https://inkbox-ds.vercel.app/preview/empty/Preview) | [NoSelection](https://inkbox-ds.vercel.app/preview/empty/NoSelection) | [Icon](https://inkbox-ds.vercel.app/preview/empty/Icon) examples/empty.tsx ```tsx import { ArrowsHorizontalIcon, ChatCircleIcon, EnvelopeIcon, EnvelopeOpenIcon, EnvelopeSimpleIcon, PaperPlaneTiltIcon, PhoneIcon, PlusIcon, TextAlignLeftIcon, TrayIcon, VaultIcon } from "@phosphor-icons/react/ssr"; import { Button } from "@/components/ui/button"; import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMascot, EmptyMedia, EmptyOrbit, EmptyTitle } from "@/components/ui/empty"; export function Preview() { return ( Create Your First Agent Set up an identity to unlock mailbox, phone, and vault features ); } export function NoSelection() { return ( No email selected Select an email from the list to read it ); } export function Icon() { return ( No emails to display New mail for @acme lands here. ); } ``` ## Source components/ui/empty.tsx ```tsx import * as React from "react"; import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "@/lib/utils"; // The console's empty states (Figma 427:2715: Create Your First Agent, No email selected). Centered: the media, a 22px // bold ink title, a 14px description on 24px lines in the secondary purple, at most 248px wide, then the action (a purple pill). The // `mascot` media is the 3D octopus with a soft shadow under it; EmptyOrbit arcs up to five 40px white circles holding // the channel glyphs above it. Nothing moves: the empty state is seen every time the page loads. function Empty({ className, ...props }: React.ComponentProps<"div">) { return
; } function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) { return
; } const emptyMediaVariants = cva("flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0", { variants: { variant: { default: "bg-transparent", icon: "size-10 rounded-pill border border-line-subtle bg-surface text-icon-secondary shadow-line [&_svg:not([class*='size-'])]:size-5.5", mascot: "relative mb-1 w-56 flex-col", }, }, defaultVariants: { variant: "default" }, }); function EmptyMedia({ className, variant = "default", ...props }: React.ComponentProps<"div"> & VariantProps) { return
; } // Where each circle sits on the arc over the mascot, outermost first: left offset and top, in px of a 224px box. const ORBIT = [ [0, 32], [44, 12], [92, 0], [140, 12], [184, 32], ] as const; /** Up to five glyphs in white circles, arced over the mascot (email, phone, iMessage, agent to agent, vault). */ function EmptyOrbit({ className, children, ...props }: React.ComponentProps<"div">) { const glyphs = React.Children.toArray(children).slice(0, 5); const start = Math.floor((5 - glyphs.length) / 2); return (
{glyphs.map((glyph, i) => { const [left, top] = ORBIT[start + i]; return ( {glyph} ); })}
); } /** The 3D octopus, standing on a soft shadow. Pass its image as src (the brand's octopus.png). */ function EmptyMascot({ className, alt = "", ...props }: React.ComponentProps<"img">) { return ( {/* eslint-disable-next-line @next/next/no-img-element */} {alt} ); } function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) { return
; } function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) { return

a]:text-fg-brand [&>a]:underline [&>a]:underline-offset-4", className)} {...props} />; } function EmptyContent({ className, ...props }: React.ComponentProps<"div">) { return

; } export { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMascot, EmptyMedia, EmptyOrbit, EmptyTitle }; ``` # Field A field's label, control, description and error stacked 4px apart, fields 12px apart, with the 'or' hairline between two ways in. Install: npx inkbox-ds add field Requires: theme, label, utils Packages: class-variance-authority Exports: Field, FieldContent, FieldDescription, FieldError, FieldGroup, FieldLabel, FieldLegend, FieldSeparator, FieldSet, FieldTitle Client boundary: yes Reference: Figma 427:2715: Login (Email address, Password, Forgot password?, or), Vault (Vault key and its description), Create Agent Identity (is not available, Will be displayed as) ## Usage Wrap every form control in Field with a FieldLabel above it; add FieldDescription for a hint (Enter a vault key or generate a random one) and FieldError, with data-invalid on the Field and aria-invalid on the control, when the value is rejected. Group fields in FieldGroup, related choices in FieldSet with a FieldLegend. Put an action beside the label (Forgot password?) in a row with justify-between. Use orientation horizontal for a label beside its control, and FieldSeparator with "or" between a form and other ways in. - Avoid: A red label on an invalid field - Avoid: A placeholder as the only label - Avoid: Errors larger than 12px - Avoid: Fields closer than 12px ## Examples Example components: npx inkbox-ds add field button input [Preview](https://inkbox-ds.vercel.app/preview/field/Preview) | [Description](https://inkbox-ds.vercel.app/preview/field/Description) | [Invalid](https://inkbox-ds.vercel.app/preview/field/Invalid) | [Horizontal](https://inkbox-ds.vercel.app/preview/field/Horizontal) | [Fieldset](https://inkbox-ds.vercel.app/preview/field/Fieldset) examples/field.tsx ```tsx import { Button } from "@/components/ui/button"; import { Field, FieldContent, FieldDescription, FieldError, FieldGroup, FieldLabel, FieldLegend, FieldSeparator, FieldSet, FieldTitle } from "@/components/ui/field"; import { Input } from "@/components/ui/input"; export function Preview() { return (
Email address or
); } export function Description() { return ( Vault key Enter a vault key or generate a random one. You'll need this to unlock the vault. ); } export function Invalid() { return ( Agent handle
Will be displayed as @acme-support (is not available)
); } export function Horizontal() { return ( Retention Days to keep delivered messages before they are deleted. Display name ); } export function Fieldset() { return (
Webhook destination Inkbox signs every delivery with the key for this agent identity. Endpoint URL Signing secret
); } ``` ## Source components/ui/field.tsx ```tsx "use client"; import * as React from "react"; import { cva, type VariantProps } from "class-variance-authority"; import { Label } from "@/components/ui/label"; import { cn } from "@/lib/utils"; // A form's fields as the console lays them out (Figma 427:2715, Login, Vault, Create Agent Identity). A field is its // 14px label on a 24px line, 4px above the control; fields sit 12px apart. A description under it is 14px muted // (Vault key: "Enter a vault key or generate a random one"); an error is 12px red (is not available). "or" between two // ways in is 14px muted on a hairline. A field marked data-invalid keeps its label ink: only the control and the // message turn red. function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) { return
[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3", className)} {...props} />; } function FieldLegend({ className, variant = "legend", ...props }: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) { return ( ); } function FieldGroup({ className, ...props }: React.ComponentProps<"div">) { return
[data-slot=field-group]]:gap-4", className)} {...props} />; } const fieldVariants = cva("group/field flex w-full gap-1 data-[disabled=true]:opacity-50", { variants: { orientation: { // Label over control (Email address over its field). vertical: "flex-col [&>*]:w-full [&>.sr-only]:w-auto", // Label beside its control (a channel row and its switch, a checkbox and its label). horizontal: "flex-row items-center gap-3 [&>[data-slot=field-label]]:flex-auto has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],&>[role=radio]]:mt-0.5", // Over on a narrow form, beside from 28rem of container. responsive: "flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:gap-3 @md/field-group:[&>*]:w-auto @md/field-group:[&>[data-slot=field-label]]:flex-auto", }, }, defaultVariants: { orientation: "vertical" }, }); function Field({ className, orientation = "vertical", ...props }: React.ComponentProps<"div"> & VariantProps) { return
; } function FieldContent({ className, ...props }: React.ComponentProps<"div">) { return
; } // A label that wraps a whole field becomes a choice card: the field edge and 8px corners, 14px in; the checked one takes // the purple edge on the lilac. function FieldLabel({ className, ...props }: React.ComponentProps) { return (