Styling and theming
The extension UI follows Material 3 (M3), Google’s design system, including its 2025 Expressive update. One set of tokens covers every surface: popup, side panel, dialogs, and the on-page UI. The colors come from a palette, one file the website template shares, so a rebrand needs no component edits.
Palettes
Section titled “Palettes”A palette is every brand color: the gray scale, the brand scale, and the
color roles built from the brand scale. The kit ships six in
packages/ui/palettes/, each checked for contrast in both themes:
| palette | filled button | grays |
|---|---|---|
ember (default) | bright orange, dark label | warm |
blue | blue, white label | cool |
violet | violet, white label | cool |
teal | deep teal, white label | green-tinted |
pink | hot pink, white label | rosy |
mono | black in light, white in dark | neutral |
The setup wizard asks for one (Color palette, or --palette teal). To
switch later, rerun the wizard with --palette <name>. The extension and
the website import the palette on their first lines, and the wizard points
both at the new file:
@import "tailwindcss";@import "../../../packages/ui/palettes/teal.css";The pages that import no palette get its colors written in: the checkout
return and uninstall pages (backend/firebase-hosting/public/page.css,
the uninstall page’s own form colors, and the Supabase function’s
return-pages.ts, which inlines page.css) and the store asset templates
(apps/extension/store-assets/templates/store.css). Each of their colors
names its token, and the wizard rewrites the value from the palette you
pick:
--eyebrow: #dd2c00; /* palette: accent-800 */--glow: rgba(255, 138, 0, 0.1); /* palette: accent-500 / 10% */--accent: #ff8a00; /* palette dark: brand */palette dark: reads the dark theme, for a role that changes with it,
like mono’s button. A color you add to one of these pages follows the
palette once it carries a mark. The logo is an image and keeps its colors:
replace apps/extension/public/icon/ and the website’s favicon with your
own.
Make your own palette
Section titled “Make your own palette”Copy the closest palette to a new file in packages/ui/palettes/, change
its values, and run the wizard with --palette acme (the file’s name): it
offers your file next to the kit’s six. A palette has three parts:
@theme { --color-*: initial;
/* the grays: the kit's lightness steps, tinted toward your hue */ --color-neutral-50: oklch(0.985 0.002 262); /* … 100–950 … */
/* your brand's scale */ --color-accent-50: #eff6ff; /* … 100–950 … */ --color-accent-ink: color-mix(in oklab, var(--color-accent-700), var(--color-accent-950) 35%);
/* the brand roles, light theme */ --color-primary: var(--color-accent-ink); --color-secondary: var(--color-accent-100); --color-secondary-foreground: var(--color-accent-ink); --color-brand: var(--color-accent-600); --color-brand-foreground: #fff;}
@layer base { /* the brand roles, dark theme */ .dark { --color-primary: var(--color-accent-400); --color-secondary: var(--color-accent-950); --color-secondary-foreground: var(--color-accent-200); }}- The brand scale has eleven steps, 50 to 950. Tailwind v4’s color reference has ready-made scales, and a scale generator builds one around a single hex.
brandis the filled button. Point it at the step wherebrand-foregroundreads: white on a deep color, near-black on a bright one.- For the grays, keep the lightness and chroma of the kit’s steps and set the hue (the last oklch number) to your brand’s.
--color-*: initialcomes first: it turns off Tailwind’s stock palette, so raw utilities likebg-blue-600don’t compile. Lint bans them too.
pnpm test checks every palette in the folder in both themes: text on
every surface, the label on the filled button, the selected row, and the
pages’ eyebrows and links each need 4.5:1. A failing check names the
palette, the theme, and the pair. It also checks that the marked colors
match the palette the extension imports.
The status scales (success, warning, danger) are the same in every
palette. They live in assets/tailwind.css next to the roles built from
them.
Color roles
Section titled “Color roles”Components never name a scale step. They use the color roles, so every
button, link, tab, dialog, and focus ring follows the palette. Each role
has a light and a dark value and switches with the theme: you write one
class, never a dark: pair. The names are the ones shadcn/ui uses, and
X-foreground is the text that sits on X. The palette holds the brand
roles (primary, secondary, brand); assets/tailwind.css builds the
rest from the palette’s grays and the status scales. The values below are
ember’s.
| role | light | dark | used for |
|---|---|---|---|
background | neutral-50 | neutral-900 | the page, lists, tabs, dialogs |
foreground | neutral-900 | neutral-100 | text |
muted-foreground | neutral-600 | neutral-400 | secondary text, icons |
card | neutral-100 | between 900 and 800 | cards that float over a page |
muted | between 200 and 300 | neutral-700 | skeletons, avatars, neutral chips |
border | neutral-300 | neutral-700 | dividers, card and button edges |
input | neutral-500 | neutral-400 | text fields, outlined controls |
primary | accent-ink | accent-400 | links, active tabs, selection, progress, focus |
secondary / secondary-foreground | accent-100 / accent-ink | accent-950 / accent-200 | selected options, the brand tint |
brand / brand-foreground | accent-500 / near-black | the same | filled buttons |
destructive / destructive-foreground | danger-700 / white | danger-300 / danger-950 | errors |
success / success-foreground | success-50 / success-800 | success-950 / success-200 | a success tint and its text |
warning / warning-foreground | warning-50 / warning-950 | warning-950 / warning-200 | a warning tint and its text |
primary reads as text on every surface, dialogs included (WCAG AA). Its
light value is accent-ink, accent-700 darkened toward accent-950: in
ember, the ramp’s own dark end turns red, and a red primary would read as
an error. The brand at full strength is reserved for filled buttons
(bg-brand text-brand-foreground), the same in both themes in every
palette but mono. Stock Material 3 fills buttons with primary; the kit
fills them with the brand.
A tinted status (a Badge variant, a BroadcastBanner level) is a role
pair with its foreground at 20% as the edge:
bg-success text-success-foreground border-success-foreground/20. A
snackbar inverts the page: bg-foreground text-background. A broadcast’s info level is
neutral, bg-muted with border-border, so it never reads like a warning.
Dark and light: always both
Section titled “Dark and light: always both”Dark mode is class strategy, driven by the settings store: auto follows
the OS; light and dark override it. The .dark class redefines the roles
wherever it sits, and the page background is the background role in both themes.
Check every new component in both themes.
Content-script shadow UIs get the theme class on their shadow wrapper,
never from the host page (mountShadowUi handles this).
Typography
Section titled “Typography”- The Google trio, bundled locally in
packages/ui/fonts/; no CDN fonts. Google Sans carries body and UI, Google Sans Flex carries headings (the display face), Google Sans Code carries uids, shortcuts, and stats. @font-facelives in@extensionstart/ui/fonts.css, imported by HTML surfaces only. The content script never loads font files: shadow UI on web pages uses the system fallbacks from the font tokens, which keeps font requests off host pages and out ofweb_accessible_resources.- Weights are 400 and 500. Hierarchy comes from size and tone.
- Numbers always get
tabular-nums(prices, credit counts, timers) so digits don’t jiggle.
Text follows the M3 type scale:
| M3 role | size/line | class | used for |
|---|---|---|---|
| Title large | 20/28 | text-xl leading-7 font-medium | dialog titles, the sign-in title |
| Title medium | 16/24 | text-base leading-6 font-medium | card titles, prices |
| Title small | 14/20 | text-sm font-medium | the active tab, plan names |
| Body large | 16/24 | text-base | list rows, text fields |
| Body medium | 14/20 | text-sm | supporting text, dialog text, snackbars |
| Body small | 12/16 | text-xs | floated labels, notes |
| Label large | 14/20 | text-sm font-medium | buttons |
The M3 shape scale, on the kit’s radius utilities:
| M3 shape | radius | used for |
|---|---|---|
| extra-small, 4px | rounded-sm | text fields, snackbars, menus |
| small, 8px | rounded-lg | chips |
| medium, 12px | rounded-xl | cards, option rows, square buttons |
| large, 16px | rounded-2xl | large containers |
| extra-large, 28px | rounded-3xl | dialogs |
| full | rounded-full | buttons, pills, avatars |
Motion
Section titled “Motion”Fades and small translates only, 150–200 ms, hard cap 300 ms, with M3’s
standard easing (ease-standard). Extension surfaces open and close
constantly; motion that draws attention twice a minute is noise. The one
sanctioned entrance: animate-enter (a fade and a 16px rise, 200 ms,
emphasized decelerate, defined in assets/tailwind.css) on transient
chrome, with motion-reduce:animate-none.
Spacing and layout
Section titled “Spacing and layout”A 4 px grid (the Tailwind default) and M3’s component sizes: buttons 40 px
(32 compact), list rows 56, 72, or 88 px with 16 px sides and 12 px gaps,
text fields 56 px, dialogs with 24 px padding. The popup’s minimum width
is min-w-90 (360 px); a dialog fits under the popup’s 600 px cap.
Components
Section titled “Components”Primitives come from @extensionstart/ui, each one an M3 component:
Button: filled (default), outlined (outline), text (ghost), anddanger; sizessm(32 px), default (40 px), andicon(a 40 px icon button); round, orshape="square"for a button with its own edge.Card: the outlined card.TextField: the outlined text field with a floating label.ModalwithDialogBody: a header row with your logo, the title and the close button; its actions stack full width, the confirming button on top and the dismissing text button under it, so neither moves when a label changes.ListItem: one-, two-, and three-line rows.Badgefor status chips,Skeleton, andToasterfor snackbars: one at a time (a new one replaces the one on screen), on a layer under dialogs, so a wall that follows a snackbar always takes the clicks.
The popup’s tab bar is plain markup in AccountHub: icon and label, the
active tab underlined in primary. The theme choice in Settings is three
buttons, the selected one filled.
Never hand-roll a button, list row, dialog, or badge in app code;
extend via className, merged with cn(). Hover, focus, and pressed
state layers and the 3 px focus ring are built into the primitives. A
custom interactive element gets the same with two utilities: state-layer
(it needs relative) and focus-ring (or focus-ring-inset when it sits
edge to edge). The rules and numbers coding agents follow are in
AGENTS.md (Conventions).
Two practical notes:
- New Tailwind class sources outside the extension app need an
@sourceline inassets/tailwind.css. - In shadow UIs, rem is converted to px at build (rem would resolve against the host page’s root font size); see UI on web pages.