Map your brand to the catalog
The copilot should look like your app built it.
Every component consumes roles, never values. You map your brand once and the whole catalog follows: no renderer hardcodes a colour, a font size, a gap or an animation duration.
Map your brand
RendelTheme.fromTokens takes the four colours you certainly have and derives the rest of the ramp from them.
final theme = RendelTheme.fromTokens(
surface: brand.background,
onSurface: brand.text,
accent: brand.primary,
onAccent: brand.onPrimary,
radius: const RendelRadiusScale(card: 12, control: 999),
fontFamily: 'YourFont',
);
await Rendel.init(
appKey: key,
theme: theme,
);surfaceRaised, surfaceSunk, surfaceHigh, onSurfaceSoft, onSurfaceFaint, line and lineStrong are all derived from your surface and text colours. Pass any of the three surfaces explicitly if your design system already defines its own elevation ramp.
The derivations are not simple blends. onSurfaceSoft and onSurfaceFaint measure how far your ink can move toward your ground before it stops clearing 4.5:1 on every surface, then divide that room between them, so a brand with very little contrast to spare still gets two distinct quiet steps rather than two identical ones. And if you map an onAccent that fails on your accent, it is darkened until it passes and your debug console says so. A brand is applied through the accessibility floor, never around it.
For full control, build RendelColors yourself and hand it to the RendelTheme constructor. RendelTheme.paper() is Rendel's own light language (RendelTheme.deep() is the dark one), useful while you are still wiring things up.
The colour roles
| Role | What it paints |
|---|---|
surface | The ground the answer page sits on |
surfaceRaised | A block plate: what the copilot handed you |
surfaceHigh | A block waiting on an answer, the composer, sheets and menus |
surfaceSunk | Data at rest: chart plots, table bodies, empty fields, image placeholders |
onSurface | Primary text, a destructive card's edge |
onSurfaceSoft | Body copy inside components, single-series bars |
onSurfaceFaint | Labels, timestamps, the working state |
accent | What the reader can act on: a primary button, a selected chip, a chosen slot. Never a readout |
| onAccent | The label on a filled accent control |
| line | Dividers and decorative hairlines |
| lineStrong | The edge of a block that is waiting on an answer |
| shadow | The hue every elevation is tinted with. A black shadow on a tinted ground reads as dirt |
The accent is a promise: it means you can act. Everything the copilot merely tells you is drawn in ink, so a reader who scans an answer page for your brand colour finds exactly the decisions in it. Charts, progress bars, order trackers and the streaming caret are all readouts and carry none of it. Every block spends the accent at most once, and most spend nothing.
The one exception is a status_banner in a warning or error tone, whose glyph uses accentText. A banner has no control in it, so there is nothing for the mark to be confused with, and an urgent notice that is not marked is worse than a rule with an exception in it.
Depth says whether the copilot is waiting on you
There are four levels and each one means something.
Sunk is data at rest: a chart's plot area, a table body, an empty field. A well is not an object and casts no shadow. Ground is the answer page itself. Raised is what the copilot handed you, which is every block plate: surfaceRaised plus the raise elevation. Floating is a block that is waiting on an answer: surfaceHigh, one step higher, plus an edge in lineStrong.
Only five blocks float: form, datetime_slots, variant_picker, booking_card and confirm_card. A destructive confirmation takes its edge to full ink. A block settles back down to raised once it has been answered, so a resolved card and a sent form stop asking.
Four blocks carry no enclosure at all, because their content already does: text, quick_replies, product_cards and media_gallery.
RendelElevationScale is one scale for both light and dark grounds. On a dark ground most of the lift is carried by the raised surface being lighter than the ground; the shadow only deepens it.
Two roles you do not supply
Both are derived from what you already gave, and both are readable on RendelTheme.
theme.accentText is your accent shifted toward black or white, whichever the surface needs, until it clears 4.5:1. The renderer uses it wherever the accent is text rather than a fill, such as a product badge or a warning glyph, because a mid-saturation brand colour as small text usually fails AA even when the same colour is fine as a button. It is derived against whichever of surface and surfaceSunk it contrasts with least, since the same mark appears on both.
theme.lineControl is onSurface at whatever opacity clears 3:1 against all four surfaces, whichever needs the most. It draws the boundary of anything interactive: a text field, a select, a chip, a quiet button. WCAG 1.4.11 asks for 3:1 on the visual boundary that identifies a control, and an empty text field is identified by nothing else. Decorative hairlines stay at line and lineStrong.
theme.accentEdge is the third. It is your accent darkened only as far as it takes to clear 3:1, and it draws the 1px rim on every filled accent control. This is the role most themeable SDKs are missing: WCAG 1.4.11 asks 3:1 of the boundary that identifies a control, and a mid-value brand colour filling a button on a light ground is routinely under that while looking completely deliberate. Asking the question of the fill instead of the edge is asking the wrong question. Where your accent already clears 3:1 on its own, which is any dark ground, accentEdge resolves to the accent and the rim is invisible.
The other scales
Colour is one of six. Each is a plain class with defaults, and each is a constructor argument on both RendelTheme and RendelTheme.fromTokens.
RendelTheme.fromTokens(
surface: brand.background,
onSurface: brand.text,
accent: brand.primary,
onAccent: brand.onPrimary,
type: const RendelTypeScale(heading: 17, body: 15),
space: const RendelSpaceScale(lg: 20),
motion: const RendelMotionScale(enter: Duration(milliseconds: 240)),
);| Scale | Fields | Default |
|---|---|---|
RendelTypeScale | display, title, heading, body, label, caption, overline | 24 / 18 / 15.5 / 14 / 13.5 / 12.5 / 11.5 |
RendelSpaceScale | hair, xs, sm, md, lg, xl | 2 / 4 / 8 / 12 / 16 / 24 |
RendelRadiusScale | xs, sm, card, hero, control | 6 / 10 / 14 / 28 / 999 |
RendelElevationScale | raise, float, lift | two layers each: a tight contact shadow and a wide ambient one |
RendelMotionScale | enter, swap, settle, press, follow, breath, caret, enterTravel | 380 / 180 / 240 / 120 / 250 / 1100 / 600 ms, 10 |
RendelSizeScale | component dimensions: rowThumb, cardWidth, plotHeight, donut, trackHeight, stepDot, stickyColumn, column and the rest | 40 / 160 / 132 / 104 / 8 / 9 / 92 / 96 |
RendelSpaceScale.plate is the inset every plated block uses. It is lg + hair (18), so raising lg widens every card at once.
RendelTypeScale.heading is deliberately also the prose size: an answer page has one reading size, separated by weight rather than by half-point steps. title sits above it and is a block's own name. display is the one figure a block exists to report, and a block has at most one: a receipt's total, a plan's price, a stat's value.
overline is the quiet label that opens a block. It is not uppercased. A label should be a different voice rather than the same voice shouting, and the web surfaces set this step condensed instead of letterspaced, so a catalog that shouted while the site whispered would not read as one product. Whatever the server sends is what the reader and the screen reader both get.
The radius scale is concentric by construction: an enclosure drawn with hero holds a card plate whose corners agree with its own, rather than two corners that are merely both round.
RendelMotionScale.curve is the design language's easing, Cubic(0.2, 0.9, 0.24, 1), exported as rendelEase. breathCurve is Cubic(0.37, 0, 0.63, 1), a symmetric curve used only by the pulse.
Every animation in the SDK collapses when the reader has asked for reduced motion, whatever these values say.
Block style
The scales set sizes; style and cards set how the blocks are built. Both are arguments on RendelTheme.fromTokens, beside displayFontFamily, the face for the display and title steps (it falls back to fontFamily).
RendelTheme.fromTokens(
surface: brand.background,
onSurface: brand.text,
accent: brand.primary,
onAccent: brand.onPrimary,
style: const RendelStyle(
card: RendelCardStyle.flat,
button: RendelButtonStyle.filled,
),
cards: {'product_cards': const RendelCardLook(radius: 18)},
displayFontFamily: 'YourDisplay',
);RendelStyle has four closed choices:
| Field | Values |
|---|---|
card | RendelCardStyle.flat (default), .raised, .outlined |
button | RendelButtonStyle.filled (default), .outlined |
product | RendelProductStyle.tile (default), .row, .photo |
tracker | RendelTrackerStyle.vertical (default), .horizontal, .bar |
cards changes one kind of card and leaves the rest alone. The keys are block types: product_cards, info_card, item_list, order_tracker and confirm_card. A RendelCardLook can set its own surfaceRaised, surfaceSunk, radius and style, and colour or size its named parts; anything it leaves null comes from the theme.
Dark mode
From Flutter 0.45.0 and web 0.22.0 the copilot has two themes, light and dark, and by default it follows the device: a phone or a browser set to dark gets the dark one, and switching repaints an open thread.
Saying what your app does
Set themeMode to what your own app does. An app that is always light should say so, or a phone in dark mode opens a dark copilot over a light app.
await Rendel.init(
appKey: key,
theme: lightTheme,
darkTheme: darkTheme, // optional: left out, it is derived
config: const RendelConfig(themeMode: RendelThemeMode.light),
);RendelThemeMode is system (the default), light or dark. An app with its own appearance switch passes the mode it launches with, and calls Rendel.setThemeMode(...) when the user changes it. If your MaterialApp already has a themeMode, it carries across in one line: RendelThemeMode.values.byName(themeMode.name).
On the web, themeMode and darkTheme are options to Rendel.init, and Rendel.setThemeMode(...) changes the mode at runtime. With system, the panel follows your page first and the browser second: a data-theme="light" or data-theme="dark" on <html> (or else <body>), which is what most sites' own switches set, and without one the visitor's prefers-color-scheme. Both are watched.
The dark theme you do not write
Leave darkTheme out and one is derived from your light theme. Four roles are worked out, and everything else follows from them exactly as it does for a light palette, through the same contrast floors:
- A light theme that is already dark — its text lighter than its ground — is its own dark theme. Nothing is inverted.
- Otherwise the ground and the text are those of Rendel's own dark palette,
deep(). - Your accent is kept wherever it still reads on that ground, and lightened along its own hue where it does not, so it stays recognisably yours.
- The label on the accent stays yours where it still clears 4.5:1 on the dark accent; otherwise it becomes near-black or white, whichever reads better.
Colours chosen for a light ground are not carried over: raised and sunk surfaces, the message box and a card's own colours are solved again for the dark one, unless you give them. Everything that is not a colour — corners, type, fonts, card and button styles — is the light theme's. paper() derives to exactly deep().
Setting it from the console
The console's Appearance editor has a Light and a Dark side. What you set on the dark side is published as themeDark, with the same tokens as the light theme, any of them; what you leave out is derived as above. On a device it is layered over the dark theme your code gives (or derives), the same way the light one is layered over yours. The contrast audit runs on both. A setup agent sends themeDark when your app has a dark theme of its own, and leaves it out when it does not.
Naming and opening prompts
await Rendel.init(
appKey: key,
config: const RendelConfig(
assistantName: 'Kicks Copilot',
emptyStateBody: 'Track orders, find products, change bookings.',
suggestions: [
'Running shoes under \$150',
'Where is my order?',
],
),
);The opening screen is the greeting and, under it, suggestions in one card with a hairline between each; it has no mark of its own. Until there is a conversation the header keeps only its controls. Once there is one it shows assistantName, after your published logo, and a counter-clockwise arrow that starts a new conversation (Flutter 0.46.1, web 0.23.2).
suggestions are the openers. Name things your app can actually do, in your users' words: a shopper who sees "Where is my order?" learns the copilot's scope faster than any onboarding sheet can teach it.
emptyStateBody is the line above them. The default names no verbs, because the SDK does not know whether your app tracks parcels or moves money.
Launcher placement
The SDK positions nothing. Place RendelLauncher yourself, or call Rendel.open(context) from your own UI, wherever the entry point belongs. It renders nothing until init has succeeded, so it is safe to place unconditionally.
Replacing a widget
ComponentRegistry.register swaps the renderer for one catalog type and leaves the rest alone.
ComponentRegistry.register('product_cards', (context, block, dispatch) {
return MyProductRail(
props: block.props,
onTap: dispatch.onAction,
);
});The primitives the built-ins are made of are exported, so a replacement presses, encloses and breathes like the rest of the catalog rather than like stock Material: RendelPressable (44pt on both axes, button semantics, press physics), RendelActionButton, RendelChip, RendelControlShell, RendelOverline, RendelSwap and RendelPulse.
Read the resolved styles off RendelThemeScope.of(context).
Contrast is checked for you
init audits your token pairs in debug builds and prints a warning naming any pair that falls below its WCAG minimum. Text roles are held to 4.5:1 against the surfaces they sit on, and that includes the label on a filled control: at 13.5pt or less it is small text, not WCAG large. The boundary of an interactive control is held to 3:1: accentEdge on a filled one, lineControl on an empty field. Release builds skip the audit entirely, so it costs nothing at runtime.
Two things are checked in release as well, because they are not lints. A mapped onAccent or onSurface that fails is deepened until it passes, and a theme that arrived over the wire and is not legible is discarded whole.
[rendel] contrast warning: onSurfaceFaint / surface is 3.10:1,
below the 4.5:1 minimum. Adjust the token pair before shipping.Translucent roles are composited against their background before being measured, so a 10% hairline is scored as the grey it actually paints rather than as the ink it is mixed from.
contrastRatio(foreground, background) is exported if you want to run the same check over your own palette.
A common trap: white on a mid-saturation brand orange is usually around 3.34:1, which fails for a control label. Map a near-black onAccent instead. If you do not, the SDK darkens yours until it passes and tells you it did. The label stays readable either way, but the colour you shipped is not the colour that renders.
theme.accentText, not accent, is what the renderer uses when the accent has to be legible as type; theme.accentEdge is what draws the rim of a filled control.
If you would rather be told before you ship rather than while you debug, the console runs this same audit at edit time. See Publishing appearance from the console.
Web SDK
How the copilot behaves on a website — the tag, version pinning, signed-in visitors, allowed domains, dark mode, CSP, actions, screens and events.
Setup API
Every endpoint the setup and sync files call: authentication, bodies, answers, step ids, limits and errors. For writing your own agent or running setup from CI.