Guides

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 the Neon Apps language, useful while you are still wiring things up.

The colour roles

RoleWhat it paints
surfaceThe ground the thread sits on
surfaceRaisedA block plate: what the copilot handed you
surfaceHighA block waiting on an answer, the composer, sheets and menus
surfaceSunkData at rest: chart plots, table bodies, empty fields, image placeholders
onSurfacePrimary text, the user's own message bubble, a destructive card's edge
onSurfaceSoftBody copy inside components, single-series bars
onSurfaceFaintLabels, timestamps, the working state
accentWhat 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 a thread 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 thread 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.

Five blocks carry no enclosure at all, because their content already does: text, quick_replies, product_cards, plan_comparison 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)),
);
ScaleFieldsDefault
RendelTypeScaledisplay, title, heading, body, label, caption, overline24 / 18 / 15.5 / 14 / 13.5 / 12.5 / 11
RendelSpaceScalehair, xs, sm, md, lg, xl2 / 4 / 8 / 12 / 16 / 24
RendelRadiusScalexs, sm, card, hero, control6 / 10 / 14 / 28 / 999
RendelElevationScaleraise, float, lifttwo layers each: a tight contact shadow and a wide ambient one
RendelMotionScaleenter, swap, settle, press, follow, breath, caret, enterTravel380 / 180 / 240 / 120 / 250 / 1100 / 600 ms, 10
RendelSizeScalecomponent dimensions: rowThumb, cardWidth, planWidth, plotHeight, donut, trackHeight, stepDot, stickyColumn, column and the rest40 / 160 / 208 / 132 / 104 / 8 / 9 / 132 / 116

RendelSpaceScale.plate is the inset every plated block uses. It is lg, so raising lg widens every card at once.

RendelTypeScale.heading is deliberately also the prose size: the thread 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.

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?',
    ],
  ),
);

suggestions fills the empty thread with 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.