Jump to
↵select↑↓navigateescclose

Development

How it works

How Carbon Theme is put together: stylesheet shadowing, the token mapping, the brand stylesheet, the header, the table engine, the AI assistant and the drift guards.

Updated
On this page

This page describes the design behind the code, for developers and for anyone whose app, report or script runs under the theme. For the commands, the checks and the release flow, see Development.

The pieces

PieceWhereDelivered by
Shadow stylesheetscarbon_theme/public/scss/{desk,website,login,email}.bundle.scss, scss/carbon, scss/map, scss/desk, scss/webFrappe's build, under Frappe's own bundle names in assets.json
frappe-ui stylesheetcarbon_theme/public/scss/carbon_frappe_ui.bundle.scsscarbon_theme/injector.py (after_request)
Brand stylesheetcarbon_theme/brand.py, Carbon Settingspage_renderer, linked by app_include_css and web_include_css
Desk scriptscarbon_theme/public/js/carbon_{charts,desk,anatomy,tables}.bundle.tsapp_include_js
AI chatcarbon_theme/public/js/ai_chat/, scss/ai_chat/scripts/build-ai-chat.ts into public/dist/ai_chat/, imported on first open
Asset repairscripts/patch-assets.ts, carbon_theme/build.pythe app's yarn build; after_migrate and after_app_install
Drift guardsscripts/audit-tokens.ts, scripts/audit-markup.ts, scripts/markup-manifest.tsbench build (warn-only) and yarn audit:drift (strict)

Stylesheet shadowing

Frappe resolves every stylesheet through sites/assets/assets.json, keyed by bare bundle basename. This app shadows frappe's own bundles: it ships same-named entries (desk.bundle.scss, website.bundle.scss, login.bundle.scss, email.bundle.scss) that recompile frappe's SCSS sources with Carbon values injected at compile time, then layer Carbon tokens and component overrides on top. The server is thereby forced to serve the Carbon stylesheets instead of frappe's — one stylesheet per surface, no double download, no cascade fights.

Three mechanisms keep the shadow deterministic:

  1. Name collision — this app builds after frappe, so its entries win the assets.json merge.

  2. scripts/patch-assets.ts — runs as the app's build script after every full bench build; re-points the shadowed keys (and rtl_ variants) at this app's compiled assets and clears the redis assets_json cache.

  3. after_migrate and after_app_install hooks (carbon_theme.build.patch_assets) — re-assert the shadow after migrations and app installs, healing partial bench build --apps frappe runs.

The token mapping

Inside each bundle, the styling itself is a three-layer transposition:

  • scss/carbon/ — emits Carbon's ~240 design tokens as --cds-* custom properties (Light → Carbon g10, Dark → Carbon g100, keyed to frappe's data-theme), plus self-hosted IBM Plex @font-face rules.

  • scss/map/ — transposes frappe's variable system onto Carbon. The espresso raw color ramps (--gray-*, --blue-*, …) snap to @carbon/colors scales, and that is the primary color mechanism: frappe v16.50 declares its semantic layer (--surface-*/--ink-*/--outline-*) and its legacy names (--bg-red, --text-on-blue, …) as var() aliases onto the ramps, so the one remap reaches every status fill, tag and tint in both themes. Only the neutral roles that have an exact Carbon token are pinned on top (--bg-color → --cds-background, --control-bg → --cds-field-02, --ink-gray-8 → --cds-text-primary, --btn-primary → blue-60), plus radii → 0, focus → 2px $focus, elevations → Carbon's single menu shadow, and type → IBM Plex on Carbon's type scale. frappe-ui SPAs are the exception: they emit the semantic layer as literals, so map/_colors-semantic-spa.scss re-aliases it onto the same ramp (see frappe-ui SPA apps). The g100 UI Shell zone is the other: aliases frappe declares on :root resolve once, in the page's theme, and cannot re-resolve inside a dark island on a light page, so map/_colors-semantic-zone.scss re-declares the dark layer inside .cf-zone-g100.

  • scss/desk/ + scss/web/ — Carbon component anatomy: bottom-border fields, Carbon button ramps, the UI Shell header on Carbon's own cds--header classes (see UI Shell header), page actions flush on the title row, side-nav selection bars, 48px data tables, Carbon modals/menus/toasts/tags/tiles, and Carbon read-only states. Carbon's component CSS itself (data-table family, pagination, overflow-menu, header, header-panel, switcher, badge-indicator) is pulled in as mixins by scss/desk/_carbon-components.scss — one partial, because the desk bundle is assembled with @import and Sass re-emits a @used module's CSS for every importing file.

Why g10 and not White. Carbon's product UIs put the page canvas on $background and lift cards, tiles and the side nav to $layer-01. The White theme inverts that ladder ($background #ffffff / $layer-01 #f4f4f4), which renders as a white page with gray cards — the opposite of every Carbon reference product. g10 gives #f4f4f4 / #ffffff. The two themes differ in only 17 of 306 tokens, all in the background / layer / field / border-subtle family. Note border-subtle-00/-01 invert between g10 and g100, so all subtle hairlines resolve through an app-level --carbon-border-subtle alias rather than a Carbon token directly.

Charts

Charts get --charts-* theming plus carbon_charts.bundle.js, which wraps frappe.Chart and injects the authentic @carbon/charts 14-color categorical palettes whenever a caller doesn't pass explicit colors, and re-themes those charts when data-theme changes (frappe fires no theme event, so this observes the attribute). Chart chrome follows Carbon rather than the concept mockup: gridlines stay (Carbon names the grid as comprehension anatomy) and both axes are IBM Plex Sans.

When the shadow breaks

Two details of the shadow matter when something goes wrong:

  • TypeScript bundle entries: the four carbon_*.bundle.ts entry points are compiled by frappe's own esbuild, but frappe keys assets.json by the entry basename (esbuild.js:450), so a build writes carbon_desk.bundle.**ts** while hooks.py and include_script look up carbon_desk.bundle.**js** with no extension fallback. The .js key then keeps an older build's hash and the desk serves a swept or stale bundle — silently. scripts/patch-assets.ts repairs it on every bench build, carbon_theme/build.py repairs it on migrate/install, and yarn audit:drift detects it; but bench watch runs none of those, so re-run node scripts/patch-assets.ts after a watch session.

  • The shadowing relies on frappe's (undocumented) basename keying and merge semantics of assets.json. The orthodox fallback — app_include_css/web_include_css hooks — remains structurally compatible with this app's bundles if the shadow mechanism ever breaks.

Scripts beyond CSS

Small JS bundles cover what stylesheets cannot reach. Every theme patch delegates to the original, never throws and never half-applies — a missing target degrades to stock frappe styling and reports itself, rather than throwing inside desk.bundle.js.

  • carbon_desk.bundle.js — tags formatter output with carbon-num so numerics and dates get IBM Plex Mono.

  • carbon_anatomy.bundle.js — the Carbon UI Shell header (mounted into the empty <header> frappe leaves in www/desk.html; see UI Shell header), the AI assistant's trigger, panel host and lazy loader (the chat itself is not in this bundle; see AI assistant) and the page header (eyebrow + heading-04 title, which has no host element in frappe).

  • carbon_tables.bundle.js — not a theme patch but a functional replacement: one Carbon-styled, TanStack-driven table engine behind frappe's child-table Grid, List view and Report/Query views. See Tables.

  • scripts/audit-markup.ts — guards every selector, runtime shape and asset-shadow assumption the above depends on. See Drift guards.

The brand stylesheet

The colours themselves are set in Carbon Settings.

/carbon-brand.css, rendered per request by a page_renderer (brand.py) and linked after the bundles by app_include_css (desk), web_include_css (website and login) and injector.py (frappe-ui SPAs). Every value is a CSS custom property declared on the same elements the bundles use (html:root / html[data-theme=…], one specificity notch above the bundles' own :root / [data-theme=…], and the .cf-zone-g100 header zone), so it wins with no !important wherever the link lands — the login page renders login.bundle.css after web_include_css, and the theme's own Sass reads the same tokens with the stock value as the var() fallback (desk/_buttons.scss, map/_colors-legacy.scss, web/_navbar.scss). Shades are computed in Python rather than emitted as colour functions, since a function inside a custom property value the browser cannot evaluate is silently ignored. Nothing is emitted until something is set — an untouched site is byte-for-byte stock Carbon. Revalidated by ETag; a settings save is live on the next page load.

The dev indicator is decided in brand.is_dev(): carbon_dev_indicator in the site config, then a cookie of the same name, then the request's host name. carbon_theme/demo.py sets the site config key to 0 on the demo site the screenshots are taken from.

frappe-ui apps

Apps that build their own bundles (CRM, Helpdesk, custom portals) can't be reached by the bench pipeline. For those, this app publishes a standalone tokens-only stylesheet — carbon_frappe_ui.bundle.css — that re-themes frappe-ui's semantic variables (unlayered, so it beats Tailwind's @layer base regardless of order) and covers frappe-ui's hardcoded utility classes. injector.py, an after_request hook, adds it and /carbon-brand.css before </head> of every HTML response that carries Vite's module-entry signature (type="module" and crossorigin), so CRM, Helpdesk and other frappe-ui apps are covered with no per-app registration. For a page that lacks that signature, resolve the hashed path via the "carbon_frappe_ui.bundle.css" key in sites/assets/assets.json and include it after the app's own CSS.

frappe-ui v1 emits its semantic colours as resolved literals, which the ramp remap that colours frappe's desk cannot reach, so map/_colors-semantic-spa.scss re-declares the whole layer as aliases onto the remapped ramp and the neutral role pins land on top. That partial assumes one token generation, and the SPA bundles in the wild come in three:

frappe-uiNamesServed
>= 1.0.0-rc.1 (Helpdesk, Wiki, Builder)v2 names, chromatic ink scale shiftedThe design target.
1.0.0-beta.29 (CRM)v2 names, unshifted ink (frappe's own)Neutrals and chromatic surfaces and outlines exactly. Chromatic ink lands one ramp step off (ink-red-6 on the 600 step, not 500), which Carbon's ramp usually collapses to the same colour, but not always.
v0 and flow beta.3the OLD namesBest effort. The names v2 dropped (--surface-white, --surface-cards, --outline-gray-modals, …) are aliased onto their successors. The names v2 reused with another meaning (--surface-gray-5/6/7, --ink-<hue>-2/3/4) cannot be served beside v2's, so a v0 app gets the v2 value under them.

Serving each generation properly would take one bundle per generation; this app does not. The hardcoded-colour and .rounded-* utility overrides (map/_frappe-ui-utilities.scss) do not depend on the generation.

Email

  • The email.bundle shadow is Premailer-inlined into outgoing mail — literal values only (no CSS-variable support in that pipeline).

The UI Shell header

The header is Carbon's UI Shell, class for class — cds--header, HeaderMenuButton, HeaderName, HeaderNavigation + HeaderMenu, HeaderGlobalBar, HeaderPanel + Switcher — mounted as light DOM by js/anatomy/ui_shell.ts and styled by @carbon/styles' own header, header-panel and switcher mixins. It runs Carbon's g100 theme in both desk themes through a zone class (cf-zone-g100) that shares the dark theme's token emission.

Everything it shows is a projection of frappe v16.50's module sidebar (frappe.app.sidebar), which is what resolves a route to a shell (a key of frappe.boot.module_sidebars), names the app that owns it and renders its items. The dock beside it is frappe's module switcher inside an app, and the left panel is frappe's too; the header takes nothing off either.

  • Header name — <app title> <module label>, e.g. "ERPNext Projects" at /desk/projects, from sidebar.get_sidebar_app() and the shell's label; no prefix for a shell no app owns. On the launcher — the page that hides both the sidebar and the dock, which is /desk — it reads "Desktop". It links to the module's landing route (sidebar.module_landing_route, the route the dock's tile for that module opens).

  • Header links and sub-menus — the sidebar's top-level rows: a Link is a header link, a Section Break with children is a sub-menu. They are read from the sidebar's rendered DOM rather than from boot.module_sidebars, so frappe's own routing (get_route(), six branches, plus the shell prefix written into every desk href) and render decisions are inherited, never re-derived. aria-current is the one row frappe lights (.active-sidebar, written by find_active_item()), never a second rule. Carbon's header has no overflow behaviour, and a module sidebar carries anywhere from one to eighteen top-level rows (ERPNext's Stock has 13), so the bar is measured and the tail folds into a More ▾ sub-menu; below Carbon's lg breakpoint (66rem) the nav hides and the sidebar carries every link, as Carbon's guidance says it should.

  • Utilities — search, the notifications bell and the account menu, three cds--header__action cells the theme builds once. Each calls what frappe's own control calls, so frappe keeps the behaviour: search carries .navbar-modal-search-mobile, the class the awesome bar delegates its click to; the bell calls frappe.ui.sidebar_panels.toggle("notifications") and keeps the row's .sidebar-notification class, so frappe mirrors the panel's state into aria-expanded and does not count a click on it as "outside"; the account cell is sidebar.create_user_menu, the menu the sidebar chip and the dock avatar share. The unread badge is frappe's .notification-count, which it writes into wherever it is. The notifications panel is frappe's frappe.ui.SidebarPanel with its own content and dismissal rules; the theme re-hosts its element on <body> (the sidebar's container is hidden on the launcher) and draws it as a right header panel, g100, 360px, under the header. Opening the bell closes the switcher and the assistant, and any of them closing the bell is frappe's outside-click rule. frappe's own copies of these controls — the Search and Notification rows, the sidebar's user chip, the pinned dock's avatar and the launcher's own navbar — stay in the DOM and are not drawn while the header is mounted; apps that add rows to the launcher's avatar menu (desktop_page.add_menu_item) therefore have no route to them from the header.

  • Switcher — the far-right action opens a right panel that mirrors what the /desk desktop lays out. On the default Apps screen that is boot.app_data filtered to the apps that opted in (on_apps_screen), ordered by sequence_id, each leading where its tile does (sidebar.app_landing_route); the app that owns the shell on screen is selected. Modules are not in it, because the dock is the module switcher. On a site still set to the retiring Desktop Icons page it is the icon grid instead — boot.desktop_icons with the grid's own rules re-applied (hidden icons dropped, orphans promoted, idx then label order, a Folder or an App with workspaces under it as an expandable row) — and the icon that opens the shell on screen is selected. The Desktop launcher sits below a divider in both.

  • Hamburger — delegates to frappe's sidebar toggle and mirrors sidebar.sidebar_expanded in aria-expanded. Beside a pinned dock a collapsed sidebar slides shut instead of folding to a rail, and the control that brings it back stays enabled. It keeps the Menu glyph: Carbon's ✕ means "an overlay is open", and frappe's sidebar collapses rather than going away. The page head's own hamburger (page.html) is hidden while the header is mounted, so there is one.

Beside the dock and the sidebar. The header is fixed at the top, so its 3rem row is reserved on the dock (while pinned), the sidebar and the content column, and each is cut to the viewport minus the header; a panel beside the sidebar is too (it overflowed by the header's row). The pinned dock starts under the header, which keeps its logo visible. A floating dock is centred on the room that is left, and the dock sits at z-index 1022 — above the sidebar and its overlay, under the assistant (1025) and the header (1030). The dock's tiles are squared and take the sidebar's hover, selection bar and focus ring. The module name appears in the header and in the sidebar's own header, which stays because its menu is the only home of Edit Sidebar, Help and, on an app with no dock, the Modules submenu.

The header re-renders after Sidebar.prototype.make_sidebar (the one place frappe rebuilds the sidebar DOM; sidebar_setup fires before the state changes and is deliberately unused), re-marks the current link after highlight_active_item and re-reads the model after apply_page_visibility (where the launcher and the modules are told apart) and on every route change, rebuilding only when what it renders from changed. It never mounts where frappe fills <header> itself (read-only, impersonation, announcement widget, mobile), and everything above that hides frappe's own copies is gated on the header having mounted. The AI assistant lives on the header, so it is not available below the mobile breakpoint (innerWidth < 768): there is no trigger and no panel on a phone.

The table engine

carbon_tables.bundle.js replaces frappe's three unrelated table renderers — a Bootstrap 12-column grid, hand-built flex-div list rows, and the separate frappe-datatable library — with a single engine built on TanStack Table v9 and rendered as Carbon's light-DOM cds--data-table.

What it unlocks

  • Child-table grids past 10 columns, with columns sized by their ratio. Through frappe 16.33 a grid sized its fields as Bootstrap spans and stopped redistributing once the total passed 10, then latched .column-limit-reached and swapped in a hand-rolled horizontal-scroll hack. frappe 16.50 dropped the spans and sizes every column in pixels itself (Grid#get_column_width, grid.js:1531-1538: a df.width, else the docfield's columns read through LEGACY_COLSIZE_TO_PX as 60-600px, else a fieldtype default, else 140), and gives the row's spare width to the last column (.grid-data-last, grid_row.js:766-768), so on an ordinary screen most columns were too narrow for their contents and the last one took the rest. The theme draws the columns as a ratio instead (tables/grid/ratio.ts, unit-tested): each data column weighs its columns span (1-12, the Columns property of the doctype or Customize Form), or, without one, the span its fallback width stands for on frappe's own scale (140px is 3, a 100px Currency default 2), and the grid's width less the gutters is shared out in that ratio, in whole px that add up to it exactly. No column is drawn narrower than the width frappe's scale gives its weight (at least frappe's 60px), which is the width frappe itself drew it at: on a grid about as wide as frappe's pixel layout assumed the ratio comes out close to frappe's own widths, above that every column grows by its ratio, and below it the columns sit at frappe's widths and the table scrolls sideways. A width the user chose is kept exactly and the others share the rest: a header drag (the engine's onColumnResize, handed to frappe's own save_column_width, which writes the same per-user GridView setting Configure Columns does) or a Configure Columns width. Double-clicking a column's resize handle forgets its chosen width (the saved one goes back to the metadata width) and returns it to the ratio. frappe saves a width for every column on each drag and pre-fills the dialog for all of them, so a saved width only counts as chosen when it differs from what the doctype's metadata gives (read from frappe.meta.docfield_map, which setup_user_defined_columns does not overwrite). A docfield's own width is a weight, not a fixed width, and a df.sticky column keeps frappe's pixel width. The layout follows a ResizeObserver on the scroll viewport, one layout per animation frame, so a window resize, the sidebar, or a grid in a hidden tab that was 0px wide when it was built all settle on the next frame; frappe's own model (visible_columns, the dialog, the saved setting) is untouched and still in pixels. The widths live in a <colgroup>, so a column's cell is exactly as wide as its header.

  • Virtualized report rows. A 742-row report keeps ~32 rows in the DOM; the engine measured 50 000 rows rendering in ~400 ms.

  • Column resizing, reordering, pinning and Carbon sort headers on every surface, plus a spreadsheet-style cell-selection primitive (cellSelectionFeature) that frappe-datatable had and Carbon's own Datagrid does not, and a header menu on Report and Query views (Sort Ascending / Descending, Reset sorting, Remove column, Freeze, and Report View's Add Column) as a Carbon overflow menu.

  • One row-size vocabulary. Carbon has five row sizes and one rule — the header row matches the body row — so an arbitrary cellHeight (frappe asks for 33 and 35) snaps to the nearest and the header follows.

  • Child-table rows that expand into their form. frappe's row form is a centered pseudo-modal — GridRowForm renders inline but .grid-row-open .form-in-grid is position: fixed; top: 5%; left: 50%; width: 80% over a frappe.dom.freeze() backdrop. It is now Carbon's expandable row instead: a chevron in a leading cds--table-expand gutter unfolds a cds--child-row-inner-container beneath the row, which stays visible above it. The panel is the same GridRowForm and the same frappe.ui.form.Layout, so every field, tab, depends_on and get_query behaves as before. One row opens at a time, matching frappe's own toggle_view() accordion and the single-slot globals (cur_frm.cur_grid, grid.open_grid_row, $('.grid-row-open')) that the rest of the desk reads. Because the table scrolls horizontally, the panel is position: sticky and sized to the scroll viewport, so it stays where the reader is while the columns move behind it. A ⋮ overflow menu in a trailing gutter carries the row actions (Insert Above/Below, Duplicate, Move, Delete) plus Open in dialog, which restores the old centered form for child doctypes too tall to read inside a row.

  • A Carbon table toolbar instead of a footer button strip. frappe's .grid-footer becomes cds--table-toolbar: Add row as the primary action, Add multiple beside it, Download / Upload / Configure Columns as cds--toolbar-action icons, and a magnifier that toggles frappe's per-column filter row (upstream that row only appears past rows_threshold_for_grid_search, 20). Selecting rows raises Carbon's cds--batch-actions bar over the toolbar with the selection count and Delete / Duplicate / Cancel. Pagination moves below the table as cds--pagination. Every button is frappe's original element moved, never rebuilt — the data-action handlers bind_actions_with_object wired onto them, the cached grid.*_button handles, and the .hidden / .d-none toggles in setup_toolbar() all keep working.

What does not change

Compatibility is the point. Nothing here alters a public frappe API:

  • Grid — CarbonGrid extends Grid and CarbonGridRow extends GridRow, overriding only the methods that produce or size DOM, plus show_form() / hide_form() for the expandable row. refresh(), add_new_row(), update_docfield_property(), get_field(), toggle_view(), insert(), move(), remove(), bulk edit, CSV upload, Sortable row dragging, the Configure Columns dialog and GridRowForm itself are all frappe's own implementations running against new DOM. Cells are still the div.grid-static-col elements GridRow.make_column() builds, complete with their .static-area / .field-area pair and their mounted controls — the engine positions them, it never rebuilds them.

  • Report and Query views — window.DataTable and frappe.DataTable become a frappe-datatable-compatible facade. The option surface, the getEditor protocol, events, hooks.columnTotal, and the datamanager / rowmanager / columnmanager / cellmanager / bodyRenderer / style sub-managers are reproduced against measured call sites — including style.setStyle(), which 13 places across frappe, ERPNext and app reports use to paint individual cells.

  • Report-view editing — frappe-datatable's grid interaction is reproduced (focus ring, arrows, shift-range, ctrl+C as TSV, Enter to edit) and extended: ctrl+V pastes a TSV block over the selection (a single value fills a range) with each value coerced to the target fieldtype or refused — numbers in the user's number format, dates in the system or user format, Selects by option, Links validated by the server — and every write goes through the report view's own getEditor().setValue, so the server, report_view.data and the charts stay in step and a rejected value reverts. Space toggles a focused Check cell in place. A multi-line editor (Small Text / Text) is a framed popover that grows below the cell; Enter is a newline there and ctrl+Enter commits.

  • List view — get_column_html() and get_meta_html() are reused verbatim as the cell renderers, so listview_settings (formatters, get_indicator, button, dropdown_button, hide_name_column) behaves exactly as before. What frappe's flex layout did for free, the adapter does itself: a column with a saved width (df.width, a saved layout) gets it, and every other column is sized from its content (tables/widths.ts; v16.50 dropped frappe's own estimator, because CSS flex shared the space out); a header drag is persisted through save_column_width, as frappe's own handle does. The selection stays frappe's checked_docnames Set, pruned against the new data on every render, and repainted onto rows the engine recycles. FileView's list mode has no column model and keeps frappe's own rows.

  • The legacy DOM contract is re-emitted alongside Carbon's. Every row and cell carries its dt-*, grid-* or list-row-* class as well, so existing app CSS, jQuery and setStyle selectors keep resolving. The mapping lives in one file per adapter (tables/*/classes.js).

Known limits

  • frappe/data_import/import_preview.js and system_console.js hold module-local frappe-datatable imports that a global reassignment cannot reach. They keep using the stock library, which remains installed and themed.

  • The child-table panel keeps frappe.dom.freeze_count balanced by hand: GridRow.show_form() freezes and hide_form() unfreezes unconditionally, so inline mode counterweights both rather than skipping them. If frappe ever makes that pairing conditional, the guard in scripts/markup-manifest.ts will catch it.

  • Column pinning is declared but inert on child tables. The engine builds its columnPinning state once, in the constructor, from the columns it was handed — and the Grid constructs the engine with an empty column list, filling it later through setColumns(), which does not revisit pinning. So df.sticky and the _expand / _check / _index gutters do not actually freeze on a horizontally scrolled child table. The List and Report adapters pass their columns up front and are unaffected. The expanded panel does not rely on this — it sticks on its own.

  • The list scrolls inside the engine (.result-container is a bounded box and the engine's scroller fills it), so its header and its pinned Subject and meta columns stay put, and rows are windowed at frappe's own virtualization_threshold (2000, reachable at the 2500 page size) with the selection repainted as the window moves. frappe's lazy list_view_virtualization bundle is never used, only preloaded by refresh() and the page-size buttons (one small request). On a phone set_result_height leaves the box unsized and the page scrolls, so nothing is windowed there.

  • frappe.ui.EmbeddedList (ui/embedded_list.js, which DocType Settings and Workspace use) is a plain <table> of its own and is not rendered by the engine.

The AI assistant

When flow, the Frappe AI app, is installed, the header gets one more action, directly before the app switcher. It opens a chat panel built from Carbon AI Chat's web components (@carbon/ai-chat-components) that docks to the inline-end edge at 360px and expands to the full width of the window. The panel talks to flow's whitelisted methods (flow.api.start_run, resume_run, stop_run, recover_session, submit_feedback, attach_file) and reads Flow Run, and reads, renames and deletes Flow Session, through frappe.client. Attachments also use frappe's upload_file and delete the File docs the panel itself uploaded. Ctrl+I toggles it. Without flow in frappe.boot.versions, or on a read-only site, there is no button, no host element and no listener.

It replaces flow's own slide-over rather than sitting beside it. How the code is split and delivered is under Deliberate deviations from Carbon; the behaviour that matters to a user or an integrator is below.

  • Flow takeover, and what happens when the chat does not load. At mount the header snapshots flow's flow-panel-state, hides flow's panel (frappe.flow.panel), hides #flow-root through body.cf-ai-chat and takes Ctrl+I. If the chat bundle cannot be loaded (never built, network, a stale entry), the panel shows an error with Retry, Reload the page (offered once an import has failed, because a failed chunk stays failed until the page reloads) and Open the Flow panel. At that moment the theme gives Ctrl+I and #flow-root back to flow, but it never shows flow's panel by itself: the user asked for ours, and two panels at once would be worse than the error. Opening flow's panel is the button. A Retry that works takes flow's panel over again.

  • Phones. There is no trigger and no panel below the mobile breakpoint (innerWidth < 768, frappe.is_mobile()): frappe keeps its own navbar there, so the header, and with it the assistant, is not mounted, and flow's own panel is left as flow made it. This is a limitation of where the action lives, not a setting. A narrow desktop window (up to 42rem) gets the panel at full width with the page behind it inert, as does the expanded panel.

  • Conversations. The Conversation history action in the chat header (pressed while the list is open, disabled while a reply streams) lays a list over the message area. It is not the shell's own history slot: Carbon's shell renders that slot only when it is at least 640px wide (--cds-aichat-messages-min-width plus --cds-aichat-history-width, 320px each), and the panel is 360px. The list shows your own Flow Sessions, newest first, whatever started them except flow's triggers (owner is you, source is not Trigger), grouped as Today, Yesterday, Previous 7 days, Previous 30 days and Older. It asks for the 100 most recent and searches those on the client by title; when exactly 100 came back, a note says older conversations exist that search cannot find. Choosing one recovers it (flow fails any Running run whose stream is gone) and rebuilds the transcript from Flow Session and its Flow Runs, so a conversation paused on an approval comes back with its approval card live. Each row has a menu: Rename edits Flow Session.title in place (shown at once, put back if the server refuses) and Delete asks for confirmation, then deletes the session and, through flow's on_trash, its runs; there is no undo. Deleting the conversation that is open resets the panel to a new chat and leaves the list open. A quick second choice wins over a slow first one, and the list shows a skeleton, an empty state or an error with Try again instead of a blank.

  • Several tabs. Streaming holds a lease in localStorage (cf-ai-stream, refreshed while a reply is read). Another tab that opens a conversation then skips recover_session, which would fail the streaming tab's live run, and refuses to delete the session that tab is streaming. Nothing else is synchronised between tabs: the list is fetched again each time it opens.

  • Feedback. A finished reply has thumbs up and down. Up is sent at once; Down opens a comment box and is sent with it. Selecting the selected thumb again withdraws the rating (submit_feedback with None). The call is optimistic: if the server refuses (a run that is not finished, for example), the thumb goes back and an alert shows the server's reason; the comment typed for a refused Down is lost. Ratings come back from Flow Run.feedback_rating when a conversation is restored or selected. A Down comment is stored by flow as agent memory only when the agent has memory enabled.

  • Times. Each message shows its time in the avatar line, with the full date as its title and accessible label: the time alone for today, otherwise date and time. The time is the turn's start, taken from Flow Run.creation for the question and the answer alike: a Flow Session Message row carries the session's creation time, so the run is the only per-turn clock. Frappe stores these datetimes as naive strings in the system time zone; the panel converts them from frappe.boot.time_zone.system and shows them in frappe.boot.time_zone.user with Intl, so a browser in a third zone does not shift them. (frappe.datetime returns formatted strings, not instants, and frappe-types declares no accessor for the instant.)

  • Long conversations. Selecting or reloading a conversation builds only the last 30 or so messages, snapped back to the question that starts the turn. Show earlier messages prepends 30 more at a time, keeps the reader's scroll position and moves focus to the row that was first. A 200-message conversation opens without a long task. Two buttons inside the message area, Beginning of the conversation and End of the conversation, are visually hidden until focused and jump into the rows. Up arrow in an empty prompt also moves to the last message; from a message the arrow keys move between messages (Up from the first one built goes to Show earlier messages while earlier ones are hidden), Home and End go to the first and last, and Escape returns to the prompt with the panel still open.

  • Attachments. The prompt has an Attach files button; files can also be dropped anywhere on the panel (an overlay and an announcement say so) or pasted into the prompt. A paste becomes attachments only when the clipboard holds files and no text/html, so a screenshot or a file copied in a file manager attaches while a spreadsheet selection still pastes as text. What may be attached is flow's call: the types are frappe.boot.flow_supported_file_types (plain text and data formats such as CSV, JSON and Markdown, PDF, DOCX, XLSX, HTML and images), matched against the file name the way flow matches it, and the size limit is the site's max_file_size. At most five files go with one message; flow has no such limit, but it puts every inline file into the prompt again on each later turn, so the cap protects the context window. The browser checks type, size, emptiness, duplicates, name length (140 characters) and count first and names each refusal in an error line under the prompt (several are grouped by reason, so the line fits the room the component gives it), so nothing is uploaded for a file that cannot be used. An accepted file is uploaded as a private File (upload_file with is_private=1, through XMLHttpRequest because fetch reports no upload progress), then flow.api.attach_file reads it: the extraction is synchronous, and images and scanned PDFs go through OCR, so it can take seconds. Each file shows as a chip that is uploading, ready or failed with the server's reason; a progress strip with Cancel shows while anything is in flight, since Carbon's uploading chip has no remove button. Send is held while an upload runs and while one has failed (remove it first). A message cannot be only files, because start_run rejects an empty input; the prompt says to add text. While an approval waits, attaching is off, because resume_run takes no attachments. On send, the files' names go to start_run as attachments; flow stores a Flow Session Attachment row per file and puts its text into that turn's prompt, either whole (clamped to the context window) or, for a file too large to inline when flow has an embedding model, as the best-matching chunks. The panel shows neither mode nor truncation. Sent files show as read-only chips under the question, and come back from those rows after a reload (name only: the rows carry no content type or URL, so there is no preview).

  • Attachments, and what happens to the files. A file the user removes, cancels or that fails is deleted again (the owner may delete a File; best effort, a failure is logged). A file that was sent belongs to the conversation: the panel never deletes it. Flow removes the File docs only in its retention purge (clear_old_logs, 30 days idle), not when a conversation is deleted, so after a delete they remain as private unattached Files until then. A File that a Flow Session Attachment row links cannot be deleted by anyone else. New chat discards staged files; switching to another conversation keeps them, like the draft text. A tab closed while a file was uploading leaves that File behind, and nothing in flow reclaims it. Not done: page context. The panel does not tell the model which document the user is looking at, and it will not prepend that to the message, because the text would end up in the stored transcript and in the conversation title; it needs a context argument on start_run.

  • Setup mode. With no enabled Flow Agent and no saved conversation the panel shows what to configure (System Managers get the steps), the prompt is disabled with the placeholder "Finish setup to start chatting", and the history action still works.

Developer and integration seams

State lives in localStorage under these keys; every read and write is guarded, so a browser that blocks storage behaves as if all were absent.

KeyValueWritten by
cf-ai-panel{"v":1,"open":bool,"expanded":bool}the header (open, close, expand, collapse)
cf-ai-sessionthe open Flow Session name; "" is a deliberate new chatthe chat; seeded once from flow's state
cf-ai-session-userthe user cf-ai-session belongs to; a different user starts a new chatthe header
cf-ai-agentoptional Flow Agent name for new conversationsnothing in the product
cf-ai-streamthe streaming tab's lease, <owner>@<epoch ms>@<session>the chat
flow-panel-stateflow's own state; read once, rewritten once with open: falseflow (the header rewrites it once)

cf-ai-agent is a supported seam, not a leftover. Set it before a conversation's first message and start_run is sent with that agent, so an integration (or a developer) can run the panel against an agent other than flow's default Assistant: localStorage.setItem("cf-ai-agent", "Support Agent"). It applies to new conversations only (an existing session is bound to its agent), the product never writes it, and the browser suite sets it to run against its own CF AI Test Agent. A name that does not exist, or that the user cannot read, makes the send fail with flow's message in the reply.

Attaching files is on only when flow says what it can read. The chat reads two keys of frappe.boot, once, when it mounts: flow_supported_file_types (flow's extend_bootinfo hook, flow.boot.boot_session: the extensions its extractor handles, lower case and without a dot) and max_file_size (frappe's; the limit falls back to 5 MiB, frappe's own client default, when it is missing). When the first is absent or empty, for example on an older flow, there is no Attach button, no file input, no drop overlay and no chip list, and a dropped or pasted file is left to the browser as if the chat were not there. A change to either key shows after a reload. The code is in ai_chat/uploads.ts (the rules: limits, validation, the wording of refusals, the mapping between pending files, structured_data and chips; no DOM) and ai_chat/flow/client.ts (uploadFile, attachFile, deleteFile, with the XHR injected so the tests drive it). ai_chat/controller.ts runs each upload's lifecycle and owns the deletion of Files, ai_chat/store.ts holds the pending uploads in the shape of Carbon AI Chat's own (pendingUploads, hasInFlightUploads), and ai_chat/view/attachments.ts is the composer, the drop target (its enter/leave counter is ai_chat/drag_depth.ts) and the sent chips.

Drift guards

Both run warn-only on every bench build and strict via yarn audit:drift.

  • scripts/audit-tokens.ts — Carbon token renames; every custom property scss/map pins still declared by frappe (read from css/espresso/*.css and its SCSS), and each theme-owned shim still needed; frappe's semantic layer still a var() chain onto the ramps (the premise the ramp-primary colour mapping rests on); scss/map still pinning every neutral role and no chromatic slot; each of frappe's four bundles (desk, website, login, email) still mirrored by the shadow bundle that replaces it, in both directions, and every file our bundles import from frappe (its source, or a package in its node_modules such as ~plyr) still there; the g10 and g100-zone premises; the frappe runtime shapes the theme patches; every cds--* class the theme emits or targets still existing in @carbon/styles; the generated icon files still what scripts/lib/icon-manifest.ts and the installed @carbon/icons generate (rebuilt in memory and compared); every frappe sprite icon the theme still names still defined; and every legacy fa-* / octicon-* class an installed app emits bridged or deliberately skipped, with each manifest entry still emitted by the apps it names. That last check reads every app installed next to frappe, says which, and fails if an app flake.lock pins (frappe, erpnext, hrms) is not among them; CI checks out all three, so entries for other apps (print_designer, helpdesk, wiki, webshop) are verified only on a bench that has them.

  • scripts/audit-markup.ts — every frappe class the stylesheet targets, every runtime shape js/anatomy/* wraps, the frappe declarations we deliberately override, and the asset shadow itself. Declared in scripts/markup-manifest.ts so the guard and the code cannot drift apart.

These exist because all of these failures are silent: the theme keeps loading and a component quietly reverts to stock frappe styling.

yarn audit:drift also runs scripts/build-ai-chat.ts --check, which builds the AI chat in memory and fails when react, react-dom or tiptap reach the first-open set, when that set (JavaScript and stylesheet) exceeds 400 KB gzip, when an output would match frappe's *.bundle.* pattern, or when a public/dist/ai_chat on disk is broken or older than its sources. It is the guard behind the size numbers in Deliberate deviations from Carbon.

Deliberate deviations from Carbon

Recorded rather than claimed as conformance:

  • The theme owns its icons, and they are Carbon's. Frappe v16 keeps removing icon systems from the desk (octicons in #39836, FontAwesome in #40571, both first shipped in v16.50.0), and a theme that re-imports whatever stylesheets frappe ships inherits each removal as a build break or as icons that silently stop drawing. So the glyphs the theme draws (the UI Shell header, the table sort, toolbar and expand glyphs, the title's edit affordance) are rendered at codegen time from @carbon/icons into committed modules (js/generated/shell-icons.ts, js/generated/icons.ts), and the legacy fa-* / octicon-* classes that frappe's own code and installed apps still emit (a private file's fa-lock, the uploader's fa-spinner fa-spin, print_designer's layer icons) are drawn as CSS masks of Carbon glyphs (scss/generated/_legacy-icons.scss), painted in ::before as the icon font was, so an app's own display or padding on the element cannot collapse the glyph. Everything is named in one place, scripts/lib/icon-manifest.ts; yarn codegen regenerates it, and yarn audit:drift fails if a generated file is stale, a Carbon glyph was renamed, an installed app emits a legacy class the manifest neither bridges nor deliberately skips (LEGACY_UNMAPPED, with a reason each), or an entry names an app that no longer emits it. Nothing from @carbon/icons ships at runtime. What stays frappe's: markup frappe renders itself (the list view's like heart, the grid's gear, sidebar and workspace icons), which this theme does not re-draw. The desk bundle's mirror of frappe's import list targets frappe ≥ 16.50; an older frappe in the pin makes audit:drift say so once rather than once per class.

  • Frappe's utility classes come after the Carbon layers, not where frappe puts them. Frappe imports common/utilities last, so a utility (.hidden, .flex, .p-2) beats any component rule that sets the same property at the same specificity. The Carbon layers are component rules in that sense (they replace frappe's), so the desk and website bundles import the utilities after them: a one-class Carbon rule such as display: flex would otherwise out-rank .hidden, and an element frappe hides by class would never hide. The utilities read only tokens the map layer has remapped, so they need no restyling. The frappe-ui overrides stay after them, as the theme's own last layer. The same bundles import espresso_components (the .es-* components frappe v16.50 renders its page head, menus, tooltips, tabs and badges with) and desk/full_height_page; without them those parts have no styles at all. Those files exist only in frappe ≥ 16.50, so the desk and website bundles do not compile against an older frappe (yarn compile stops on a missing stylesheet, and audit:drift says the pin predates the mirror).

  • The type scale is snapped, not copied. Carbon's productive scale has eight sizes (12 / 14 / 16 / 20 / 28 / 32 / 42 / 54); frappe v16.50 has eighteen. Each --text-* step takes the nearest Carbon size, a step midway between two snaps down (18 → 16, 24 → 20), and 48 keeps the 54 it had before frappe inserted two sizes and renumbered everything above them. map/_typography.scss has the table.

  • A few names are theme-owned shims. --border-radius, -sm, -md, -lg, --invert-neutral, --ink-white and --surface-modal are still read by frappe v16.50 but no longer declared by it; the theme declares them at the Carbon equivalent of what they meant, and the audit says so when frappe declares one again or stops reading it. The whole --focus-<color> family stays pinned because frappe's utility classes read it through interpolation, which a search for the name does not find.

  • IBM Plex Mono on tabular data. Carbon is silent on numeric font and alignment in data tables. Monospaced tabular figures are a project choice for financial data; charts deliberately stay Plex Sans, matching Carbon's data-viz references.

  • Light-DOM tables rather than @carbon/web-components. Carbon's own TanStack transition ships React and Web Component examples, and the Web Component set would have been the closer fit for an app that uses no framework. It was rejected for three reasons: @tanstack/lit-table is pinned to TanStack v8, which has no cellSelectionFeature (the primitive that reproduces frappe-datatable's range selection and ctrl+C); @carbon/web-components compiles its own copy of Carbon's SCSS into each component's shadow root, which is a second Carbon delivery channel alongside the pinned @carbon/styles; and shadow roots would put row and cell internals out of reach of datatable.style.setStyle() and of every third-party report stylesheet. @carbon/styles/scss/components/data-table gives the identical appearance in the light DOM — it is what @carbon/react renders.

  • Web components for the AI chat, in a lazy bundle. The assistant panel is the one place this app does use @carbon/ai-chat-components (Lit custom elements with shadow roots): there is no light-DOM equivalent of its message, prompt-line and code-snippet elements to render against, and re-implementing them would drift from Carbon's chat. The cost of the table rejection above applies in full, so it is contained instead of accepted:

    • Never in the desk bundle. Built as one IIFE by frappe's esbuild the chat is 4.4 MB raw and 1.04 MB gzip, twice the whole desk.bundle.js (521 KB gzip) to open a side panel. scripts/build-ai-chat.ts builds it separately, with this app's own esbuild (0.28, pinned in devDependencies; frappe's is 0.14), as split ESM into public/dist/ai_chat/, and the header button import()s it on first click through manifest.json. A first open fetches the entry's static closure and the stylesheet, about 388 KB gzip as built with attachments (the spike measured 354 KB before the conversation list; budget: 400 KB, enforced by yarn audit:drift); the editor runtime, the table runtime and each code language are separate chunks fetched only when a message needs them. Nothing in the output is named *.bundle.*, which keeps it out of frappe's assets.json.

    • Deep-import prompt-line. register.ts imports prompt-line/src/{prompt-line-shell,prompt-line,send-control,stop-streaming-button}.js and never prompt-line/index.js: the index statically pulls in tiptap and ProseMirror, which takes the first open from 354 KB to 468 KB gzip. The build check fails if @tiptap/, react or react-dom reach the first-open set.

    • Attachments are in the first open. register.ts also imports file-uploads/index.js and prompt-line/src/error-message.js, which are Carbon's file-uploader family and nine document glyphs: about 17.6 KB gzip; the attachment code on top brings the first open to about 388 KB. A lazy chunk would save that but needs a wait for the element definition in two places, and the roughly 12 KB of headroom left under the budget is enough. The figures drift with every change to register.ts: node scripts/build-ai-chat.ts --check prints the exact current total and fails over budget.

    • Two paths shims, for the type checker only. skipLibCheck is off here, and two of the package's entry points cannot be checked: markdown/index.js imports types that upstream's own stripInternal removed from the file they live in, and feedback/index.js reaches @carbon/web-components' text-input.d.ts, which declares set value(v: string): any (TS1095, a syntax error in a .d.ts). [tool.frappe-nix.typescript] paths maps both specifiers to an empty module, ai_chat/shims/empty-module.d.ts; they are imported for registration only, and the elements' typed surface is the local interfaces in ai_chat/elements.ts. esbuild honours paths, so the build is deliberately given no tsconfig. Delete the shims when upstream fixes either file.