Skip to content
OpENEMdocs

DocumentationFor contributors

Color themes

How to create a color theme family in OpENEM, the three files it touches, the contrast tests and the format of the custom theme.

How themes work

Each theme family has a light side and a dark side, applied as data-theme="<family>-light" or data-theme="<family>-dark" on <html>. The colors are CSS properties defined in openem_frontend/src/themes.css, which index.css imports.

  • The [data-theme=...] selectors work on any element, not just <html>: the gallery on the Themes screen previews each palette with a <div data-theme>.
  • The public/theme-boot.js script applies the saved theme before the first paint, so the screen does not flash in the wrong color. It is a file, not an inline script, because the nginx CSP only accepts scripts from its own origin.
  • default (red) is the base, also applied on :root. It does not change.

The three places of a new family

1. The block in themes.css

Two selectors, one per side, each with the 10 color tokens in #rrggbb:

[data-theme='mar-light'] {
  color-scheme: light;
  --bg: #......;
  --sidebar: #......;
  --surface: #......;
  --surface-2: #......;
  --fg: #......;
  --muted: #......;
  --line: #......;
  --brand: #......;
  --brand-2: #......;
  --brand-ink: #......;
}
[data-theme='mar-dark'] {
  color-scheme: dark;
  /* the same 10 tokens */
}

mar is an example name. The existing blocks also define --shadow and --glow; use one of them as a template.

2. FAMILIES in src/theme.tsx

Add the key to the ThemeFamily type and an entry to FAMILIES, with key, label (the name the student sees) and group, which decides the gallery section: classicos, criativos, oled or acessibilidade.

3. The list in theme-boot.js

Add the key to the families list in openem_frontend/public/theme-boot.js. The script cannot import anything, so it repeats the list.

The key must have only lowercase letters: the boot reads the saved theme with the <letters>-light or <letters>-dark pattern, and an unknown value falls back to default.

The tests that hold the three together

src/theme-boot.test.ts checks that the boot list has exactly the same keys as FAMILIES, that every saved theme is applied before the paint and that index.html has no inline script.

src/theme-palettes.test.ts reads the CSS and checks, for each side of each family:

CheckMinimumAgainst
The 10 tokens defined in #rrggbb
Main text (fg)7:1bg, surface, surface-2 and sidebar
Muted text (muted)4.5:1bg, surface, surface-2 and sidebar
Text on the brand color (brand-ink)4.5:1brand
Brand (brand)3:1bg, surface and surface-2
Area colors (mat, nat, hum, lin, red, danger)4.5:1bg, surface and surface-2

The area colors do not belong to the family: they come from index.css, one set for the light side and another for the dark side. The surfaces of the new family must work with both.

cd openem_frontend
npm test -- theme

Effects of the creative themes

Some creative themes change corners and the display font through tokens (--radius-* and --font-display): Cyberpunk and Terminal get square corners and monospaced headings, and Sakura gets very round corners. In themes.css, the dark sides of Cyberpunk, Synthwave and Terminal get a glow on the main button, and Cyberpunk and Terminal also get scanlines. These effects are decoration: they go away with html[data-fx='off'], turned on by the "Efeitos visuais" (visual effects) switch on the Themes screen, and with the system preference prefers-contrast: more.

The custom theme

Students build their own theme from a single color. The palette for both sides is generated by src/lib/theme-derive.ts, on top of src/lib/color.ts (OKLCH and WCAG contrast), and applied as custom-light or custom-dark.

The theme is kept in localStorage, under the openem.theme.custom key, in the v2 format:

{
  "v": 2,
  "id": "optional, id of the theme saved on the server",
  "name": "My theme",
  "light": { "bg": "#f5f1f6", "brand": "#d91748" },
  "dark": { "bg": "#15111c", "brand": "#ff5a7d" }
}

Each side carries the same 10 tokens; the example shows only two. The old single-sided format is migrated on read: the side is chosen by the luminance of the background.

Only #rrggbb

The colors go into style.setProperty, and a theme published in the community runs in other students' browsers. That is why only #rrggbb gets through: a url(...) would leak the IP of whoever opens the theme, and var() and the like would open room for CSS injection. The backend validates in src/common/theme-tokens.ts, and the frontend and theme-boot.js validate again before applying.

In the backend, the themes module stores the students' themes: a theme starts private and publishing is optional, each student likes a published theme once (liking again undoes it), nobody likes their own theme, and editing or deleting another student's theme answers 404.

What about the PDF?

The palettes of the PDF booklets live in openem_backend/src/common/pdf/themes.ts and do not follow the new families: a theme that is not there falls back to default. A new family requires no backend change, but the PDF only gets its colors if you add a palette to that file.

Suggest a fix for this page