# urbanflow design language (Flow) Flow is urbanflow's design system: a quiet, dense, monochrome interface for working on buildings, floors and zones, where colour is kept for meaning. This file is the human-written part of https://components.urbanflow.co/llms.txt; the token, component and icon lists below it are generated from the code. ## Principles 1. **Colour means something.** The UI is greyscale. Hue only appears for status (info, success, caution, critical), for AI (magic purple), and for keying data (Misc colours on series, zones, categories and avatars). Never use colour for decoration. 2. **One ink.** All text and icons are one ink, `#202020`, at different strengths. Choose the strength by importance, not by picking greys: `texticons-emphasis` (100%) › `primary` (80%, default body) › `secondary` (70%, supporting text) › `tertiary` (50%) › `placeholder` (40%, placeholders and the default icon colour) › `disabled` (30%). Icons at 40% are below 3:1, so an icon-only control always needs a label (tooltip / `aria-label`), and a selected or primary control uses its own ink. On dark surfaces use `texticons-inverse-*`. 3. **Black is the primary action.** The main call to action is `background-inverse` (near-black) with inverse text. Use one primary action per view. 4. **Dense, tool-like UI.** Controls are small. Buttons and icon buttons are 24 / 28 / 32px (micro / medium / large, default medium), text fields are 32px and selects are 36px. Labels on controls are `label-xsmall` (12px semibold), and supporting copy is `paragraph-small` / `paragraph-xsmall`. 5. **Soft shapes.** - `rounded-md` (8px) for controls. - `rounded-lg` (12px) for cards and modals. - `rounded-full` for pills, chips, avatars and pins. - `rounded-sm` / `rounded-xs` for small inner parts. 6. **Elevation is information.** - `shadow-xs` / `shadow-sm` for resting cards. - `shadow-md` for menus and popovers. - `shadow-lg` for modals. - `shadow-float` for bars floating over the canvas. Every interactive element shows `shadow-focus` (a 2px info-blue ring) on keyboard focus. 7. **Warm, quiet structure.** Surfaces are white (`background-default`) with light greys (`background-light` for fills, `background-medium` for hover and pressed). Borders are warm greys: `stroke-subtle` › `stroke-default` › `stroke-strong`, and `stroke-selected` for the selected item. ## App shell Every product (urbanflow, Flux, and the next one) uses the same `AppShell`, the way Figma Design, Slides and FigJam share one chrome: a 44px dark `TopNavigation`, then the canvas full-bleed with a left panel, floating canvas tools and a right panel on top of it, 8px inset so the model stays visible. Products only change what fills each region. Product modes and tools live in the `UtilitiesToolbar` pinned top-right of the canvas, never as tabs in the top bar. Both side panels open at a fixed default width every session (left urbanflow 252px / Flux 360px, right 359px / 300px) and the user can drag their inner edge to resize them, with no maximum. The two panels always keep a 24px gap: growing one squeezes the other, and a panel dragged to 0 closes, leaving a small reopen tab on that edge (click to reopen at the default width, or drag it out). With one panel closed, the other stops 24px from the far edge so the tab stays reachable. Floating canvas tools hide when there isn't room. Never save the widths; the next session starts at the defaults again. The urbanflow mark is at `/brand/urbanflow-mark.svg` (ink) and `/brand/urbanflow-mark-white.svg` (for the dark top bar). Pass it to `TopNavigation` as `logo`; never redraw, recolour beyond these two, or put it in a box. ## Type Figtree for everything; DM Mono only for numbers that are read as data. | Style | Use | |---|---| | `text-header-*` (xxlarge 32 → xxsmall 12, semibold) | Page, section and card titles | | `text-paragraph-*` (large 18 → xsmall 12) | Body and supporting copy | | `text-label-*` (large 18 → xsmall 12, semibold) | Labels on controls, tabs and table headers | | `text-metric-*` + `font-mono` | Key figures, measurements, counts in analysis results | ## Button hierarchy Pick the button by emphasis; the tone says what kind of action it is. | Button | When | |---|---| | `primary` | The most important action to move forward, or to finish or dismiss a task. Highest emphasis; full width when it stands alone. | | `primary` + `tone="success"` | Confirm or submit. | | `primary` + `tone="critical"` | Irreversible actions such as Delete. | | `secondary` | Supporting actions next to a primary, or an alternative (Cancel, optional steps). | | `secondary` + `tone="critical"` | Less destructive but still cautionary actions. | | `tertiary` (ghost) | Low emphasis, optional or navigational (Skip, Learn more). No fill. | | `plain` (text link) | Inline in copy (View more). Can be a low-emphasis critical action. | | `IconButtonGroup` | Frequent actions on the canvas, horizontal or vertical. | | `HybridButtonGroup` | The current canvas mode, its object and one action, in one floating bar. | ## Rules for building urbanflow UI - **Use the components first.** Install from the registry (`npx shadcn add @urbanflow/`) before building anything by hand, and compose them as the preview at components.urbanflow.co shows. - **Only Flow classes work.** Tailwind's default colours, font sizes, shadows and radii are switched off, so `bg-white`, `text-sm`, `shadow-2xl` or `bg-blue-500` do nothing. Use the token classes below. - **No hex, no arbitrary values** for colour, type, radius or shadow. If something is missing, it's a design question, not a code one. - **Express state with tones and props**, not colours: `tone="critical"`, `variant="secondary"`, `aria-pressed`, `disabled`, `loading`. - **Icons come from `@urbanflow/icons`** (not Lucide). They're drawn for 14 / 16 / 20px, inherit text colour, and default to `texticons-secondary` next to text. - **Components marked ↪** belong to one product area (Urbanflow canvas, Compliance, Comments, Files). Use them for that area, not as general-purpose building blocks. ## Setup (Next.js or Vite, Tailwind v4) 1. `npm i @urbanflow/theme @urbanflow/icons` 2. In the main CSS file: `@import "tailwindcss"; @import "@urbanflow/theme";` (replace shadcn's generated colour blocks). 3. Load the fonts: Next.js `next/font/google` `Figtree({ variable: "--font-figtree" })` and `DM_Mono({ variable: "--font-dm-mono", weight: "400" })` on ``; Vite `@fontsource/figtree` (400, 600) and `@fontsource/dm-mono` (400). 4. In `components.json`: `"registries": { "@urbanflow": "https://components.urbanflow.co/r/{name}.json" }`, then `npx shadcn add @urbanflow/utils` (Flow-aware `cn()`). 5. Add components with `npx shadcn add @urbanflow/`. The code is copied into the app and can be edited. It lands at your `components.json` `ui` alias (usually `@/components/ui/`) and its imports are rewritten to match, so import from there. The examples below use this repo's `@/registry/ui/`; swap in your alias. ## Components Each item's full source, props and dependencies: `https://components.urbanflow.co/r/.json`. Live examples of every variant: https://components.urbanflow.co - [cn helper](https://components.urbanflow.co/r/utils.json) `@urbanflow/utils`: cn() that understands Flow text styles and shadows. Replaces shadcn's lib/utils. - [Button](https://components.urbanflow.co/r/button.json) `@urbanflow/button`: urbanflow Button: primary / secondary / tertiary / plain × default / critical / success tones, micro / medium / large and icon sizes, loading and pressed states. - [Icon Button](https://components.urbanflow.co/r/icon-button.json) `@urbanflow/icon-button`: urbanflow Icon Button and Icon Button Group: square icon-only actions (24 / 28 / 32px) and the floating toolbar cluster. - [Badge](https://components.urbanflow.co/r/badge.json) `@urbanflow/badge`: urbanflow Badge: short status, label or count. default / info / critical / success (done, passed) / caution (warning, needs attention) × label / number / small-number. - [Checkbox](https://components.urbanflow.co/r/checkbox.json) `@urbanflow/checkbox`: urbanflow Checkbox: 16px box or eye (layer visibility), checked / indeterminate, optional inline label. - [Toggle](https://components.urbanflow.co/r/toggle.json) `@urbanflow/toggle`: urbanflow Toggle: 36×21 on/off switch (Radix Switch) with optional label. - [Tooltip](https://components.urbanflow.co/r/tooltip.json) `@urbanflow/tooltip`: urbanflow Tooltip: dark label with a tail on hover or focus. - [Field](https://components.urbanflow.co/r/field.json) `@urbanflow/field`: urbanflow Field shell (label, help text, error / alert) and Inline Error, shared by Text Field, Multiline Field and Select. - [Text Field](https://components.urbanflow.co/r/text-field.json) `@urbanflow/text-field`: urbanflow Text Field: 32px input with label, unit, prefix / suffix icon, number stepper, help text, error / alert, disabled, read only, inline label. - [Multiline Field](https://components.urbanflow.co/r/multiline-field.json) `@urbanflow/multiline-field`: urbanflow Multiline Field: textarea with label, character count, help text and error / alert. - [Select](https://components.urbanflow.co/r/select.json) `@urbanflow/select`: urbanflow Select: 36px trigger with chevron opening an Action List of options; label default / inline / hidden. - [Tabs](https://components.urbanflow.co/r/tabs.json) `@urbanflow/tabs`: urbanflow Tabs: 28px pill tabs (Flow Tab) or dense underline tabs (Flow Underline Tab) via TabsList variant. - [Segmented Control](https://components.urbanflow.co/r/segmented-control.json) `@urbanflow/segmented-control`: urbanflow Segmented Control: 2–4 mutually exclusive options, text or icon. - [Page Header](https://components.urbanflow.co/r/page-header.json) `@urbanflow/page-header`: urbanflow Page Header: back, title with badge or leading mark, description, actions. - [Full Screen Modal Header](https://components.urbanflow.co/r/full-screen-modal-header.json) `@urbanflow/full-screen-modal-header`: urbanflow Full Screen Modal Header: 56px title bar with back, leading mark, tabs, actions and close. - [Action List](https://components.urbanflow.co/r/action-list.json) `@urbanflow/action-list`: urbanflow Action List: static ActionList / ActionSection / ActionItem, and DropdownMenu built on the same styles (icon, help text, destructive, selected, disabled), with DropdownMenuRadioGroup / DropdownMenuRadioItem for single-choice menus (checked item gets the medium fill and a check). - [Modal](https://components.urbanflow.co/r/modal.json) `@urbanflow/modal`: urbanflow Modal over the Dimmer: grey title bar, body, right-aligned footer; default 620px or small 360px. - [Toast](https://components.urbanflow.co/r/toast.json) `@urbanflow/toast`: urbanflow Toast: dark pill (or critical red) with optional action and dismiss; auto-dismisses after 4s. - [Banner](https://components.urbanflow.co/r/banner.json) `@urbanflow/banner`: urbanflow Banner: info / success / warning / critical, as a card with a tinted icon tile or inCard on a tinted ground; title, description, actions, dismiss. - [Color Indicator](https://components.urbanflow.co/r/color-indicator.json) `@urbanflow/color-indicator`: urbanflow Color Indicator: 14px (or 8px small) swatch keying a series, zone or category to a Misc colour. - [Avatar](https://components.urbanflow.co/r/avatar.json) `@urbanflow/avatar`: urbanflow Avatar (L 28 · M 24 · S 20 · XS 18, initials on a Misc colour or a photo) and Avatars Stacked (+N after max). - [Chip Filter](https://components.urbanflow.co/r/chip-filter.json) `@urbanflow/chip-filter`: urbanflow Chip Filter: pill toggle for filtering a list; selected inverts. - [Dropdown Pill](https://components.urbanflow.co/r/dropdown-pill.json) `@urbanflow/dropdown-pill`: urbanflow Dropdown Pill: compact pill that opens a menu to set a category inline; empty reads Unclassified. - [Scroll Indicator](https://components.urbanflow.co/r/scroll-indicator.json) `@urbanflow/scroll-indicator`: urbanflow Scroll Indicator (5px light or dark thumb) and ScrollArea that uses it. - [Thumbnail](https://components.urbanflow.co/r/thumbnail.json) `@urbanflow/thumbnail`: ↪ Urbanflow Thumbnail: render, plan or file tile (xsmall 24 · small 40 · medium 60 · large 80) with a placeholder glyph. - [Resize Handle & Panel Reopen Tab](https://components.urbanflow.co/r/resize-handle.json) `@urbanflow/resize-handle`: Shell Resize Handle (the 12px drag edge of a resizable panel: invisible at rest, stroke-strong on hover, info-primary while dragging or focused) and Panel Reopen Tab (20×56 tab flush with the canvas edge when a panel is closed; click reopens at the default width, drag pulls it out). Used by AppShell. - [App Shell](https://components.urbanflow.co/r/app-shell.json) `@urbanflow/app-shell`: Shell App Shell: shared chrome for every urbanflow product (urbanflow, Flux). A top bar, then a canvas full-bleed behind a left panel, floating canvas tools (AppShellPin top-right / bottom-centre / bottom-right) and a right panel, 8px inset. The shell owns both panel widths: they open at the product defaults every session (left urbanflow 252 / Flux 360, right 359 / 300), resize by dragging their inner edge with no maximum, always keep a 24px gap (growing one squeezes the other), close when dragged to 0 with a reopen tab on that edge, and are never saved. Canvas tools hide when there isn't room. Products fill the slots; modes and tools go on the canvas, never as tabs in the top bar. - [Top Navigation](https://components.urbanflow.co/r/top-navigation.json) `@urbanflow/top-navigation`: Shell Top Navigation: 44px dark bar with the product logo and the urbanflow TopNav project bar (project switcher menu, open proposal and its status, notifications, collaborators, credits). product="flux" hides notifications and collaborators. - [Proposal Badge](https://components.urbanflow.co/r/proposal-badge.json) `@urbanflow/proposal-badge`: Shell Proposal Badge and Proposal Title: proposal status (Draft, Behind main, In review, Changes suggested, Ready for merge, Merged, Closed shown as Archived) and the proposal name with its badges on the dark top bar. - [Utilities Toolbar](https://components.urbanflow.co/r/utilities-toolbar.json) `@urbanflow/utilities-toolbar`: Shell Utilities Toolbar: floating rail of large icon buttons pinned top-right of the canvas. urbanflow puts its analysis tools here; Flux puts its modes (Model, HVAC, Weather, Runs, Results, Files), one selected at a time. - [Left Context Panel](https://components.urbanflow.co/r/left-context-panel.json) `@urbanflow/left-context-panel`: Shell Left Context Panel: the AppShell's left panel. product="urbanflow" (252px: page switcher menu, optional tabs, scrolling Building / Floor / Zone sections) or product="flux" (360px: children fill the panel, e.g. the chat). Inside AppShell it fills the width the shell gives it (resizable, collapsible); on its own it uses its default width. - [Hybrid Button Group](https://components.urbanflow.co/r/hybrid-button-group.json) `@urbanflow/hybrid-button-group`: ↪ Urbanflow Hybrid Button Group: floating canvas bar with the current mode, its object and one action. - [Building Select](https://components.urbanflow.co/r/building-select.json) `@urbanflow/building-select`: ↪ Urbanflow Building Select (24px building / floor / zone tree row with connectors, caret, count, add and more; `depth` for trees deeper than three levels, `trailing` for row details such as an area) and Building Header. - [Output Select](https://components.urbanflow.co/r/output-select.json) `@urbanflow/output-select`: ↪ Urbanflow Output Select: selectable analysis-run card with thumbnail, meta, badges and key metrics. - [Template Select](https://components.urbanflow.co/r/template-select.json) `@urbanflow/template-select`: ↪ Urbanflow Template Select: 116px floor-plate template tile. - [Policy](https://components.urbanflow.co/r/policy.json) `@urbanflow/policy`: ↪ Compliance Policy: collapsible rule reference with source link and quoted clause. - [Compliance Issue](https://components.urbanflow.co/r/compliance-issue.json) `@urbanflow/compliance-issue`: ↪ Compliance Issue: result card with a 2px pass/fail rail; opens to a Required / Actual table, a Policy and a Dismiss footer. - [Photo](https://components.urbanflow.co/r/photo.json) `@urbanflow/photo`: ↪ Comments Photo: image attachment tile (S 60px or L 120×90), selected ring, +N overflow, optional remove. - [Comment Pin](https://components.urbanflow.co/r/comment-pin.json) `@urbanflow/comment-pin`: ↪ Comments Comment Pin: 32px canvas pin; read, unread, selected, resolved, typing; stacked avatars or a group count. - [Comment Thread](https://components.urbanflow.co/r/comment-thread.json) `@urbanflow/comment-thread`: ↪ Comments Comment Thread: panel row with location, author, time, first message and replies; unread dot; more and resolve on hover. - [Comment Section](https://components.urbanflow.co/r/comment-section.json) `@urbanflow/comment-section`: ↪ Comments Comment Section (panel header with filter menu and close) and Comment Search (search field with empty-state guidance or no-match message). - [View Comment](https://components.urbanflow.co/r/view-comment.json) `@urbanflow/view-comment`: ↪ Comments View Comment: open thread popover beside its pin — header actions, messages with photos and a reply composer. - [File Category](https://components.urbanflow.co/r/file-category.json) `@urbanflow/file-category`: ↪ Files File Category: tone-coded category pill that opens a menu to classify a file; empty reads Uncategorized. Also exports CategoryTag. - [File Actions](https://components.urbanflow.co/r/file-actions.json) `@urbanflow/file-actions`: ↪ Files File Actions: primary Actions button whose caret flips when its Action List menu opens. - [File Row](https://components.urbanflow.co/r/file-row.json) `@urbanflow/file-row`: ↪ Files File Row: file glyph, name + type, category, size, owner, date; hover swaps the date for actions; selected info ring. - [Select Floor Plan](https://components.urbanflow.co/r/select-floor-plan.json) `@urbanflow/select-floor-plan`: ↪ Files Select Floor Plan: row to upload a new floor plan or pick an existing file. - [Spinner](https://components.urbanflow.co/r/spinner.json) `@urbanflow/spinner`: urbanflow Spinner (Figma AILoader): 16px ring, a 90° arc turning every 800ms; static when reduced motion is on. role=status, aria-label defaults to Loading. - [Progress](https://components.urbanflow.co/r/progress.json) `@urbanflow/progress`: urbanflow Progress: 4px bar, background-medium track and texticons-primary fill (Radix Progress, value 0–100). - [Separator](https://components.urbanflow.co/r/separator.json) `@urbanflow/separator`: urbanflow Separator: 1px stroke-subtle divider, horizontal or vertical (Radix Separator). - [Hover Card](https://components.urbanflow.co/r/hover-card.json) `@urbanflow/hover-card`: urbanflow Hover Card: card that opens on hover or focus of its trigger (Radix HoverCard), white with shadow-md and a stroke-subtle edge. - [Command](https://components.urbanflow.co/r/command.json) `@urbanflow/command`: urbanflow Command: searchable list (cmdk) with Action List styling: input, groups, items, empty state, separator, shortcut. Use for pickers and palettes. - [Collapsible](https://components.urbanflow.co/r/collapsible.json) `@urbanflow/collapsible`: urbanflow Collapsible (Radix): Collapsible, CollapsibleTrigger (focus ring, pointer) and CollapsibleContent (collapsible-up / -down animation, none with reduced motion). Unstyled otherwise; the AI trace, reasoning and sources build their rows on it. - [AI Context Chip](https://components.urbanflow.co/r/ai-context-chip.json) `@urbanflow/ai-context-chip`: urbanflow AI Context Chip: an object or file attached to a prompt. status attached / uploading (spinner) / sent (read-only) / error (retry), icon, label, detail, onRemove, onRetry. AIContextSelectionChip takes a generic { kind, label, count } selection; AIContextChipAdd starts picking context. The app owns uploads and passes the status. - [AI Context Usage](https://components.urbanflow.co/r/ai-context-usage.json) `@urbanflow/ai-context-usage`: urbanflow AI Context Usage: 16px ring with % (from used / limit) that opens a hover card (context window %, bar, tokens used of limit, optional input, output, credits). AIContextUsageCard on its own. AIContextLimit shows the nearly-full banner (from nearlyFullAt, default 75%) and the full banner, with a New chat button only when onNewChat is passed. contextUsage() returns the same percent and state. - [AI Prompt Input](https://components.urbanflow.co/r/ai-prompt-input.json) `@urbanflow/ai-prompt-input`: urbanflow AI Prompt Input (AI Elements PromptInput on Flow): context chips row, auto-growing textarea (Enter sends, Shift+Enter new line, max 6 lines), toolbar with attach, mode / model / effort pills (a dropdown only when there are two or more options, a static label for one, hidden for none), context usage and Send / Stop (status ready / submitted / streaming / error, like useChat). Enter does not send while generating unless submitWhileGenerating is set. AIPromptInputPicker floats a Command list above it for @ and / pickers; give the textarea role=combobox, aria-expanded and aria-controls when you open one. Presentational: state and callbacks come from the app. AIPromptInputTools wrap puts the pills on more lines at narrow widths instead of scrolling them under a fade. - [AI Code Block](https://components.urbanflow.co/r/ai-code-block.json) `@urbanflow/ai-code-block`: urbanflow AI Code Block (Figma AICodeBlock): plain monospace block for code and error details in AI output, Background/Light, 12px radius, wraps long lines. No syntax highlighting. - [AI Trace](https://components.urbanflow.co/r/ai-trace.json) `@urbanflow/ai-trace`: urbanflow AI Trace (Figma AITrace · Chain of thought): the tool calls of one assistant turn as a single collapsible summary with one level of steps. steps: { id, name, label, status pending / running / done / error, detail?, input?, output?, attempt? }; toolPartStatus(part) maps an AI SDK tool part (state, preliminary) to a status. The summary reads Researching · Step n of N, Researched in N steps, Research stopped at step n (only while the failed step blocks the rest), Retrying / Retry failed (attempt > 1), or Stopped at step n when stopped is set after the user stops the reply; label overrides it. Opens while running or failed and closes otherwise, unless the user toggled it. A failed step opens to its detail (AICodeBlock) and Retry (onRetry). toolRenderers maps a tool name to a component ({ step }) drawn under that step at any status. - [AI Reasoning](https://components.urbanflow.co/r/ai-reasoning.json) `@urbanflow/ai-reasoning`: urbanflow AI Reasoning (Figma AITrace · Reasoning): Thinking with a spinner while status is running, Thought · 5s when complete (duration in seconds). Collapsed by default; children are the reasoning text (pass rendered markdown). - [AI Sources](https://components.urbanflow.co/r/ai-sources.json) `@urbanflow/ai-sources`: urbanflow AI Sources (Figma AISources): Used N sources, expanding to one row per source (icon, title, detail). sources: { id, title, detail?, href?, icon? }; parts.filter(isAISourcePart).map(toAISource) maps AI SDK source-url and source-document parts. onSelect makes rows buttons; otherwise rows with href open in a new tab. - [AI Citation Card](https://components.urbanflow.co/r/ai-citation-card.json) `@urbanflow/ai-citation-card`: urbanflow AI Citation (Figma AICitationCard): AICitation is an inline citation badge that previews a hover card on hover or focus; AICitationCard lays out the card: icon, title, description and an optional action such as a plain Open in Costflow button. The card can't be reached by keyboard or touch, so also give AICitation an onClick that opens the source. AICitations wraps a row of badges. - [AI Message](https://components.urbanflow.co/r/ai-message.json) `@urbanflow/ai-message`: urbanflow AI Message (Figma AIMessage): AIMessage from user (right-aligned bubble, put an AIContextChip with status sent above AIMessageContent) or assistant (no bubble). AIMessageContent takes the rendered body (e.g. a Streamdown element). AIMessageGenerating shows Thinking and skeleton lines; AIMessageActions holds AIMessageCopy (shows Copied for 2 s) and AIMessageRegenerate (latest reply only); AIMessageStopped and AIMessageError (connection lost, with Retry) end a reply that did not finish. - [AI Conversation](https://components.urbanflow.co/r/ai-conversation.json) `@urbanflow/ai-conversation`: urbanflow AI Conversation: the scrolling message list. Sticks to the latest message while you are at the bottom and stays put when you scroll up; AIConversationScrollButton (Figma Scroll to latest) shows only when scrolled up and smooth-scrolls back. Built on use-stick-to-bottom. Keeps following the latest message when the panel itself is resized. - [AI Approval Card](https://components.urbanflow.co/r/ai-approval-card.json) `@urbanflow/ai-approval-card`: urbanflow AI Approval Card (Flux's approval card on Flow; no Figma frame yet): asks before the agent changes something. title (the tool or the change, e.g. Edit model.epJSON), detail (one sentence on why), an optional body as children (an AIApprovalDiff per file, a script, a list of paths) and actions, an ordered list of { id, label, variant primary / secondary / tertiary, tone default / critical / success, title?, disabled?, onSelect } so each app picks its buttons (Approve and Deny; or Allow once, Allow in this project, Full auto, Deny). Set disabled while a decision is in flight. Buttons sit in an auto-fit grid that stacks them when the panel is narrow, and long labels wrap. status requesting / decided / denied / stopped: once answered the card shrinks to a one-line record (title · outcome, default You allowed this. / You declined this. / Stopped before this was answered. Nothing was changed.), with an Auto badge when auto is set on a decided card; actions can be left out there. A keyboard user who answers lands on the record. AIApprovalDiff draws one file's unified diff: path, new file, +added / -removed and coloured lines. Presentational: the app owns the gate and the decision. focusOnDecide (default true) moves focus to the card once it is decided; false leaves focus to the app. - [AI Question Card](https://components.urbanflow.co/r/ai-question-card.json) `@urbanflow/ai-question-card`: urbanflow AI Question Card (Flux's ask_user card on Flow; no Figma frame yet): the agent's questions inline in the chat. questions: { id, prompt, placeholder?, kind } with single { options, recommended?: string, allowOther? }, multi { options, recommended?: string[], allowOther? }, text { recommended?: string } or number { recommended?: number }; options are { value, label, description? }. title is the sentence on why it asks, image an optional { src, alt } above the questions. All questions show at once: single is a radio list, multi a checkbox list, text and number a field; allowOther adds one free-text field per question (typing in it clears a single choice). The starting answer is answers[id] when given, else recommended, which is also marked Recommended. Submit (onSubmit({ answers, skipped })) is disabled until something is answered; Enter in a text field or Ctrl/Cmd+Enter anywhere submits, while plain Enter on a choice does nothing. Skip shows when onSkip is set. status asking / timed-out (the form stays answerable under note, default The agent went ahead with the recommended answers. Answer now and it changes course.) / answered (each prompt with its answer label, or Skipped, from answers; a keyboard user lands on it). Key the card by request id so a new request starts fresh. The core app's ask_user (one question, 2-4 options, free text) is one single question with allowOther. Each question carries data-question-id, and text and number fields carry a name. focusOnDecide (default true) as on the approval card. - [AI Pending Banner](https://components.urbanflow.co/r/ai-pending-banner.json) `@urbanflow/ai-pending-banner`: urbanflow AI Pending Banner: the sticky Waiting for you bar above the composer while an approval or question card waits for an answer. children says what is waiting (e.g. Approve Edit model.epJSON); count shows a number badge above 1, and at 0 the bar is gone; onJump (jumpLabel, default Show) is where the app scrolls to the card and focuses it. Keep it mounted at count 0: a hidden status region stays in place so screen readers announce each new wait. Sticks to the bottom of its scroll container. actions adds controls before Show (e.g. Notify me). - [AI Message Queue](https://components.urbanflow.co/r/ai-message-queue.json) `@urbanflow/ai-message-queue`: urbanflow AI Message Queue: messages typed while the agent works, waiting above the composer. items: { id, text, editable? }; the header reads N queued, with an optional note (e.g. sent when the agent is free), and collapses the list (open / defaultOpen true / onOpenChange). Each row has Move up and Move down (onReorder gets the whole new id order), Edit (onEdit(id, text); a growing field keeps line breaks, Enter saves, Shift+Enter adds a line, Escape cancels, leaving it unchanged or empty keeps the old text), Send now (onSendNow) and Remove (onRemove); a control shows only when its callback is set, the row actions appear on hover, keyboard focus or touch, and focus moves to the next row when one leaves. Renders nothing when the queue is empty. onEditingChange(id or null) says when a row's editor opens and closes. ## Icons `import { SearchIcon } from "@urbanflow/icons"`, then `` (sizes 14 / 16 / 20, colour from `currentColor`). Inside `Button` and `IconButton` the icon is sized for you, so don't pass `size`. `AddIcon`, `AddBuildingIcon`, `AiIcon`, `AlertTriangleIcon`, `AnalyticsIcon`, `ArrowDownIcon`, `ArrowLeftIcon`, `ArrowReloadIcon`, `ArrowRightIcon`, `ArrowUpIcon`, `ArrowUpDownIcon`, `ArrowUpStraightIcon`, `ArrowsDirectionIcon`, `ArrowsMaximizeIcon`, `ArrowsMinimizeIcon`, `BlueprintIcon`, `BoxAddAboveIcon`, `BoxAddBelowIcon`, `BuildingIcon`, `BuildingFilledIcon`, `CarbonIcon`, `CheckIcon`, `CheckboxCheckedIcon`, `CheckboxIndeterminateIcon`, `CheckboxUncheckedIcon`, `ChevronDownIcon`, `ChevronLeftIcon`, `ChevronRightIcon`, `ChevronUpIcon`, `CloseIcon`, `CloudIcon`, `CoinIcon`, `ComplianceIcon`, `ConstructionIcon`, `CopyIcon`, `DateIcon`, `DownloadIcon`, `DrawWallIcon`, `EducationIcon`, `EllipsisHorizontalIcon`, `EyeClosedIcon`, `EyeDottedIcon`, `EyeOpenIcon`, `FilesIcon`, `FloorIcon`, `GlobeIcon`, `HammerIcon`, `HelpIcon`, `HvacIcon`, `InfoIcon`, `LayoutIcon`, `LightbulbIcon`, `ListIcon`, `MassingIcon`, `MessageIcon`, `MinusIcon`, `NoteIcon`, `ParameterIcon`, `PencilIcon`, `PhotoIcon`, `PlaceholderIcon`, `PoliciesIcon`, `RadioCheckedIcon`, `RadioUncheckedIcon`, `RunIcon`, `SearchIcon`, `SelectObjectIcon`, `SendIcon`, `SlopedIcon`, `SteppedIcon`, `StopIcon`, `TimeIcon`, `TokenIcon`, `TrashIcon`, `UploadIcon`, `VersionIcon`, `ZoneIcon`, `ZoneFilledIcon` ## Tokens ### Colours (`bg-*`, `text-*`, `border-*`, also `var(--color-*)`) - `neutral-0`: #ffffff - `neutral-1`: #fdfdfd - `neutral-2`: #fafafa - `neutral-3`: #f7f7f7 - `neutral-10`: #e3e3e3 - `neutral-20`: #cccccc - `neutral-30`: #b5b5b5 - `neutral-80`: #616161 - `neutral-90`: #4a4a4a - `neutral-100`: #303030 - `neutral-120`: #1a1a1a - `info-xxxlight`: #fcfdff - `info-xxlight`: #e2e7ff - `info-xlight`: #d5dcff - `info-light`: #c5d0ff - `info-primary`: #005bd3 - `info-heavy`: #004299 - `info-xheavy`: #002e6a - `success-xxxlight`: #f8fffb - `success-xxlight`: #cdfee1 - `success-light`: #29845a - `success-primary`: #136f45 - `success-heavy`: #0c5132 - `success-xheavy`: #083d25 - `success-xxheavy`: #092a1b - `caution-xxlight`: #fff1e3 - `caution-light`: #ffbd14 - `caution-primary`: #e6aa12 - `caution-heavy`: #332e00 - `caution-xheavy`: #1f1c00 - `critical-xxxlight`: #fffbfb - `critical-light`: #fee9e8 - `critical-primary`: #e51c00 - `critical-heavy`: #8e1f0b - `critical-xxheavy`: #5f1507 - `critical-xxxheavy`: #2f0a04 - `magic-xxxlight`: #fdfdff - `magic-primary`: #7126ff - `magic-heavy`: #5700d1 - `misc-red`: #ff383c - `misc-orange`: #ff8d28 - `misc-yellow`: #ffcc00 - `misc-green`: #34c759 - `misc-cyan`: #00c0e8 - `misc-indigo`: #cb30e0 - `misc-pink`: #ff2d55 - `misc-brown`: #ac7f5e - `background-default`: #ffffff (neutral-0) - `background-light`: #f7f7f7 (neutral-3) - `background-medium`: #efefef - `background-card`: #fdfdfa - `background-overlay`: #00000066 - `background-inverse`: #1a1a1a (neutral-120) - `background-inverse-secondary`: #4b4b48 - `background-info`: #e2e7ff (info-xxlight) - `background-critical`: #fee9e8 (critical-light) - `background-caution`: #fff1e3 (caution-xxlight) - `background-success`: #cdfee1 (success-xxlight) - `texticons-emphasis`: #202020 - `texticons-primary`: #202020cc - `texticons-secondary`: #202020b2 - `texticons-tertiary`: #20202080 - `texticons-placeholder`: #20202066 - `texticons-disabled`: #2020204d - `texticons-inverse-primary`: #ffffff - `texticons-inverse-secondary`: #ffffffb2 - `texticons-inverse-disabled`: #ffffff80 - `texticons-link-primary`: #005bd3 (info-primary) - `texticons-link-secondary`: #004299 (info-heavy) - `texticons-success-primary`: #29845a (success-light) - `texticons-caution-primary`: #ffbd14 (caution-light) - `texticons-caution-secondary`: #e6aa12 (caution-primary) - `texticons-critical-primary`: #8e1f0b (critical-heavy) - `texticons-critical-secondary`: #5f1507 (critical-xxheavy) - `stroke-subtle`: #ecece6 - `stroke-default`: #e3e3dc - `stroke-strong`: #d1d1ca - `stroke-selected`: #202020cc - `stroke-info`: #005bd3 (info-primary) - `stroke-critical`: #8e1f0b (critical-heavy) ### Type (`text-*`; metric-* also needs `font-mono`) - `text-header-xxlarge`: 2rem - `text-header-xlarge`: 1.75rem - `text-header-large`: 1.5rem - `text-header-medium`: 1.25rem - `text-header-small`: 1rem - `text-header-xsmall`: 0.875rem - `text-header-xxsmall`: 0.75rem - `text-paragraph-large`: 1.125rem - `text-paragraph-medium`: 1rem - `text-paragraph-small`: 0.875rem - `text-paragraph-xsmall`: 0.75rem - `text-label-large`: 1.125rem - `text-label-medium`: 1rem - `text-label-small`: 0.875rem - `text-label-xsmall`: 0.75rem - `text-metric-large`: 1.75rem - `text-metric-medium`: 1.5rem - `text-metric-small`: 1.125rem - `text-metric-xsmall`: 1rem ### Shadows - `shadow-xs`: 0 1px 2px 0 #1c1c1c0d - `shadow-sm`: 0 2px 6px 1px #1c1c1c0f, 0 1px 2px 0 #1c1c1c0a - `shadow-md`: 0 4px 20px -4px #0000001a, 0 2px 4px -1px #1c1c1c0d - `shadow-lg`: 0 24px 48px -12px #0000002e, 0 4px 8px -2px #1c1c1c0f - `shadow-float`: 0 12px 32px -8px #00000059 - `shadow-focus`: 0 0 0 2px var(--color-info-primary) ### Radius - `rounded-none`: 0px - `rounded-xs`: 2px - `rounded-sm`: 4px - `rounded-md`: 8px - `rounded-lg`: 12px - `rounded-full`: 9999px ## Examples Real usage from the preview site (one file per section), showing how components compose, including controlled state, Modal, Toast (`ToastProvider` + `ToastViewport`) and menus. ### ai-cards.tsx ```tsx "use client" import * as React from "react" import { AIApprovalCard, AIApprovalDiff, type AIApprovalAction, type AIApprovalStatus } from "@/registry/ui/ai-approval-card" import { AIMessageQueue, type AIQueuedMessage } from "@/registry/ui/ai-message-queue" import { AIPendingBanner } from "@/registry/ui/ai-pending-banner" import { Button } from "@/registry/ui/button" import { AIQuestionCard, type AIAnswers, type AIQuestion, type AIQuestionResult } from "@/registry/ui/ai-question-card" const patch = `@@ -12,7 +12,7 @@ "Zone 2": { "floor_area": 84.0, - "people_per_area": 0.05, + "people_per_area": 0.11, "lighting_w_per_area": 9.0, },` const fluxActions = (tool: string, decide: (line: string) => void = () => {}): AIApprovalAction[] => [ { id: "once", label: "Allow once", variant: "primary", onSelect: () => decide("You allowed this.") }, { id: "tool", label: `Allow ${tool} in this project`, onSelect: () => decide("You allowed this, and every later call to this tool in this project."), }, { id: "auto", label: "Full auto", title: "Allow this, and stop asking about anything but deleting runs and shell commands.", onSelect: () => decide("You allowed this, and turned on full auto."), }, { id: "deny", label: "Deny", variant: "tertiary", onSelect: () => decide("You declined this.") }, ] const alwaysAskActions: AIApprovalAction[] = [ { id: "once", label: "Allow once", variant: "primary", onSelect: () => {} }, { id: "yolo", label: "YOLO for this chat…", title: "Stop asking about anything in this chat, this included. Asks you to confirm first.", onSelect: () => {}, }, { id: "deny", label: "Deny", variant: "tertiary", onSelect: () => {} }, ] const coreActions = (decide: (status: AIApprovalStatus) => void = () => {}): AIApprovalAction[] => [ { id: "approve", label: "Approve", variant: "primary", onSelect: () => decide("decided") }, { id: "deny", label: "Deny", variant: "secondary", onSelect: () => decide("denied") }, ] const fileDiff = function InteractiveApproval() { const [line, setLine] = React.useState(null) return ( {fileDiff} ) } function InteractiveCoreApproval() { const [status, setStatus] = React.useState("requesting") return ( ) } const purpose: AIQuestion = { id: "use", prompt: "What is Zone 2 used for?", kind: "single", options: [ { value: "office", label: "Office", description: "0.11 people/m², 9 W/m² lighting" }, { value: "meeting", label: "Meeting rooms", description: "0.5 people/m², intermittent" }, { value: "storage", label: "Storage", description: "Unoccupied, lights on demand" }, ], recommended: "office", allowOther: true, } const systems: AIQuestion = { id: "systems", prompt: "Which systems should the model include?", kind: "multi", options: [ { value: "vav", label: "VAV air handling" }, { value: "chiller", label: "Water-cooled chiller" }, { value: "dhw", label: "Domestic hot water" }, { value: "pv", label: "Rooftop PV" }, ], recommended: ["vav", "chiller"], allowOther: true, } const name: AIQuestion = { id: "name", prompt: "What should the project be called?", kind: "text", placeholder: "Project name" } const height: AIQuestion = { id: "height", prompt: "Floor-to-floor height (m)", kind: "number", recommended: 3.6 } const core = { question: "Which floors should I compare?", options: ["Floors 1–3", "Floors 4–6", "Every floor"], } const coreQuestion: AIQuestion = { id: "ask", prompt: core.question, kind: "single", options: core.options.map((o) => ({ value: o, label: o })), allowOther: true, placeholder: "Or type your own answer", } const why = "Zone 2 has no name in the IFC, so I can’t tell its loads from the drawings." function InteractiveQuestions() { const [result, setResult] = React.useState(null) const [skipped, setSkipped] = React.useState(false) return (
setSkipped(true)} /> {skipped ? "skipped" : result ? JSON.stringify(result) : "—"}
) } const timedOutAnswers: AIAnswers = { use: "office" } const answered: AIAnswers = { use: "meeting", systems: ["vav", "dhw", "Heat recovery wheel"], height: 4.2 } function Banner({ count, actions }: { count: number; actions?: React.ReactNode }) { const [jumps, setJumps] = React.useState(0) return ( setJumps((n) => n + 1)}> {count > 1 ? "2 approvals and a question" : "Approve Edit model.epJSON"} ) } const queued: AIQueuedMessage[] = [ { id: "a", text: "Also check the roof insulation against Part L." }, { id: "b", text: "Use the 2024 weather file for Singapore.\nKeep the design days from the old one." }, { id: "c", text: "Then compare cooling loads with the baseline run, floor by floor, and flag any zone that is more than 10% above it." }, ] function Queue({ note, defaultOpen }: { note?: string; defaultOpen?: boolean }) { const [items, setItems] = React.useState(queued) const [sent, setSent] = React.useState([]) return ( i.id).join(",")} data-sent={sent.join(",")} items={items} note={note} defaultOpen={defaultOpen} onEdit={(id, text) => setItems((all) => all.map((i) => (i.id === id ? { ...i, text } : i)))} onReorder={(ids) => setItems((all) => ids.map((id) => all.find((i) => i.id === id)!))} onRemove={(id) => setItems((all) => all.filter((i) => i.id !== id))} onSendNow={(id) => { setSent((s) => [...s, id]) setItems((all) => all.filter((i) => i.id !== id)) }} /> ) } function InPanel() { const card = React.useRef(null) const [status, setStatus] = React.useState("requesting") return (
{Array.from({ length: 6 }, (_, i) => (

While you decide, the agent keeps the rest of its plan visible here. Floor 3 is the only floor whose height differs from the drawings, so it asked before changing it.

))} { card.current?.scrollIntoView({ block: "nearest" }) card.current?.querySelector("button")?.focus({ preventScroll: true }) }} > Approve update_building
) } function Labelled({ label, children, width = "w-90" }: { label: string; children: React.ReactNode; width?: string }) { return (
{label}
{children}
) } export function AICardsDemo() { return (
{fileDiff}
  • runs/2026-10-24-baseline/eplusout.sql
  • runs/2026-10-24-baseline/eplusout.err
{}} onSkip={() => {}} /> {}} /> {}} onSkip={() => {}} /> {}} onSkip={() => {}} /> {}} /> {}} /> {}} />
Notify me } />
) } ``` ### ai-messages.tsx ```tsx "use client" import * as React from "react" import { CoinIcon, FloorIcon } from "@urbanflow/icons" import { AICitation, AICitationCard, AICitations } from "@/registry/ui/ai-citation-card" import { AICodeBlock } from "@/registry/ui/ai-code-block" import { AIContextSelectionChip } from "@/registry/ui/ai-context-chip" import { AIConversation, AIConversationContent, AIConversationScrollButton } from "@/registry/ui/ai-conversation" import { AIMessage, AIMessageActions, AIMessageContent, AIMessageCopy, AIMessageError, AIMessageGenerating, AIMessageRegenerate, AIMessageStopped, } from "@/registry/ui/ai-message" import { AIReasoning } from "@/registry/ui/ai-reasoning" import { AISources, type AISource } from "@/registry/ui/ai-sources" import { AITrace, type AITraceRenderers, type AITraceStep } from "@/registry/ui/ai-trace" import { Button } from "@/registry/ui/button" const question = "Why is cost per GFA higher on this floor?" const answer = "Cost per GFA on Floor 3 is [__] $/m², against a building average of [__] $/m². Most of the difference comes from Architecture works ([__] $) and Service & equipment ([__] $)." const partial = "Cost per GFA on Floor 3 is [__] $/m², against a building average of [__] $/m². Most of the difference comes from" const thought = "Reviewed the selected floor, compared its cost breakdown with the building average, and identified the main contributors." const thinking = "Reviewing the selected floor and comparing its cost breakdown with the building average…" const timeout = "Expected Costflow comparison, received a timeout\n\nat Costflow.compare (src/costflow.ts:58:22)" const timeoutAgain = "Attempt 2 timed out after 30 seconds\n\nat Costflow.compare (src/costflow.ts:58:22)" const sources: AISource[] = [ { id: "costflow", title: "Costflow", detail: "Elemental breakdown", icon: }, { id: "floor-3", title: "Floor 3", detail: "Properties", icon: }, ] const step = (id: string, label: string, status: AITraceStep["status"], extra?: Partial): AITraceStep => ({ id, name: id, label, status, ...extra, }) const read = (status: AITraceStep["status"]) => step("read_floor", "Read Floor 3 properties", status) const compare = (status: AITraceStep["status"], extra?: Partial) => step("compare_costflow", status === "error" ? "Couldn’t compare the Costflow breakdown" : "Compared the Costflow breakdown", status, extra) const average = (status: AITraceStep["status"]) => step("building_average", "Checked the building average", status) const traces: [string, AITraceStep[], Partial>?][] = [ ["Running step 1", [read("running"), compare("pending"), average("pending")]], ["Running step 2", [read("done"), compare("running"), average("pending")]], ["Running collapsed", [read("done"), compare("running"), average("pending")], { defaultOpen: false }], ["Expanded", [read("done"), compare("done"), average("done")], { defaultOpen: true }], ["Collapsed", [read("done"), compare("done"), average("done")]], ["Error", [read("done"), compare("error", { detail: timeout }), average("pending")], { onRetry: () => {} }], [ "Retrying", [read("done"), step("compare_costflow", "Comparing the Costflow breakdown", "running", { attempt: 2 }), average("pending")], ], ["Stopped by the user", [read("done"), compare("running"), average("pending")], { stopped: true }], ["Failed step, then the model carried on", [read("done"), compare("error", { detail: timeout }), average("done")]], [ "Retry failed", [read("done"), step("compare_costflow", "Costflow comparison timed out again", "error", { attempt: 2, detail: timeoutAgain }), average("pending")], { onRetry: () => {} }, ], ] const layers = [ { name: "M01 100mm brick", mm: 102, r: 0.11 }, { name: "I02 50mm insulation board", mm: 51, r: 1.69, insulation: true }, { name: "F04 Wall air space resistance", mm: 0, r: 0.15 }, { name: "G01a 19mm gypsum board", mm: 13, r: 0.12 }, ] const toolRenderers: AITraceRenderers = { list_constructions: ({ step: s }) => (s.output as string[]).map((name) => (

{name}

)), read_construction_layers: () => ( <>
{layers.map((l) => (
))}
Outside Inside
{layers.map((l) => (
{l.name} {l.mm ? `${l.mm} mm` : "—"} R {l.r.toFixed(2)}
))}

R 2.08 m²·K/W through the layers, before surface films

Missing Building, GlobalGeometryRules

), locate_site: () => ( <> {Array.from({ length: 48 }, (_, i) => ( ))}
1000 m square, 215 buildings N ↑

Approximate location highlighted in blue

), read_building: () => ( <>
{[["Storeys", "55"], ["Floor", "168,514 m²"], ["Height", "193.0 m"], ["WWR", "35%"]].map(([k, v]) => ( {k} {v} ))}

Missing Building, GlobalGeometryRules

), } const constructionSteps: AITraceStep[] = [ step("list_constructions", "Listed 4 constructions", "done", { output: ["Medium Exterior Wall", "Medium Floor", "Medium Roof/Ceiling", "Dbl Clr 6mm/13mm Air"], }), step("read_construction_layers", "Read exterior wall layers", "done"), ] const siteSteps: AITraceStep[] = [ step("locate_site", "Located the site", "done"), step("read_building", "Read building properties", "done"), ] function Labelled({ label, children, width = "w-90" }: { label: string; children: React.ReactNode; width?: string }) { return (
{label}
{children}
) } const userMessage = ( } selection={{ kind: "floor", label: "Floor 3" }} /> {question} ) function Citations() { return ( {}}> } title="Costflow · Elemental breakdown" description="Architecture works and Service & equipment rows for Floor 3." action={ } /> {}}> } title="Floor 3 · Properties" description="Gross floor area and zone areas." /> ) } function Actions({ latest = true }: { latest?: boolean }) { return ( {latest && {}} />} ) } function AssistantReply({ trace }: { trace?: boolean }) { return ( {trace && ( <> {thought} )} {answer} ) } function RetryableReply() { const [phase, setPhase] = React.useState<"lost" | "retrying" | "done">("lost") const [retries, setRetries] = React.useState(0) React.useEffect(() => { if (phase !== "retrying") return const timer = setTimeout(() => setPhase("done"), 1200) return () => clearTimeout(timer) }, [phase]) return ( {phase === "lost" && ( <> {partial} { setRetries((n) => n + 1) setPhase("retrying") }} /> )} {phase === "retrying" && } {phase === "done" && ( <> {answer} )} ) } function RetryableTrace() { const [attempt, setAttempt] = React.useState(1) const [status, setStatus] = React.useState("error") React.useEffect(() => { if (status !== "running") return const timer = setTimeout(() => setStatus("done"), 1200) return () => clearTimeout(timer) }, [status]) const steps = [ read("done"), status === "error" ? compare("error", { detail: timeout, attempt }) : step("compare_costflow", status === "running" ? "Comparing the Costflow breakdown" : "Compared the Costflow breakdown", status, { attempt }), average(status === "done" ? "done" : "pending"), ] return ( { setAttempt((n) => n + 1) setStatus("running") }} /> ) } function Conversation() { const [extra, setExtra] = React.useState(0) return (
{[0, 1, ...Array.from({ length: extra }, (_, i) => i + 2)].map((i) => ( {userMessage} ))}
) } export function AIMessagesDemo() { return (
{userMessage} {partial}
{thinking} {thinking} {thought} {thought}
{traces.map(([label, steps, props]) => ( ))}
{timeout} {}} />
) } ``` ### ai.tsx ```tsx "use client" import * as React from "react" import { FilesIcon, FloorIcon, ZoneIcon } from "@urbanflow/icons" import { AIContextChip, AIContextChipAdd, AIContextSelectionChip } from "@/registry/ui/ai-context-chip" import { AIContextLimit, AIContextUsage, AIContextUsageCard } from "@/registry/ui/ai-context-usage" import { AIPromptInput, AIPromptInputAttach, AIPromptInputContext, AIPromptInputPicker, AIPromptInputSelect, AIPromptInputSubmit, AIPromptInputTextarea, AIPromptInputToolbar, AIPromptInputTools, type AIPromptInputStatus, } from "@/registry/ui/ai-prompt-input" import { CommandGroup, CommandItem } from "@/registry/ui/command" const modes = [ { value: "ask", label: "Ask", description: "Asks before every change" }, { value: "auto", label: "Auto", description: "Edits, asks before running" }, { value: "yolo", label: "YOLO", description: "Runs everything without asking" }, ] const models = ["GPT-5", "GPT-5 mini", "Claude Sonnet 4.5", "Claude Haiku 4.5"].map((m) => ({ value: m, label: m })) const efforts = ["Low", "Medium", "High"].map((e) => ({ value: e.toLowerCase(), label: e })) const usage = { used: 84_000, limit: 200_000, inputTokens: 61_240, outputTokens: 22_760, credits: "1.8" } const mentions = [ { id: "floor-3", name: "Floor 3", kind: "Floor", icon: }, { id: "zone-3a", name: "Zone 3A · Office", kind: "Zone", icon: }, { id: "cost-plan", name: "Floor 3 cost plan.pdf", kind: "File", icon: }, ] const commands = [ { name: "/clear", detail: "Start a new chat" }, { name: "/compact", detail: "Summarise the chat to free context" }, ] const selection = ( } selection={{ kind: "floor", label: "Floor 3" }} onRemove={() => {}} /> ) const file = (name: string) => ( } label={name} detail="PDF · 2.4 MB" onRemove={() => {}} /> ) const longDraft = "Why is cost per GFA higher on this floor? Compare it with Floor 2 and the building average, break the difference down by element (substructure, superstructure, services and finishes), and tell me which elements to review first to bring it closer to the Green Mark target. Also list any assumptions about floor area or unit rates, and what data is missing." function Composer({ initial = "", placeholder = "Ask about this model, or select an object on the canvas", status = "ready", context, disabled, picker, wrap, }: { initial?: string placeholder?: string status?: AIPromptInputStatus context?: React.ReactNode disabled?: boolean picker?: React.ReactNode wrap?: boolean }) { const [text, setText] = React.useState(initial) const [mode, setMode] = React.useState("auto") const [model, setModel] = React.useState("GPT-5") const [effort, setEffort] = React.useState("medium") return ( setText("")}> {picker} {context} setText(e.target.value)} placeholder={placeholder} disabled={disabled} /> {}} /> ) } function SingleModelComposer() { const [text, setText] = React.useState("") return ( setText("")}> setText(e.target.value)} placeholder="Ask about this model" /> ) } function PickerComposer() { const [text, setText] = React.useState("Compare @fl") const [active, setActive] = React.useState(0) const token = /(^|\s)([@/])(\S*)$/.exec(text) const rows = token?.[2] === "@" ? mentions .filter((m) => m.name.toLowerCase().includes(token[3].toLowerCase())) .map((m) => ({ key: m.id, insert: `@${m.name} `, icon: m.icon, label: m.name, detail: m.kind })) : token?.[2] === "/" && token.index === 0 ? commands .filter((c) => c.name.startsWith(`/${token[3]}`)) .map((c) => ({ key: c.name, insert: `${c.name} `, icon: null, label: c.name, detail: c.detail })) : [] const pick = (insert: string) => { setText(text.slice(0, text.length - token![0].trimStart().length) + insert) setActive(0) } return ( setText("")}> {rows.length > 0 && ( setActive(rows.findIndex((r) => r.key === key))}> {rows.map((r) => ( pick(r.insert)}> {r.icon} {r.label} {r.detail} ))} )} { setText(e.target.value) setActive(0) }} placeholder="Type @ to mention an object, / for commands" onKeyDown={(e) => { if (rows.length === 0) return if (e.key === "ArrowDown" || e.key === "ArrowUp") { e.preventDefault() setActive((i) => (i + (e.key === "ArrowDown" ? 1 : rows.length - 1)) % rows.length) } else if (e.key === "Enter" || e.key === "Tab") { e.preventDefault() pick(rows[active].insert) } }} /> ) } const states: [string, React.ReactNode][] = [ ["Empty", ], ["Draft", ], ["Selection", ], ["Generating", ], ["Draft max height", ], ["Selection and file", {selection}{file("Floor 3 cost plan.pdf")}} />], [ "Selection and multiple files", {selection}{file("Floor 3 cost plan.pdf")}{file("Cost assumptions.pdf")}} />, ], [ "Narrow, tools wrap",
, ], ] function Labelled({ label, children }: { label: string; children: React.ReactNode }) { return (
{label} {children}
) } export function AIDemo() { return (
{states.map(([label, node]) => (
{node}
))}
{selection} } selection={{ kind: "floor", label: "Floor 3" }} /> } selection={{ kind: "zone", label: "2 zones", count: 2 }} onRemove={() => {}} /> } label="Floor 3 cost plan.pdf" detail="PDF · 2.4 MB" onRemove={() => {}} /> {}} /> } label="Floor 3 cost plan.pdf" detail="PDF · 2.4 MB" /> } label="Floor 3 cost plan.pdf" onRetry={() => {}} onRemove={() => {}} />
{}} />
{}} />
) } ``` ### comments.tsx ```tsx "use client" import * as React from "react" import { CommentPin } from "@/registry/ui/comment-pin" import { CommentSearch, CommentSection } from "@/registry/ui/comment-section" import { CommentThread } from "@/registry/ui/comment-thread" import { Photo } from "@/registry/ui/photo" import { ViewComment } from "@/registry/ui/view-comment" export function CommentsDemo() { const [showResolved, setShowResolved] = React.useState(false) const [resolved, setResolved] = React.useState(false) const [selected, setSelected] = React.useState(1) const [messages, setMessages] = React.useState([ { author: "Ryan Tan", time: "2h", body: "Can we check the setback on the east side?", index: 1 }, { author: "Pei Ning", time: "1h", body: "Updated — see the new massing.", index: 5, photos: [] }, ]) return (
{}} /> {[0, 1, 2].map((i) => ( i === 1 && setResolved(!resolved)} onClick={() => setSelected(i)} replies={i === 2 ? "2 replies" : undefined} > Can we check the setback on the east side? ))}
setMessages((m) => [...m, { author: "Pei Ning", time: "now", body: text, index: 5 }])} />
{}} />
) } ``` ### compliance.tsx ```tsx "use client" import { ComplianceIssue } from "@/registry/ui/compliance-issue" import { Policy } from "@/registry/ui/policy" const clause = "The building height shall not exceed the maximum permissible height stated in the Master Plan for the site." export function ComplianceDemo() { return (
{clause} } /> = 19" }, { label: "Actual", value: "20" }, ]} policy={} />
) } ``` ### files.tsx ```tsx "use client" import * as React from "react" import { ArrowUpDownIcon, ComplianceIcon, MassingIcon, TimeIcon, TrashIcon } from "@urbanflow/icons" import { FileActions } from "@/registry/ui/file-actions" import { FileCategory } from "@/registry/ui/file-category" import { FileRow } from "@/registry/ui/file-row" import { IconButton } from "@/registry/ui/icon-button" import { SelectFloorPlan } from "@/registry/ui/select-floor-plan" const files = [ { name: "Tower-04-Floor-Plans.pdf", type: "PDF document", category: "Floor plan", size: "3.1 MB", owner: "Pei Ning", date: "2 days ago" }, { name: "Site-survey.jpg", type: "JPEG image", category: "Reference", size: "1.4 MB", owner: "Ryan Tan", date: "5 days ago" }, { name: "Massing-v3.ifc", type: "IFC model", category: null, size: "50.4 MB", owner: "Ana Lim", date: "1 week ago" }, ] export function FilesDemo() { const [cats, setCats] = React.useState<(string | null)[]>(files.map((f) => f.category)) const [selected, setSelected] = React.useState(0) return (
{files.map((f, i) => ( setSelected(i)} category={ setCats((c) => c.map((x, j) => (j === i ? v : x)))} /> } actions={ <> }, { label: "Run compliance check", icon: }, ]} /> } /> ))}
) } ``` ### inputs.tsx ```tsx "use client" import * as React from "react" import { SearchIcon } from "@urbanflow/icons" import { Button } from "@/registry/ui/button" import { InlineError } from "@/registry/ui/field" import { MultilineField } from "@/registry/ui/multiline-field" import { Select } from "@/registry/ui/select" import { TextField } from "@/registry/ui/text-field" const archetypes = ["Office", "Residential", "Retail", "Hotel", "Education"] export function InputsDemo() { const [height, setHeight] = React.useState(3.6) const [use, setUse] = React.useState("Office") return (
} /> setHeight(Number(e.target.value) || 0)} unit="m" stepper onIncrement={() => setHeight((h) => h + 0.1)} onDecrement={() => setHeight((h) => Math.max(0, h - 0.1))} helpText="from Archetype default" /> Reset to default} defaultValue="0.6" />