Frontend
Frontend Infrastructure¶
We use Webpack to manage our frontend assets.
Its configuration lives in webpack.config.js,
which imports files from the webpack/ folder.
To build the development Webpack bundle run npm run webpack:build.
To watch files
(excluding the Webpack configuration files)
and automatically rebuild the development Webpack bundle when they change,
run npm run webpack:watch.
To build a production Webpack bundle run npm run webpack:build:prod.
Webpack puts its build output in the dist/ folder,
which is picked up by Django staticfiles.
Entry points¶
webpack/entrypoints.js defines each of our entry points,
and the files that should be bundled into them.
For now this is very verbose, as it mirrors the configuration we used in our old Django Pipeline setup. However over time we'll leverage the ability to import libraries directly in the JS files which require them, reducing the number of files we need to list here.
When building a development bundle, we generate sourcemaps to make in-browser debugging easier.
When building a production bundle, we add a hash to the output file names, to allow for cache-busting on staging and production.
Loading in Django¶
We use the HtmlWebpackPlugin to generate HTML files for each of our entry points,
which includes the script tags
(and link tags, explained in the CSS section below)
needed to load the hashed chunks that form each entry point.
This configuration lives in webpack/entrypoints-html.js.
In Django,
templates can set the scripts variable to specify which entry points should be loaded for that template.
Loading CSS¶
There is a special entry point screen which contains all our CSS,
and is treated slightly differently by webpack/entrypoints-html.js.
Since Webpack is primarily a JS bundler it emits an empty JS file,
which we have no need to include in our site,
so webpack/entrypoints-html.js ignores it.
Javascript¶
All our JS files are run through Babel, allowing us to use modern JS syntax now, without having to worry whether our users' browsers support it.
Webpack automatically minifies our JS when building the production bundle.
Globals¶
Because most of our JS stack was written well before modules were a thing,
many files place variables in the global scope for other files to use.
However in Webpack,
unless a file explicitly places a variable under window,
we have to expose each of those variables manually.
This is done in webpack/global-expose-rules.js.
Like above,
over time we'll leverage imports to reduce the number of variables we have to expose to the global scope.
Stylesheets¶
Our stylesheets are written in Sass,
using its SCSS syntax (.scss).
We use the MiniCssExtractPlugin to separate our CSS from our JS files.
PostCSS¶
We use the postcss-loader to process our css files with postcss.
Its configuration lives in postcss.config.js.
When building a production bundle, cssnano minifies our CSS.
KSS styleguide¶
Components and layouts are documented in SUMO Styleguide using kss-node
automation to generate the documentation from SCSS sources.
The Dark examples button in the styleguide's top bar switches every example to the dark theme by setting data-theme="dark" on it
(on the page itself in the full-screen item view), and the choice is kept across pages.
The styleguide's own chrome stays light.
Theming¶
Colors reach components through tokens, so a theme is a set of token values rather than a second copy of the stylesheets. There is a light theme, which is the default, and a dark theme.
Token layers¶
- Palette, in
kitsune/sumo/static/sumo/scss/config/_color-swatches.scss. Named ramps such as--color-blue-06and--color-graphite-03, emitted on:rootand documented in the KSS styleguide. The palette is the same in both themes. - Semantic tokens, in
config/themes/_light.scssandconfig/themes/_dark.scss. Each file is one Sass map from token name to value.scss/_theme.scssemits the light map on:rootand[data-theme="light"]and the dark map on[data-theme="dark"], so any element can carry either theme. Tokens say what a color is for (--color-text,--page-bg,--color-border), and cover surfaces, text, links, accent fills, borders, form fields, status colors, elevation shadows, and media filters. - Component tokens, in the same two maps.
A component that has to vary on its own gets its own token (
--status-info-bg,--field-border-color-hover), defined in terms of semantic tokens or palette entries.
Components read tokens with var().
Values that don't change with the theme,
such as fonts, spacing, and the --radius-* scale,
are declared once on :root in _theme.scss.
Rules¶
- Components never know which theme is active.
Component SCSS reads tokens.
It never uses
[data-theme]selectors, hex or named colors,rgb()orhsl(), raw palette variables such asvar(--color-blue-06), or Sass palette access such ascolor('blue', '06')andp.$color-white. Anything that differs between themes is a token, including gradients, shadows, and filters. - Every token has a light and a dark value.
A token goes in both maps,
and the build fails with an
@errorlisting the missing keys if the two maps differ. - Moving a component onto tokens leaves its light rendering unchanged. A token's light value is the value the component has now. When a semantic token would change how a component looks in light, give the component its own token whose light value is the component's current value. Only a deliberate design change may alter light.
Dark-only overrides¶
Sometimes a component needs a dark value but must keep its current light value,
which may be specific to that component.
Set the token to initial in the light map,
the guaranteed-invalid value,
and give it the dark value in the dark map.
The component reads var(--token, <current value>).
In light the fallback applies,
in dark the token does,
and both maps still have the same keys.
// config/themes/_light.scss
'promo-wash': initial,
// config/themes/_dark.scss
'promo-wash': var(--color-graphite-04),
// a component
.promo {
background: var(--promo-wash, var(--color-callout-bg));
}
The fallback must be another token (a nested var()),
one of the allowed keywords (transparent, currentColor, none, inherit, initial, unset, revert, auto, normal),
or a number, percentage, or length.
Never use a raw color.
Stylelint enforces this.
Adding a token¶
- Add the token to both
config/themes/_light.scssandconfig/themes/_dark.scss. The light value is the component's current value. Reference palette entries withvar(--color-<ramp>-NN), and use a raw value only when there is no palette entry or you need alpha. - For a color, add an entry to the matching KSS block at the top of
_light.scss. The swatch shows the light value and the description gives the dark value. When you change a token's value, update its entry too. - Run
npm run theme:test, thennpm run webpack:buildandnpm run theme:contrast. CI runs the theme tests; the contrast check needs a build, so run it yourself. - If the token is text, an icon, or a control border that sits on a token background, add a contrast pair for it (see below).
- If the token is decorative (a gradient, shadow, or layer), add it as
nonetoconfig/themes/_forced-colors.scss.
Stylelint guard¶
npm run stylelint and the pre-commit stylelint hook enforce Rule 1 at error severity.
They cover all SCSS under kitsune/sumo/static/sumo/scss and the <style> blocks of the Svelte app.
Outside config/, they reject:
- hex colors, named colors, and color functions (
rgb(),rgba(),hsl(),hsla(),hwb(),lab(),lch(),oklab(),oklch(),color(),color-mix(),light-dark()) - raw palette properties:
var(--color-blue-06)and the other ramp steps, plus--color-white,--color-black, and--color-lavender - Sass palette access:
c.color(),$color-*with or without a namespace (such asp.$color-white), and the$colorsmap (such asmap.get(c.$colors, 'blue')) - Protocol's other color-bearing Sass variables, with or without a namespace:
$link-color*,$background-color*,$body-text-color*,$title-text-color*,$field-border*(but not$field-border-radius),$select-bg*,$box-shadow-*, and$button-border-color-focus* [data-theme]selectorsvar()fallbacks that aren't a token, one of the allowed keywords (listed under Dark-only overrides), or a number, percentage, or length
config/ is exempt,
because the palette, the theme maps, and the Protocol bridge hold raw values by definition.
scss/_theme.scss is the only other file that may use [data-theme],
because it emits the dark theme.
No other file is exempt.
In short, the guard rejects these in every other file:
- hex and named colors, and the color functions (
rgb(),hsl(),oklch(),color-mix(),light-dark()and the rest); - palette properties:
var(--color-<name>-<step>)and--color-white,-blackand-lavender. Don't name a semantic token--color-<word>-<digits>, and add a new single-value palette entry's name to that pattern in.stylelintrc; - Sass palette access (
c.color(…),$color-*,$colors) and Protocol's color-bearing variables, such as$link-colorand$field-border; - a
var()fallback that isn't anothervar(), a keyword, a number, a percentage or a length; [data-theme]selectors.
The guard reads declaration values and selectors. It deliberately does not read at-rule parameters, and it can't see inside strings, so some things get through. Reviewers should watch for these:
- color values in at-rule parameters:
@include foo(#fff),@return #fff, and mixin parameter defaults such as@mixin foo($color: #fff) - selectors in at-rule parameters, such as
@at-root [data-theme="dark"] & - named colors inside Sass lists, such as
$palette: (red, blue) - hex colors inside SVG data URIs
- Protocol color variables not named above, such as
$field-focus-ring*,$choice-fill,$form-*,$gradient-*, and$svg-*
Contrast check¶
npm run theme:contrast reads the compiled dist/screen*.css,
so run npm run webpack:build first.
It resolves the custom properties for :root and for [data-theme="dark"],
and checks the foreground and background pairs listed in scripts/theme/contrast-pairs.json.
Each pair names an fg token, a bg token, the minimum ratio, and the themes to check:
{ "fg": "--status-info-text", "bg": "--status-info-bg", "backdrop": "--page-bg", "min": 4.5, "themes": ["light", "dark"] }
Token names include the leading --.
min is 4.5 for normal text and 3 for large text and UI components.
backdrop is only needed when the background is translucent,
and names the surface it sits on.
themes lists "light", "dark", or both.
A token whose light value is initial, as in the dark-only override pattern, can only be used in a pair with "themes": ["dark"],
because the checker reports "no value" for it in light.
The script exits non-zero if any pair fails.
A pair that already fails in light can be listed under exceptions:
- An exception covers light only.
- Each one needs a
reason. - It must match a declared pair.
- An exception for a pair that now passes is stale, and stale exceptions fail the check, so remove them.
Dark failures can't be excepted. Fix the dark token instead.
Protocol bridge¶
Protocol's own components read custom properties such as --background-color, --body-text-color, and --link-color.
config/_protocol-bridge.scss points each property it lists at a theme token,
so those components follow the active theme.
Some properties point at semantic tokens (--background-color at --page-bg, --link-color at --color-link).
The rest point at protocol-* tokens defined in the theme maps.
In light, every protocol-* token holds Protocol's own value.
In dark, some point at SUMO tokens (the tertiary background and visited links, for example),
and the others keep Protocol's inverse values.
config/themes/_dark.scss has the current mapping.
_theme.scss emits the bridge after the theme blocks.
To map another Protocol property,
add it to $mappings in the bridge file,
with the name of a token.
The build fails if the token doesn't exist.
Protocol styles that bake in a Sass color at compile time can't be reached through custom properties.
They need component-level overrides in the bridge file.
An override repeats Protocol's selector and sets a token, and wins by source order:
the bridge is emitted after Protocol and before base/, layout/ and components/.
Don't win with !important or a longer selector, which would also outrank SUMO's own Protocol overrides and can change light.
Shadow DOM and the Svelte app¶
Custom properties inherit into shadow roots,
so the device-migration wizard's *.styles.scss sheets read theme tokens like any component.
Rules in screen.css don't reach them, though.
Two of them compile Protocol's form and button styles, with colors baked in,
so _wizard-protocol.scss repeats Protocol's selectors after those imports and sets the colors from tokens.
After changing what those sheets import, check the compiled dist/*.styles.*.css for colors that no override covers.
The contribute app in svelte/contribute/ has no shadow root.
Its root component, Contribute.svelte, points the properties its children read (--tile-bg, --fact-bg, and the rest) at --contribute-* tokens.
The pre-rendered pages inline the component CSS,
so rebuild them with npm run webpack:build:pre-render after changing it;
the build reads the catalogs that ./manage.py compilejsi18n writes to jsi18n/.
Tom Select¶
The scripts that create a Tom Select import components/_tom-select.scss instead of the vendor's CSS.
It compiles the vendor's default theme with the vendor's colors,
then repeats each color-bearing vendor rule with --tom-select-* tokens.
Light keeps the vendor's gradients as image tokens, and dark is flat.
When upgrading tom-select, compare the vendor stylesheet against it for new or changed color rules.
Charts¶
sumo/js/charts/theme.js themes the Chart.js charts from --chart-* tokens.
renderLineChart sets the text, grid, and tooltip colors on Chart.defaults and resolves the dataset colors before it creates a chart.
Charts redraw when sumo:themechange changes the theme and when printing, without fetching their data again.
Under forced colors, text, grid, and tooltips take the system colors,
and the series come from whichever theme suits the system background,
so charts also redraw when forced colors or the OS color scheme change.
- Set a dataset's
borderColorandbackgroundColorwithseriesColor(index, alpha), which cycles through the categorical tokens--chart-1to--chart-11, or withchartColor(name, alpha)for a named token such as--chart-votes-yes. Don't use color literals. - The retention grid's cells use classes that read
--chart-heat-1to--chart-heat-10.
Icons¶
- Templates use the
icon()macro fromincludes/icons.html, as in{{ icon('pencil', class='pencil') }}. It inlines the SVG incurrentColor, which.svg-iconsets from--color-icon, and is decorative unless you pass alabel(its accessible name) or atitle(a tooltip that also names it). Add a new icon to the dict at the top of the file. - CSS icons are per-theme image tokens, such as
--search-icon. The light value is the old URL, byte for byte; the dark one is built with the functions inconfig/_mixins.scssor inline in the dark map. Addforced-colors-icon($url)where the image could vanish on the system palette.icon-mask($url, $color)is for pseudo-elements only, because a mask clips focus rings and hover fills. - Images you can't replace, such as the logo and the
<img>icons that instant search and the device-migration wizard render, takefilter: var(--icon-image-filter):nonein light, aninvert()in dark. A colored image gets its own filter token, as the wizard's info icon does with--wizard-info-icon-filter.
Forced colors¶
scss/_forced-colors.scss makes keyboard focus visible when the browser forces its own colors.
Forced-colors mode removes box shadows,
and fields and buttons draw focus with --focus-shadow alone.
Under @media (forced-colors: active) the layer gives :focus-visible a 2px Highlight outline,
keeps a visible border on fields,
and marks selected states (checked controls, active tabs, the current page) with system colors.
It is loaded last in screen.scss,
so it wins over every earlier rule at equal specificity.
Its :focus-visible rule lives in _forced-colors-focus.scss,
which the wizard's shadow-DOM sheets also use, because screen.css doesn't reach into shadow roots.
The existing rules that remove outlines and make field borders transparent are more specific,
so the layer uses !important.
This is the one place theming code uses it,
and it only applies in forced-colors mode.
Decorative tokens follow a second path.
config/themes/_forced-colors.scss is a map of tokens that change under forced-colors: active.
Decorative layers, such as the ring and shadow of a callout, become none.
An icon's color becomes currentColor,
because an svg keeps an authored color instead of taking the forced one.
_theme.scss emits the map inside @media (forced-colors: active) on :root and both [data-theme] values,
after both themes,
so system colors are never painted over decoration.
A key that isn't a theme token fails the build.
Print¶
Printing always uses the light theme.
_theme.scss re-emits the light map for [data-theme="dark"] inside @media print,
along with color-scheme: light,
so a page printed from the dark theme comes out as light.
Because the whole map is re-emitted,
new tokens are covered without extra work.
Callouts¶
The forum scam banner and KB warnings share one component:
the callout($variant) mixin in config/_mixins.scss.
It draws a tinted card with a start edge,
an icon chip on an svg inside it,
and an outlined .sumo-button.primary-button,
all from --callout-<variant>-* tokens.
components/_callout-warning.scss includes callout(warning) for .sumo-banner-warning and #doc-content .warning.
Forum posts keep their own .warning look.
The blue info variant sits alongside warning with the same tokens;
the topic page's curated callouts use info for "Good to know" and warning for "Important".
A new variant defines the same token set in both theme maps,
then includes callout(<variant>) on its selector:
-bg-start,-bg-mid, and-bg-endare the solid gradient stops, and-bg-mid-atis where the middle stop sits. The mixin composes the gradient from them instead of a token holding it, because a token'svar()references resolve on:root, where the angle that flips in RTL can't reach them. Text sits on the stops, so the contrast check pairs it with each stop instead of with the gradient.-edgeand-edge-widthdraw the start edge. It is a logical border, so it mirrors in RTL and survives forced colors.-ringand-shadoware the box-shadow layers.-icon-bgand-iconcolor the chip and the icon. The icon takes its color throughcurrentColor.-headingand-textcolor the heading and the body.-action-bg,-action-border,-action-text, and-action-hover-bgstyle the action.- Add
-ring,-shadow,-icon-bg, and-icontoconfig/themes/_forced-colors.scss. The mixin removes the gradient itself under forced colors.
Elevation¶
elevation-01 to elevation-03 in config/_elevation.scss set the shadow.
Pass the component's background as $bg, as in @include c.elevation-01(var(--card-bg)):
light keeps it and dark paints the elevated surface; without $bg the component keeps its own background in dark.
Product themes¶
config/_enterprise-theme.scss holds one class, .product-theme--enterprise, which _theme.scss emits.
A page opts in on <body>:
templates with a product context set product_theme to the product's slug,
and base.html adds the class when it is firefox-enterprise.
The class sets:
--product-callout-ring,--product-callout-base, and--product-callout-glows, the hooks thebrand-calloutmixin reads: a 1px gradient border just outside the callout, its surface, and its glow layers. The base and the first glow are the per-theme--enterprise-callout-baseand--enterprise-callout-glowtokens. Forced colors ignore the hooks.--product-hero-text, the text color for a hero drawn over product art.--hero-aurora: none, which turns the hero's aurora off behind that art.
No page uses the last two yet.
Runtime and the flag¶
The dark-mode waffle flag turns the runtime on or off; templates read it as DARK_MODE.
With it off, pages render exactly as they do without the feature.
Anonymous HTML is cached and shared,
so for anonymous traffic the flag must be on or off for everyone: no percentage rollout and no testing mode.
A flip reaches cached anonymous pages as their cache entries expire, about five minutes for KB articles.
Attributes¶
On <html>:
data-theme: the resolved theme,lightordark. CSS reads only this one.data-theme-preference:system,light, ordark.data-theme-owner: an opaque per-account key. Its presence marks a logged-in page.data-theme-endpoint: the URL that saves the preference.
The server renders the last three for logged-in users only. The head script sets the first two, and the runtime keeps them current.
Head script¶
kitsune/sumo/theme_head.js is inlined first in <head>, so the theme is set before the first paint.
It reads data-theme-preference on a logged-in page and localStorage otherwise,
treats unknown values as system, resolves system with prefers-color-scheme,
and sets the two attributes and <meta name="theme-color">
(with the flag on, the favicon() macro leaves out its own).
It isn't transpiled: keep it ES5, under 1 KB, and free of listeners and requests.
Don't add <meta name="color-scheme">: it would darken the canvas before the theme is known.
settings.py holds the file as HEAD_SCRIPT and its hash as HEAD_SCRIPT_CSP_SOURCE, which script-src allows outside DEBUG.
theme_head_script() inlines the text verbatim, so it always matches the hash.
The wiki's doc_page_cache keys include the hash, so editing the script never serves stale cached pages.
Runtime API and event¶
sumo/js/theme.js, in the common bundle:
getPreference():system,light, ordark.getTheme():lightordark.isActive():falsewhere the head script didn't run; the runtime then does nothing.setPreference(preference): applies and saves a preference, ignoring unknown values.
While the preference is system, the theme follows the OS.
sumo:themechange fires on document with detail: { theme, preference } when either changes,
so it also fires when only the preference does, as for System to Dark on a dark OS:
document.addEventListener("sumo:themechange", ({ detail }) => redraw(detail.theme));
Storage and sync¶
- Anonymous:
localStorage["sumo-theme-preference"]; other tabs follow throughstorageevents. - Logged-in: the
theme_preferenceSettingrow (get_theme_preferenceandset_theme_preferenceinkitsune/users/theme.py), rendered on every page. Other tabs follow throughBroadcastChannel("sumo-theme")messages{ owner, preference }, ignoring other owners and never saving or re-posting what they receive. - Saving:
savePreference()intheme-persistence.jspostspreferenceandownertoPOST /<locale>/users/themewithkeepalive. A page keeps one save in flight, sends only the latest choice next, and doesn't retry a failed save. - Endpoint: requires login and CSRF. It returns 200 with the stored preference, 400 for an unknown preference or no owner,
409 when
ownerisn't the signed-in account's key (the page then stops saving), and 404 with the flag off.
Switcher¶
theme_switcher(id_prefix, variant, field_name=None, checked=None) in common_macros.html renders the System, Light, and Dark radios
for the themeSwitcher Alpine component in theme-switcher.js:
id_prefixprefixes the radio ids and names the group, so it must be unique on the page.variantaddsis-footer,is-menu, oris-field.field_namemakes it a form field that works without JavaScript; without it, the switcher rendershiddenand appears once the runtime is active.checkedis the server-rendered choice.
With the flag on, it appears in the footer, in the avatar menu, and in account settings as the theme_preference field of SettingsForm.
Images¶
Images directly imported in JS files, or referenced in CSS files, are copied by Webpack into the build directory, after renaming and optimizing them.
In Django¶
Aside from managing images directly imported in JS and CSS files, we also make use of Webpack to manage images used in Django itself, in Jinja2 templates and Python code.
To do this, we make use of the CopyWebpackPlugin to copy images under certain paths into the build directory, after processing them through the asset pipeline.
We then hook into the Webpack build process,
in webpack/asset-json-plugin.js,
to generate an original to build path mapping in the dist/source-to-asset.json file.
We shorten the original path as explained below.
Included images are those:
- under the Protocol icons directory:
node_modules/@mozilla-protocol/core/protocol/img/icons/, shortened toprotocol/img/icons/ - under the static image directory for any Django app:
kitsune/*/static/**/img/, shortened to**/img/
In Django,
the webpack_static helper loads the mapping,
and is used to get an output path for any image needed from those directories.
Optimization¶
In the Webpack pipeline, PNGs are run through optipng, and SVGs are run through svgo.