Arc Forge Labs Docs v2026.10.53

Design system

Flow's screens use one theme engine: the one Arc Forge Labs' Field uses, so a design system looks the same in both apps.

Appearance

Appearance is three choices, per person, on the Appearance page (/appearance). Each person's choice is saved with their account (people.appearance, PUT /api/me/appearance) and applied when they sign in, so people who share a browser, or move between devices, each see their own. The browser keeps a copy (localStorage flow_appearance) so a page paints in it before Flow knows who is signed in. An account with no choice yet takes the browser's current one at its first sign-in.

  • Mode: Light, Dark or Auto (follow the device, the default), on the Appearance page. The sign-in screen has a quick light or dark switch.
  • Design system: a theme family, each with a light and a dark palette and its own faces, corners and label style.
    • Harbour (harbour, the default): Flow's own. One deep teal for ink, filled buttons and labels, a mint highlight, cream pages and white cards. Nunito Sans for reading, Playfair Display for page titles, soft corners, tracked uppercase labels. In dark mode the teal becomes the page and the mint the accent.
    • Meridian (meridian): a dental laboratory's look. Deep navy for ink, filled buttons and labels, a champagne highlight, warm stone pages and white cards. Plus Jakarta Sans for reading, Playfair Display at regular weight for page titles, square corners, widely tracked uppercase labels. In dark mode the navy becomes the page and the champagne the accent. An installation makes it the default with FLOW_THEME=meridian.
    • Beacon, Tide, Claw, Knot, Dash, then Absolutely, Phosphor, CRT, Manuscript, Rosé, Miami: ported from the OpenClaw Control UI themes (MIT licence, Copyright (c) 2026 OpenClaw Foundation) by scripts/port-openclaw-themes.mjs. The generated server/theme/openclaw-palettes.js keeps that attribution and the upstream commit.
  • Accent colour: the family's own, one of upstream's presets (plus Harbour teal), or any custom colour.

An installation picks the design system every browser starts on with FLOW_THEME (any family id; unset means harbour; an unknown id stops the server). A person's own choice on the Appearance page wins wherever they sign in.

How it works

server/theme/themes.js ──► /assets/themes.css      every family × mode as
  (the --t-* tokens)       [data-theme][data-mode] rules
        │
        ├──► /assets/theme/*.js  the same modules in the browser
        │      (accent overrides, contrast helpers, font lists)
        │
server/ui/page.js ──► every page: themes.css, flow.css, and an inline script
                      that puts the stored family and mode on <html> before
                      first paint and loads that family's faces
server/ui/appearance.js ──► applies a change, saves it in localStorage
                            (flow_appearance), tells listeners
server/ui/clerk-appearance.js ──► Clerk's components, themed from the tokens

server/theme/themes.js is the one source of the --t-* tokens. Screens use the tokens, never literal colours, radii or strokes (server/ui/flow.css), so every family reaches every screen. The faces are the fontsource variable builds, served from node_modules under /assets/fonts/<package>/ (server/theme/font-families.js); a page loads only the chosen family's faces. The fonts are under the SIL Open Font Licence.

The rules every family keeps, checked in both modes by test/theme.test.js and by the WCAG AA audit in test/theme-contrast.test.js (CI fails on any miss; npm run theme:contrast prints every pair). The audit (server/theme/contrast-audit.js) lists each text and control colour on each surface Flow's components and Clerk's screens put it on, and lays translucent tints over their surface first. A new pairing on screen gets a row there.

  • --t-text and --t-dim read at 4.5:1 or better on their surfaces.
  • --t-accent is text and reads at 4.5:1 on --t-bg. --t-brand is the colour as chosen, for fills, highlights and the focus ring. An accent override shifts --t-accent just enough to keep 4.5:1.
  • --t-control is the edge of an input or an outlined button: the text colour faded toward --t-paper only as far as it still reads at 3:1 (WCAG 1.4.11). themes.js derives it for every family.
  • A ported family that misses AA on a surface Flow uses is nudged in themes.js just far enough: --t-dim, --t-accent and --t-brand-ink.

Clerk

Clerk's own components do the account work: SignIn on the sign-in screen, UserButton in the top bar (manage account, sign out) and UserProfile on the Account page. server/ui/clerk-appearance.js builds their appearance from the active tokens: colours, faces, corners, and the control edges in --t-control. Clerk builds its border shades (dividers, hover edges, the "Primary" badge) as fractions of colorBorder, so that is the opaque text colour, and the badge text is --t-dim. Clerk draws an input's edge as a box-shadow ring on a [data-variant] selector, so Flow's edge rules double the class (&&) to win. A change of appearance remounts every Clerk component, so Clerk follows the theme and mode at once. Clerk's words that name the app say "Flow", whatever the Clerk instance is called.

Screens

The page at /sign-in, /people, /account and /appearance (server/ui/app-page.js, server/ui/app.js):

  • Every page: the space shows work, not words about the page. The header is the page's name only (pageHeader), with no line under it, at one height on every page; a live count (2 to check) may sit beside the name, and a docs link is a "?" icon there (helpLink), not a line of text. Prefer white space to filler. Every page uses the same centred width (.fx-main); only the Jobs board's lanes use the whole window.
  • Tips: every control that shows only an icon says its name on hover and on keyboard focus (server/ui/tip.js, one listener for the whole app). It reads data-tip, else the control's aria-label or title; a control with words on it shows no tip. Give each icon-only control an aria-label (or named()), and it gets its tip.
  • Sign-in: a brand panel (the primary colour in light mode, a raised surface in dark) beside Clerk's sign-in card. On a phone the panel is a band above the card.
  • Sidebar: the Flow mark and the views the person has pinned, daily work first, then More. More opens the shared row menu as a floating popover to the right of that row (on a phone, inside the slide-out menu, with 44px targets). The menu lists every sidebar page this person may open: Jobs, Invoices, Contacts, Reconcile, Price list, Rules, Automations, Custom fields and Integrations (owners). Each row has a pin. A pinned page sits in the sidebar; an unpinned page shows only in More. Jobs and Invoices stay pinned. The pins are saved with the person (PUT /api/me/views/nav) and follow the account. With none saved yet, Jobs, Invoices, Contacts, Reconcile, Price list and Rules are pinned. More shows as current while the open page is one of these and is not pinned. People (owners) and Appearance are in the account menu. Account is not in the list: "Manage account" in the account menu opens it. At its foot: the account chip (Clerk's photo or initial, then the name, or the email when there is no name, clipped to fit) that opens the account menu (Manage account, People, Appearance, Report a problem when Sentry is on, Sign out; as wide as its rows, 220px to 280px, with a long name or email clipped), and Search (also Ctrl+K or ⌘K) for pages, invoices and jobs. While Sentry's report form is on, "Report a problem" sits at the foot of the menu, above the chip (an icon when folded). The menu button at the top folds the sidebar to its icons and back; the choice stays in the browser. On narrow screens the sidebar is a bar across the top (menu button, Flow mark, account photo, Search), and the menu button opens the pages as a panel below the bar.
  • Contacts: Flow's own customers and suppliers (docs/CONTACTS.md). Search, a kind filter, and a row that opens the contact. New asks for the name, kind and email, then opens the record. The record holds the billing and practice fields. When Xero is connected and the contact is missing there, an owner or admin sees Create.
  • Jobs: the board (one lane per group) or the list. On a phone one lane fills the width and snaps when swiped; stage tabs with counts sit in the toolbar, and a long press opens Move to. A job opens as a full page: back, the client, previous and next, and a menu, with Activity and Fix as tabs and the main action at the foot. On a wider screen each lane is a fixed width that never stretches, with empty space past the last lane, filling the window to its bottom edge, each lane scrolling inside itself. The toolbar has Group, Sort and Filter at the left, and the search icon (it opens into a box, findBox) and Board/List at the right. Each opens a panel of rows, laid out as ClickUp's, in Flow's own look; each row has a bin that deletes it, and an active chip has an × that clears it ("Clear all filters"). Group by is one row: status, invoice stage, client, practitioner or a groupable custom field, and Ascending or Descending (the group order). Its bin takes the groups away: one plain list (the board always has lanes: status lanes). Sort is rows applied in turn (due, requested, shipped, client, practitioner, total or any custom field, each in either direction), with "Add sort"; no sort keeps the lab system's order. Filters work as ClickUp's: groups of rows of field, operator and value. The field list has a search box and a type icon for each field (status, invoice stage, client, practitioner, Assignee, due, requested, shipped, total, problem, or any custom field). Each type has ClickUp's operators: Is, Is not, Is set, Is not set for a choice, person or date; Greater than, Less than, Equal to for a number; Contains, Does not contain, Is for text; Is checked for a checkbox. A date's value is a range: Today, This week, Next month, This quarter, Overdue, Last or Next N days, an exact date, before or after a date, or a date range (weeks start on Monday). "Add nested filter" adds a row to a group, and "Add filter" adds a group; And / Or joins the rows in a group, and the groups. "Clear all" takes them all away. A row still being filled in filters nothing. Each person's view is saved with their account (people.views, PUT /api/me/views/jobs, server/ui/job-view.js), so it follows them to every device. Saved views, as ClickUp's view tabs, sit between the title and the toolbar on Jobs, Invoices and Reconcile. "+ View" saves the page's Group, Sort, Filter and Columns under a name. While the page differs from the chosen tab, the tab has a dot and "Save" and "Revert" show at the right. Each tab's ⋯ has "Set as my default", Rename and Delete. A person's default opens when they first come to the page in a browser tab (ClickUp's "Set as default view" is for everyone; Flow's is each person's own). The views follow the account (people.views.saved, PUT /api/me/saved-views/:page, server/ui/list-views.js). In the list, Columns chooses the columns (saved with the view; Client always shows): Case, Practitioner, Patient (only when the installation keeps patient names), Stage, Assignee, Requested, Due, Shipped, Total, and each custom field. One column holds one field: each date has its own column, short (Today, Tomorrow, Yesterday, else day/month; the full date on hover), and a late Due date shows in red. A person column shows each person's badge (initials; the name on hover). In the list, sorting starts on the column heads, as ClickUp's: a sort button shows on hover (and always while the column sorts, as a filled circle with the arrow). A click sorts one way, a second click the other way, a third click takes the sort off. A right-click on a sorting button opens "Sort by " (changes the direction) and "Clear sort". The Sort chip shows in the list only while a sort is on. A click or right-click on a column's name opens its menu: sort by it (when it sorts), move it to the start or end, Edit custom field (owners and admins), Hide column. + at the end of the head lists the hidden columns; a click adds one back. The filter "Assignee is Me" keeps the jobs assigned to the person looking. The Stage column shows each status as its coloured label. A tick column is at the left; a group head ticks every job in its group, and a click on the head folds the group or opens it (folds stay in the browser). Ticked rows get the bulk bar at the foot, as on Invoices (Move, Custom field, Mark ready to invoice, Draft invoices, Copy case numbers, Export CSV; More adds each custom field and Selection). Custom field sets one: the custom field, then its value, in Flow only. Move says "Not yet available": a work system's stages cannot be moved from Flow in bulk yet. Every item in the bar is a working tool or says plainly that it is not available.
  • People (owners): a title with one primary action, Invite (a dialog: email, and role with its one-line hint). One row per person: initials, the name (the email under it only when there is a name), the role as a select (its hint as a tooltip), a status pill only when access is not plain Active (Invited, Waiting for approval, Turned off), and a ⋯ menu (Email invite again, Turn off or Turn on, Remove behind a confirm dialog). Approve stays a visible button. No column headings and no role key under the table. Results show as toasts. Without a verified owner sign-in the page says so and the list stays closed.
  • Account: Clerk's profile. Its own card heading is the only visible title.
  • Appearance: mode, design system cards (each drawn in its own family) and accent swatches.
  • Messages: one-card pages (sign-in not set up, an error, no access yet, the end of connecting Xero) use messagePage in server/ui/page.js.

Shared components

Every page builds from these. Reuse one, or give it a new option; do not copy its code into a page (AGENTS.md, "UI: reuse first"). A new shared component gets a row here and a card in the design system (docs/DESIGN_SYSTEM.md) in the pull request that adds it. The Card column names its card in the Flow Design System. Read the card's mobile.json before changing it on a phone: the component reads those rules.

Component Where Card Use it for
h, ui(): flow, go, me server/ui/kit.js (h), server/ui/app.js (ui()) Build elements; what every view module takes: call the API, open a page, the signed-in person
pageHeader(title, { count, help, actions }), helpLink server/ui/kit.js PageHeader The page title only, a live count, a ? docs link, its actions
listHead(title, { controls, views, start, end, actions, line, sticky, controlsAt }) server/ui/kit.js ListHead The top of every list (Jobs Board and List, Invoices, Reconcile, Price list): title, then one toolbar (start left, end and actions right). controls (Group, Sort, Filter) and views (the page's savedViews element) sit together in it. Same place on every list and layout.
listFrame(main, content), edgeFade(scroller, { target, edges }) server/ui/kit.js ListFrame The window a list or board sits in under listHead: fills the page, scrolls inside itself both ways, sticky table head, a foot bar with the count and tools. edgeFade: a soft fade where content scrolls past an edge
notice(text, tone, title, { short }) server/ui/kit.js Notice A note on the page that stays until the cause is gone. The title leads the text on one line. short: the one line a phone shows when the note sits in a row with buttons ("Client not in Xero")
cardRows(table, { areas, columns }) server/ui/kit.js CardRows A short table inside a card (invoice lines): on a phone each row is a small card, each cell under its column name; a table on a tablet and desk. Never a sideways scroll
fieldError(field, on), .fx-input[aria-invalid="true"] server/ui/kit.js, server/ui/flow.css FieldError A field that fails a check glows red, like a required field, until the person types in it
toast(text, tone, { stay }) server/ui/kit.js Toast A short result after an action. stay: true keeps it until the person closes it (a failed Send, a deleted invoice)
dialog({ title, body, confirm, tone }) server/ui/kit.js Dialog A confirm step before an action that is hard to undo
iconPicker({ value, fallback, label, onpick }), ICON_CHOICES server/ui/kit.js IconPicker Pick an icon: a button, and a panel with a search box over a grid of Lucide icons, each named on hover. A sheet on a phone (the icon of a rule)
pickOne({ title, intro, label, find, load, item, empty, confirm, remote }) server/ui/kit.js Pick Pick one row of a long list in a dialog, with a search box (New invoice from a job, Link a Xero client)
holdButton(button, ms) server/ui/kit.js HoldButton A button that waits between presses (Xero, Seazona) and shows the time left
captureButton(ui, { icon, added, done }) server/ui/captures.js CaptureButton The camera button that sends a form photo to Capture: the phone's own camera, or files on a desk. icon: true in a toolbar (Jobs); send(files) for a drop or paste
settingRow({ label, hint, help, control }), sectionTitle, .fx-setting server/ui/kit.js SettingRow One setting row: short name, ? help, the control at the right
tileCard({ href, mark, title, status, blurb, meta, action, row }), tileSection(title, cards, { href, more }) server/ui/kit.js Tiles Cards that open a page. row: true with tileSection: one-line cards, three columns, like Claude's Customize categories (Rules). Without row: the large card (Integrations). On a phone, both are one 56px row with ›
crumbs([href, label]…, here) server/ui/kit.js Crumbs The trail above a page one level down (Rules › Invoices, Integrations › Xero)
rowMenu(label, items, { trigger, className, align, place }) server/ui/kit.js RowMenu The ⋯ menu on a row. align: 'start' lines the menu up with the button's left edge. place: 'side' floats it to the right of the button (the sidebar's More); on a phone that menu stays inside the slide-out. An item's trailing is a control that does not close the menu (the pin). trigger replaces the ⋯ icon.
savedViews(ui, { page, current, apply }) server/ui/saved-views.js SavedViews The named view tabs of a list, in the toolbar beside Group, Sort and Filter, as ClickUp's: + View, Save / Revert, and each tab's ⋯ (Set as my default, Rename, Delete). The rules are in list-views.js
.fx-btn and its modifiers server/ui/flow.css Button Every button
named, startTips server/ui/tip.js Tip An icon-only control's name, shown on hover and focus
viewBar(h): menu, option, groupMenu, sortMenu, filterMenu and the rows-panel parts server/ui/view-bar.js Filter Group, Sort, Filter and Columns chips and their panels, on every list (Jobs, Invoices, Reconcile, Price list)
columnTable(h, { order, widths, change, hidden, sorting, extra }), groupHead, rowCells, viewSaver, dateCell, personCell, fieldColumn server/ui/columns.js (rules in job-view.js) Columns A list's table (fieldColumn: a custom field as a column, on Jobs and Invoices): drag an edge to resize (a minimum per column kind), drag a head to move, the head menu (Sort, Move, Hide, Reset width), head sort buttons (sorting), group head rows (groupHead), + for hidden columns; the end column fills each row to the right edge (no stretch); order and widths kept with the account. On a phone the row is two lines and 56px: line 1 is the title columns and the amount, line 2 is the status and up to three meta columns. A column's phone flag is title, meta, end or hide (the first column, the money column and a stage or status are the defaults). Jobs, Invoices, Reconcile, Price list
findBox(h, { label, oninput }) server/ui/view-bar.js FindBox A list's own search: an icon that opens into a box
ICONS, svg, today server/ui/view-bar.js The toolbar icons, an SVG string as a node, the local day
personLabel, initialsOf, clipLabel server/ui/person.js A person's name everywhere (pickers, filters, lanes, badges, People): full name, else the whole email; a badge's letters; a native menu's label cut with "…"
filterJobs, cleanFilterView, sortJobs, groupJobs, OPERATORS, DATE_VALUES server/ui/job-view.js The rules behind Filter, Sort and Group (no DOM). Every list's filter uses them with its own fields
syncStatus() server/ui/sync-status.js SyncStatus Shared Layers sync row: server clock, total count, day rail and ETA. Caller supplies one action and owns polling.
bulkBar(ui, { label }) server/ui/bulk-bar.js BulkBar The bar at the foot while rows are ticked: icon and one word per action, More
listPager(h, { label, sizes, change }), pageNumbers, pageRange server/ui/pager.js ListPager The pages of a long list in its foot bar: rows per page, "1–100 of 1,204", numbered pages. The server cuts the page and counts every match. Invoices
pager(h, { label, back }) → { element, draw({ count, prev, next, … }) } server/ui/pager.js Pager "← Back", "n of N", previous and next over one record. Invoices, Reconcile, Price list › Reconcile
.fx-tabs, .fx-tabs__count server/ui/flow.css Tabs Tabs with counts (Invoices, Price list)
alertStrip(ui, page) server/ui/alerts.js The slim alert line on a page (docs/ALERTS.md)
lucide(h, name, label) server/ui/custom-fields.js Icon A Lucide icon in the text colour; with label it is named
colorPicker, optionPill server/ui/custom-fields.js OptionPill A colour choice; a dropdown option as a coloured pill
jobChangeBanner(ui, change), jobChangeReview(ui, change, { canAccept, done }) server/ui/job-change.js JobChangeBanner "The job changed" on a draft, with Accept change; jobChangeReview wires Accept and the recheck (invoice page, a job's Review sidecar)
sidecar(h, { label, overlay, escape, tabs, phone }), recordBar server/ui/sidecar.js Sidecar The right-hand panel: tabs with an icon and one word, ×, closed takes no room. A job's panel (Activity, Fix); over a page with overlay (Custom fields). phone: 'page' opens that panel as a full page on a phone: recordBar (back, title, previous and next, menu), the tabs under it, and the main action at the foot. The invoice page uses the same bar
formatCents, xeroTag, linkIcon, accountingIcon server/ui/invoicing.js Money, an accounting status tag, a link to the other system
xeroContactActions(ui, { organisationId, contact, offerLink, offerCreate, done }) server/ui/xero-contact.js Link and Create for a client not in Xero (Pending invoice, contact page)
automationSection, rulesSection, sendModeSection, lineMappingSection server/ui/automations.js, invoice-rules.js, invoicing-settings.js, line-mappings.js Settings sections a page mounts
messagePage({ title, text, tone, integration, organisation, mode, autoReturnSeconds }) server/ui/page.js MessagePage The shared HTML card after an integration approval (OAuth callback). Flow mark + maker logo, status line (organisation · mode), tick or cross, "Back to Flow". Success may auto-return after a few seconds with a polite countdown; failure never does. Every integration with an approval step uses this, not its own copy.

Adding a family

Add it to FAMILIES in server/theme/themes.js with every token in both modes (copy Harbour), list its faces in server/theme/font-families.js, add any new fontsource package to package.json, and run npm test. To take a newer OpenClaw, check it out and run node scripts/port-openclaw-themes.mjs <checkout> <commit>.