Carbon Frappe
On this page
The IBM Carbon (v11) design-language theme for Frappe and ERPNext. Every surface — desk, website/portal, login, email, charts, and frappe-ui/Espresso components — is restyled to look as though it was designed with Carbon: IBM Plex type, square geometry, Carbon color tokens, 2px focus rings, the g100 UI Shell header, and Carbon's categorical chart palettes, all sourced from the official @carbon/* npm packages.
How it works
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:
Name collision — this app builds after frappe, so its entries win the assets.json merge.
scripts/patch-assets.ts— runs as the app'sbuildscript after every fullbench build; re-points the shadowed keys (andrtl_variants) at this app's compiled assets and clears the redisassets_jsoncache.after_migratehook (carbon_frappe.build.patch_assets) — re-asserts the shadow after migrations, healing partialbench build --apps frapperuns.
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'sdata-theme), plus self-hosted IBM Plex@font-facerules.scss/map/— transposes frappe's variable system onto Carbon. The espresso raw color ramps (--gray-*,--blue-*, …) snap to@carbon/colorsscales, 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, …) asvar()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, somap/_colors-semantic-spa.scssre-aliases it onto the same ramp (see frappe-ui SPA apps). The g100 UI Shell zone is the other: aliases frappe declares on:rootresolve once, in the page's theme, and cannot re-resolve inside a dark island on a light page, somap/_colors-semantic-zone.scssre-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 owncds--headerclasses (see UI Shell header below), 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 byscss/desk/_carbon-components.scss— one partial, because the desk bundle is assembled with@importand 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
$backgroundand 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 thebackground/layer/field/border-subtlefamily. Noteborder-subtle-00/-01invert between g10 and g100, so all subtle hairlines resolve through an app-level--carbon-border-subtlealias rather than a Carbon token directly.
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.
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 withcarbon-numso numerics and dates get IBM Plex Mono.carbon_anatomy.bundle.js— the Carbon UI Shell header (mounted into the empty<header>frappe leaves inwww/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-04title, 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 below.scripts/audit-markup.ts— guards every selector, runtime shape and asset-shadow assumption the above depends on. See Caveats.
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, fromsidebar.get_sidebar_app()and the shell'slabel; 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
Linkis a header link, a Section Break with children is a sub-menu. They are read from the sidebar's rendered DOM rather than fromboot.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-currentis the one row frappe lights (.active-sidebar, written byfind_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'slgbreakpoint (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__actioncells 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 callsfrappe.ui.sidebar_panels.toggle("notifications")and keeps the row's.sidebar-notificationclass, so frappe mirrors the panel's state intoaria-expandedand does not count a click on it as "outside"; the account cell issidebar.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'sfrappe.ui.SidebarPanelwith 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
/deskdesktop lays out. On the default Apps screen that isboot.app_datafiltered to the apps that opted in (on_apps_screen), ordered bysequence_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_iconswith the grid's own rules re-applied (hidden icons dropped, orphans promoted,idxthen 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_expandedinaria-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.
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-rootthroughbody.cf-ai-chatand takesCtrl+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 givesCtrl+Iand#flow-rootback 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 itinert, 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
historyslot: Carbon's shell renders that slot only when it is at least 640px wide (--cds-aichat-messages-min-widthplus--cds-aichat-history-width, 320px each), and the panel is 360px. The list shows your ownFlow Sessions, newest first, whatever started them except flow's triggers (owneris you,sourceis notTrigger), 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 anyRunningrun whose stream is gone) and rebuilds the transcript fromFlow Sessionand itsFlow Runs, so a conversation paused on an approval comes back with its approval card live. Each row has a menu: Rename editsFlow Session.titlein place (shown at once, put back if the server refuses) and Delete asks for confirmation, then deletes the session and, through flow'son_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 skipsrecover_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_feedbackwithNone). 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 fromFlow Run.feedback_ratingwhen 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.creationfor the question and the answer alike: aFlow Session Messagerow 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 fromfrappe.boot.time_zone.systemand shows them infrappe.boot.time_zone.userwithIntl, so a browser in a third zone does not shift them. (frappe.datetimereturns 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 arefrappe.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'smax_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 privateFile(upload_filewithis_private=1, throughXMLHttpRequestbecausefetchreports no upload progress), thenflow.api.attach_filereads 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, becausestart_runrejects an emptyinput; the prompt says to add text. While an approval waits, attaching is off, becauseresume_runtakes no attachments. On send, the files' names go tostart_runasattachments; flow stores aFlow Session Attachmentrow 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 theFiledocs 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. AFilethat aFlow Session Attachmentrow 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 thatFilebehind, 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 acontextargument onstart_run.Setup mode. With no enabled
Flow Agentand 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.
| Key | Value | Written by |
|---|---|---|
cf-ai-panel | {"v":1,"open":bool,"expanded":bool} | the header (open, close, expand, collapse) |
cf-ai-session | the open Flow Session name; "" is a deliberate new chat | the chat; seeded once from flow's state |
cf-ai-session-user | the user cf-ai-session belongs to; a different user starts a new chat | the header |
cf-ai-agent | optional Flow Agent name for new conversations | nothing in the product |
cf-ai-stream | the streaming tab's lease, <owner>@<epoch ms>@<session> | the chat |
flow-panel-state | flow's own state; read once, rewritten once with open: false | flow (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.
Brand colours and the dev indicator
Desk → Carbon Settings (System Manager). Four colours, all optional; anything left empty falls back to Carbon's own value, so a site that sets one colour still gets a coherent palette.
The brand colour is not a header tint. It sets Carbon's interactive role, so one value reaches primary buttons, links, focus rings, selected rows, the sidebar selection bar and the active tab — everywhere the theme draws on --cds-interactive and --cds-button-primary. The header is configured separately, because tinting the bar and rebranding the controls are different decisions.
Light and dark are separate fields on purpose. Carbon does not reuse one interactive tone across themes: its own is Blue 60 #0f62fe on g10 and the lighter Blue 50 #4589ff on g100, because a mid-dark colour that reads well on #f4f4f4 does not carry against #161616. Set only the light one and the dark variant is derived by lightening it; set both to control each exactly. Hover, active and text-on-brand are derived too — hover darkens in light and brightens in dark, and the button label flips to near-black on a light brand (yellow, lime). Danger stays red-with-white regardless.
A header colour re-points every token Carbon's UI Shell CSS reads on the zone element (cf-zone-g100) — background, hover/active, the $layer family behind sub-menus and the notification/switcher panels, text, icons, focus, color-scheme — so a light header gets light menus rather than g100 blocks sitting on a coloured bar. The website navbar, footer and the theme-switcher preview strip follow through --carbon-header-* aliases. Carbon's own #161616 shell is left untouched: its values are hand-tuned and the shell parity tests assert them.
How it is delivered
/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.
Dev indicator
On localhost, 127.0.0.1, ::1 or any *.localhost host (frappe-nix benches serve <site>.localhost) the header is painted Carbon yellow-30 #f1c21b with near-black text and a diagonal hazard-stripe overlay running under the menu items (idle links go transparent; hover, active and open-menu states keep their tones), so a development tab can never be mistaken for production. The stripes stay on the bar alone — across the page they never line up between header, sidebar and content column. The brand colour still applies; the configured header colour is replaced on the bar but kept on the footer. It is decided server-side from the request host, so there is no flash.
Overrides, in order of precedence: carbon_dev_indicator in site_config.json (bench --site <site> set-config carbon_dev_indicator 0), then a carbon_dev_indicator cookie (scripts/tables/cdp.ts sets it to 0 after login, because the shell parity tests run against localhost and assert the g100 bar), then the hostname.
Not branded, deliberately
Espresso's status-blue slots (
--surface-blue-*,--ink-blue-*: Draft/Submitted pills, info callouts) — Carbon keeps blue as the informational colour regardless of brand.The dark focus ring, interactive icons and the tertiary button in dark mode — g100 draws them white.
Email (Premailer literals; see below) and, on website pages, Bootstrap-compiled utilities such as
.text-primary— set Website Theme'sprimary_coloras well if those matter.If a catch-all Website Route Redirect or a stale
website_404cache entry ever 404s/carbon-brand.css, the theme silently falls back to Carbon blue;frappe.clear_cache()(which Carbon Settings runs on save) clears the latter.
Tables
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 widths the engine owns. Through frappe 16.33 a grid sized its fields as Bootstrap spans and stopped redistributing once the total passed 10, then latched
.column-limit-reachedand 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: a saveddf.width, else a legacycolumnsspan, else a fieldtype default, clamped to 60-600;grid.js:1526-1538), and the grid inherits that model unchanged. What the engine adds is who lays the columns out: the widths seed TanStack's column-sizing feature and live in a<colgroup>, so the table scrolls horizontally like any table and a column's cell is exactly as wide as its header. A header drag is the engine's (onColumnResize, once per drag, the settled width in whole px), handed to frappe's ownsave_column_width, which writes the same per-userGridViewsetting Configure Columns does; the last data column takes any spare width (frappe's.grid-data-last) and every other column keeps exactly the width it was given. There is no cap.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 —
GridRowFormrenders inline but.grid-row-open .form-in-gridisposition: fixed; top: 5%; left: 50%; width: 80%over afrappe.dom.freeze()backdrop. It is now Carbon's expandable row instead: a chevron in a leadingcds--table-expandgutter unfolds acds--child-row-inner-containerbeneath the row, which stays visible above it. The panel is the sameGridRowFormand the samefrappe.ui.form.Layout, so every field, tab,depends_onandget_querybehaves as before. One row opens at a time, matching frappe's owntoggle_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 isposition: stickyand 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-footerbecomescds--table-toolbar: Add row as the primary action, Add multiple beside it, Download / Upload / Configure Columns ascds--toolbar-actionicons, and a magnifier that toggles frappe's per-column filter row (upstream that row only appears pastrows_threshold_for_grid_search, 20). Selecting rows raises Carbon'scds--batch-actionsbar over the toolbar with the selection count and Delete / Duplicate / Cancel. Pagination moves below the table ascds--pagination. Every button is frappe's original element moved, never rebuilt — thedata-actionhandlersbind_actions_with_objectwired onto them, the cachedgrid.*_buttonhandles, and the.hidden/.d-nonetoggles insetup_toolbar()all keep working.
What does not change
Compatibility is the point. Nothing here alters a public frappe API:
Grid —
CarbonGrid extends GridandCarbonGridRow extends GridRow, overriding only the methods that produce or size DOM, plusshow_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 andGridRowFormitself are all frappe's own implementations running against new DOM. Cells are still thediv.grid-static-colelementsGridRow.make_column()builds, complete with their.static-area/.field-areapair and their mounted controls — the engine positions them, it never rebuilds them.Report and Query views —
window.DataTableandfrappe.DataTablebecome a frappe-datatable-compatible facade. The option surface, thegetEditorprotocol,events,hooks.columnTotal, and thedatamanager/rowmanager/columnmanager/cellmanager/bodyRenderer/stylesub-managers are reproduced against measured call sites — includingstyle.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.dataand 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()andget_meta_html()are reused verbatim as the cell renderers, solistview_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 throughsave_column_width, as frappe's own handle does. The selection stays frappe'schecked_docnamesSet, 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-*orlist-row-*class as well, so existing app CSS, jQuery andsetStyleselectors keep resolving. The mapping lives in one file per adapter (tables/*/classes.js).
Known limits
frappe/data_import/import_preview.jsandsystem_console.jshold module-localfrappe-datatableimports 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_countbalanced by hand:GridRow.show_form()freezes andhide_form()unfreezes unconditionally, so inline mode counterweights both rather than skipping them. If frappe ever makes that pairing conditional, the guard inscripts/markup-manifest.tswill catch it.Column pinning is declared but inert on child tables. The engine builds its
columnPinningstate once, in the constructor, from thecolumnsit was handed — and the Grid constructs the engine with an empty column list, filling it later throughsetColumns(), which does not revisit pinning. Sodf.stickyand the_expand/_check/_indexgutters 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-containeris 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 ownvirtualization_threshold(2000, reachable at the 2500 page size) with the selection repainted as the window moves. frappe's lazylist_view_virtualizationbundle is never used, only preloaded byrefresh()and the page-size buttons (one small request). On a phoneset_result_heightleaves 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.
Install
bench get-app https://github.com/Avunu/carbon_frappe
bench --site <site> install-app carbon_frappe
bench buildbench get-app installs the npm dependencies (the app-root package.json); the SCSS imports @carbon/styles straight from the app's node_modules. If the first build fails with a missing-import error, run bench setup requirements (or simply re-run bench build) and build again.
Verifying
sites/assets/assets.json→"desk.bundle.css"should point at/assets/carbon_frappe/dist/css/…DevTools on
/app:--cds-backgroundpresent on:root;--bg-colorresolves through the--cds-*chain; toggling the theme (Settings → Toggle Theme) flips token values andcolor-scheme.Fonts: IBM Plex woff2 requests from
/assets/carbon_frappe/fonts/; no Inter requests.With flow installed:
/assets/carbon_frappe/dist/ai_chat/manifest.jsonanswers 200 and names anentry.<hash>.jsand achat.<hash>.cssthat exist next to it (yarn audit:driftchecks the same, and fails when the build on disk is older than its sources). It is requested only when the assistant panel is first opened.
Caveats (read before deploying)
Bench-global: assets.json is shared by every site on the bench, so the theme applies to sites that don't have the app installed. Run carbon_frappe on a dedicated bench.
Partial builds:
bench build --apps frappere-points the shadowed keys at frappe's assets until the next fullbench buildorbench migrate. Prefer plainbench build.bench watch: rebuilds of frappe's own bundles re-claim the keys mid-session. This is the most common way to lose the theme, and it has no symptom other than stock frappe styling reappearing — nothing errors.yarn audit:driftnow checks for it explicitly; the fix isnode scripts/patch-assets.ts.TypeScript bundle entries: the four
carbon_*.bundle.tsentry points are compiled by frappe's own esbuild, but frappe keysassets.jsonby the entry basename (esbuild.js:450), so a build writescarbon_desk.bundle.**ts**whilehooks.pyandinclude_scriptlook upcarbon_desk.bundle.**js**with no extension fallback. The.jskey then keeps an older build's hash and the desk serves a swept or stale bundle — silently.scripts/patch-assets.tsrepairs it on everybench build,carbon_frappe/build.pyrepairs it on migrate/install, andyarn audit:driftdetects it; butbench watchruns none of those, so re-runnode scripts/patch-assets.tsafter 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_csshooks — remains structurally compatible with this app's bundles if the shadow mechanism ever breaks.
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 propertyscss/mappins still declared by frappe (read fromcss/espresso/*.cssand its SCSS), and each theme-owned shim still needed; frappe's semantic layer still avar()chain onto the ramps (the premise the ramp-primary colour mapping rests on);scss/mapstill 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 itsnode_modulessuch as~plyr) still there; the g10 and g100-zone premises; the frappe runtime shapes the theme patches; everycds--*class the theme emits or targets still existing in@carbon/styles; the generated icon files still whatscripts/lib/icon-manifest.tsand the installed@carbon/iconsgenerate (rebuilt in memory and compared); every frappe sprite icon the theme still names still defined; and every legacyfa-*/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 appflake.lockpins (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 shapejs/anatomy/*wraps, the frappe declarations we deliberately override, and the asset shadow itself. Declared inscripts/markup-manifest.tsso 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 above.
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/iconsinto committed modules (js/generated/shell-icons.ts,js/generated/icons.ts), and the legacyfa-*/octicon-*classes that frappe's own code and installed apps still emit (a private file'sfa-lock, the uploader'sfa-spinner fa-spin, print_designer's layer icons) are drawn as CSS masks of Carbon glyphs (scss/generated/_legacy-icons.scss), painted in::beforeas the icon font was, so an app's owndisplayorpaddingon the element cannot collapse the glyph. Everything is named in one place,scripts/lib/icon-manifest.ts;yarn codegenregenerates it, andyarn audit:driftfails 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/iconsships 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 makesaudit:driftsay so once rather than once per class.Frappe's utility classes come after the Carbon layers, not where frappe puts them. Frappe imports
common/utilitieslast, 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 asdisplay: flexwould 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 importespresso_components(the.es-*components frappe v16.50 renders its page head, menus, tooltips, tabs and badges with) anddesk/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 compilestops on a missing stylesheet, andaudit:driftsays 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.scsshas the table.A few names are theme-owned shims.
--border-radius,-sm,-md,-lg,--invert-neutral,--ink-whiteand--surface-modalare 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-tableis pinned to TanStack v8, which has nocellSelectionFeature(the primitive that reproduces frappe-datatable's range selection andctrl+C);@carbon/web-componentscompiles 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 ofdatatable.style.setStyle()and of every third-party report stylesheet.@carbon/styles/scss/components/data-tablegives the identical appearance in the light DOM — it is what@carbon/reactrenders.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) just to open a side panel.scripts/build-ai-chat.tsbuilds it separately, with this app's own esbuild (0.28, pinned indevDependencies; frappe's is 0.14), as split ESM intopublic/dist/ai_chat/, and the header buttonimport()s it on first click throughmanifest.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 byyarn 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'sassets.json.Deep-import
prompt-line.register.tsimportsprompt-line/src/{prompt-line-shell,prompt-line,send-control,stop-streaming-button}.jsand neverprompt-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/,reactorreact-domreach the first-open set.Attachments are in the first open.
register.tsalso importsfile-uploads/index.jsandprompt-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 toregister.ts:node scripts/build-ai-chat.ts --checkprints the exact current total and fails over budget.Two
pathsshims, for the type checker only.skipLibCheckis off here, and two of the package's entry points cannot be checked:markdown/index.jsimports types that upstream's ownstripInternalremoved from the file they live in, andfeedback/index.jsreaches@carbon/web-components'text-input.d.ts, which declaresset value(v: string): any(TS1095, a syntax error in a.d.ts).tsconfig.base.jsonmaps 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 inai_chat/elements.ts. esbuild honourspaths, so the build is deliberately given no tsconfig. Delete the shims when upstream fixes either file.
frappe-ui SPA 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. 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-ui | Names | Served |
|---|---|---|
>= 1.0.0-rc.1 (Helpdesk, Wiki, Builder) | v2 names, chromatic ink scale shifted | The 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.3 | the OLD names | Best 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.
The
email.bundleshadow is Premailer-inlined into outgoing mail — literal values only (no CSS-variable support in that pipeline).
Development
The repo is a frappe-nix app: direnv allow (or nix develop --no-pure-eval) gives a shell with Node 24, yarn, uv, chromium and a bench — frappe, erpnext and hrms from the flake's pinned inputs, this app symlinked in — and devenv up + provision-site bring the site up. Every check below runs there, and CI runs the same commands.
yarn check # format, lint, types, the frappe-major guard, unit tests — what `lint`/`typecheck` gate
yarn format # oxfmt: TS, JSON, SCSS, YAML, TOML, Markdown (tabs, 110 columns)
yarn lint # oxlint + stylelint
yarn lint:py # ruff check + ruff format --check
yarn typecheck # tsc --build, all three projects
yarn typecheck:py # ty, against the bench's interpreter (where `frappe` is importable)
yarn test:unit # node:test over the pure modules, with a 50% line-coverage floor
yarn compile # compile all bundles against the pinned frappe (.dev-dist/)
yarn audit:drift # strict drift audit against frappe's source, plus the chat bundle's size budget (builds in memory)
yarn build:chat # build the lazy AI chat bundle into public/dist/ai_chat (also part of `yarn build`)
yarn watch:chat # the same, unminified, rebuilding on save; run it next to `bench watch`, which does not build the chat
yarn codegen # refresh vendored IBM Plex fonts, @carbon/charts palettes, and the @carbon/icons glyphs + legacy icon classes (scripts/lib/icon-manifest.ts)
yarn test:tables # browser tests for the table engine and its adapters (needs the bench up)
yarn test:shell # browser tests for the UI Shell header and the AI chat (needs the bench up); `yarn test:shell assistant` runs the chat suite alone
bash ci/integration.sh # what CI's `integration` job runs: the site-backed suites end to end
node scripts/dev-table.ts --serve # the engine alone, on :8123, with no benchConventions, and what enforces them
| Surface | Format | Lint | Types | Tests |
|---|---|---|---|---|
| TypeScript (browser, scripts, tests) | oxfmt | oxlint — no-explicit-any and ban-ts-comment are errors: no escape hatches | tsc --build, strict, skipLibCheck: false | node --test (unit), CDP suites (integration) |
| SCSS | oxfmt | stylelint (standard-scss) + audit-tokens | — | compiled by nix build and yarn compile |
| Python | ruff format (tabs) | ruff — frappe's set at our strictest app's level + SIM/C4/PIE/PERF/T20; semgrep with frappe's rules | ty | bench run-tests --app carbon_frappe |
| JSON / YAML / TOML / Markdown | oxfmt | — | — | — |
| Workflows | oxfmt | actionlint + zizmor (third-party actions hash-pinned) | — | — |
| Nix | nix fmt | statix + deadnix; nix run .#relock must leave nix/ unchanged | — | nix build |
| Commits | — | committed (conventional, frappe's types); no !/BREAKING CHANGE — see Releasing | — | — |
Doctype JSON is left exactly as frappe's exporter writes it. Generated files (public/js/generated, public/scss/generated, the fonts) are formatted by their generators and CI fails if yarn codegen would change them.
The git hooks are prek's (uv run --frozen --project tools prek install, once per clone; prek run --all-files is what CI runs). Every hook calls a binary this repo pins — the JS tools through package.json, the Python ones through tools/pyproject.toml — so a developer's shell, the hook and CI run one version, and dependabot is the only thing that moves it. In a nix shell without a CA bundle exported, uv sync may need SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt.
nix/node-locks/ holds forced fallback locks for the nested frontends (frappe/ui, erpnext/banking, hrms/frontend, hrms/roster): upstream ships a yarn.lock for each that is stale against its package.json, so frappe-nix's offline yarn install cannot use it as is. After a frappe/erpnext/hrms bump, nix run .#relock -- --node-locks frappe/ui erpnext/banking hrms/frontend hrms/roster refreshes them (seeded from upstream's lock, gaps filled from the registry). docs-site/ (this documentation's site, built with npm by docs-publish.yml) is a nodeNestedFrontendExcludes entry in flake.nix, so the bench neither installs nor builds it and relock writes no lock for it. A plain nix run .#relock is a no-op on a current tree, and the bench job fails when it is not.
docs/README.md is the documentation site's copy of this file, and must stay identical to it: a prek hook (and so CI's lint) fails when they differ. Edit README.md, then cp README.md docs/README.md.
scripts/test-shell.ts runs the same harness over the header, against ERPNext's Projects sidebar (7 rows, two sub-menus) and its 13-row Stock sidebar, both read from the site's module_sidebars: the name and links, sub-menu keyboard behaviour, aria-current (one row, the one frappe lit), the overflow, the switcher (the apps screen, forced by taking boot.desktop_icons out for one block, and the icon grid when the site is set to it), the hamburger beside a pinned dock, search, the bell and its panel, the badge and the account menu, the layout beside the dock and sidebar (including a floating dock), g100 parity in both themes, the dialog z-order, the landing page and the mobile no-mount case.
The pagehead suite (CF_SITE_URL=http://127.0.0.1:8889 yarn test:shell pagehead) covers the page head under the header, on documents every site has (a Role, a Language) that it reads and never saves: the breadcrumb trail as a Carbon eyebrow and 28px heading, the status as a Carbon tag beside the heading (placement, silhouette and the red / blue / green tag colours in both themes), the editable title (Edit glyph on hover and focus, a keyboard-reachable button, frappe's Rename dialog opening from a click and from Enter, and surviving a repaint of the trail), a list's single-crumb head with no rule, the page actions as one flush set of Carbon buttons, the Menu as an es-menu panel above the header, the dialog footer's full-bleed pair, and the one-hamburger rule between 768 and 991px.
The assistant suite (CF_SITE_URL=http://127.0.0.1:8889 yarn test:shell assistant; CF_SITE_URL defaults to http://localhost:8794) covers the AI chat end to end against a bench that has flow installed and yarn build:chat run: the header action and flow takeover, that the first desk load requests none of the chat bundle, the lazy open and its loading and failure states, streaming, sanitised markdown, tool steps, approvals (approve, deny, redirect), stop, errors and Try again, scroll pinning, keyboard, both themes, RTL, narrow and reduced-motion hosts, reload restore, then the conversation list (list, search, switch, rename, delete, windowing of a 200-message conversation), feedback, message times, the error layout, Try again, the setup-mode prompt and the reduced-motion dots, then attachments (picker, drop and paste with their refusals, client validation and flow's own refusals, progress and cancel, send, restore after a reload, Try again keeping the files, an open approval) against the mock model's FILES reply, which names the files it was given. Flow's model is scripts/shell/mock-llm.ts, an OpenAI-compatible server the suite starts itself (it also runs alone: node scripts/shell/mock-llm.ts --port 8950), so no provider key is needed. The suite creates a CF AI Test Flow Model, two Flow Tools, a Flow Agent, the sessions the cases need (the chats it has through the UI, a 200-message one and a trigger-sourced one that must never be listed), the ToDos the approvals create and the CF AI Test File docs its uploads leave, and removes all of it when it ends; after a killed run, node scripts/shell/assistant.ts --cleanup-only does the same. Flow's own panel is part of the takeover cases, so sites/assets/flow must be linked to apps/flow/flow/public (bench build links it; a dev bench that has not built since flow was added or its directory renamed has no such link). Without flow the suite checks that nothing of the assistant appears and exits 0. Screenshots land in .dev-dist/screenshots/.
scripts/test-tables.ts drives headless Chromium over the DevTools Protocol (no dependencies beyond Node 24 and chromium on PATH) against a running bench. It covers the engine in isolation, then each adapter's frappe contract: the dt-* selectors, style.setStyle, rowmanager.getCheckedRows, a report script's get_datatable_options / formatter / after_datatable_render / getEditor hooks, the Grid's inherited API, its >10-column layout, frappe's pixel column widths (df.width, a header drag and its GridView persistence, Configure Columns, a short grid's last column filling the spare width), its toolbar labels and its pagination, the List view's bulk actions, editing (the Check editor, the multi-line popover, paste), and g100 parity. Most of those assertions exist because that exact thing regressed once — several are specificity guards against frappe-datatable's, Bootstrap's or Carbon's own stylesheet quietly winning.
scripts/dev-table.ts builds the engine and a fixture page into .dev-dist/ with no frappe present at all, which is where engine behaviour is verified before any adapter is involved.
yarn build:chat is the one build step bench watch skips, so the panel 404s on a bench that has never run it: the loader reports "could not be loaded" and offers Flow's own panel instead (see AI assistant for why that is a button and not an automatic switch). Run it once per checkout, and after a dependency bump. While working on the chat, keep yarn watch:chat running next to bench watch: it rebuilds js/ai_chat/** and scss/ai_chat/** on save and rewrites manifest.json last; a page picks the new build up when it is reloaded, because the entry is imported once per page. bench watch and yarn watch:chat do not touch each other's output, but two chat builds at once would race on public/dist/ai_chat, so run only one. It is the last step of yarn build so a failing chat build cannot skip patch-assets.ts. public/dist/ai_chat is gitignored and holds content-hashed files plus a manifest.json that is renamed into place last, so rebuilding under an open page never leaves a manifest naming a missing file.
@carbon/ai-chat-components (like @carbon/styles, @carbon/icons and @carbon/web-components) has an ibmtelemetry postinstall that reports which of its elements and attributes a project uses. Per @ibm/telemetry-js, it collects only in CI and container environments, never on a local machine, and IBM_TELEMETRY_DISABLED=true turns it off. Set it for any yarn add or yarn install in a CI job or container you do not want counted; the nix build installs with --ignore-scripts, so it never runs there.
scripts/dev-compile.ts replicates frappe's exact sass pipeline (legacy API, includePaths = app roots + node_modules, ~ importer), so bundles can be smoke-tested without a bench. The shell exports FRAPPE_PATH; set it yourself outside one.
CI
.github/workflows/check.yml, on every pull request and push to develop: lint (prek over the whole tree, commit subjects, semgrep, the tools lock), typecheck (tsc, unit tests, codegen freshness, then yarn compile and yarn audit:drift against frappe, erpnext and hrms at the revisions flake.lock pins — checked out with git side by side, no nix), bench (nix fmt, statix/deadnix, nix run .#relock leaving nix/ unchanged, nix build — the app compiled in a clean bench — and ty). Those three are the required checks on develop. integration runs ci/integration.sh on push, nightly and on demand — a whole bench closure through the Actions cache is a daily cost, not a per-commit one.
Dependencies are on autopilot: dependabot covers npm, tools/uv.lock, the workflows' actions and the flake inputs (frappe, erpnext, hrms and frappe-nix themselves), and dependabot-auto-merge.yml merges each pull request once the required checks are green. A frappe bump can legitimately fail the drift audits and need a follow-up commit; auto-merge simply waits.
Upgrading Carbon
Bump the exact-pinned @carbon/* versions, then yarn install && yarn codegen && yarn audit:drift && yarn compile, review the diff, and do a visual pass in both themes.
Releasing
Releases are cut by release-please. Nobody edits the version by hand, and nobody pushes to version-16.
Land conventional commits on
develop(fix:→ patch,feat:→ minor; the other types are left out of the changelog). Pull requests are squash-merged, so the title is the commit subject and is linted as one.release-please keeps an open release pull request against
developwith the next version and the accumulated changelog. Merging it is the release:release-please.ymltagsv16.x.y, cuts the GitHub release, and fast-forwardsversion-16— the production branch, whatbench get-app https://github.com/Avunu/carbon_frappe --branch version-16installs — to the release commit.developcarriesCHANGELOG.mdand the version;version-16is always exactly the latest release.There is no artifact to publish: a frappe app installs from git, and
bench buildcompiles the assets on the target.
The major never moves on its own. carbon_frappe@16.x.y means "the theme for frappe v16" — the major is the frappe major, the way erpnext, hrms and frappe-types are versioned — so a feat!: subject or a BREAKING CHANGE: footer proposing 17.0.0 is a bug, not a release. The commit-msg hook refuses both; release-please.yml's guard-major job checks the version release-please actually proposes, on the release branch it writes, and posts a frappe-major (release PR) status on the pull request; yarn check:major runs on every push and also cross-checks frappe.major in package.json against the flake's frappe pin. Breaking changes ship as feat: on the line they belong to. Moving to frappe v17 is a new version-17 branch: point the flake input at version-17, set frappe.major/frappe.branch, and land a Release-As: 17.0.0 commit. scripts/check-frappe-major.ts has the full rationale and the recovery for a stray ! (an empty commit with a Release-As: footer).
Repository settings this relies on: "Allow auto-merge" and "Allow GitHub Actions to create and approve pull requests" ON; develop protected with lint, typecheck and bench required; version-16 protected against force-pushes and deletion only — a "require pull request" rule there would block the token's fast-forward, and github-actions[bot] cannot be a ruleset bypass actor. check.yml's version-16 job is the alarm: the token's ref updates raise no workflow runs, so any run on that branch is a human push, and it fails unless the head is the newest v* tag.
License
MIT — see license.txt. IBM Plex is vendored under the OFL (see carbon_frappe/public/fonts/LICENSE-*.txt).