Frontend#

Server-rendered Jinja templates, one stylesheet per module on top of a shared design system, and plain ES modules. No bundler, no framework, no package manager, no CDN. A page works before its JavaScript loads; JavaScript makes it nicer, it does not make it function.

The rules#

  1. base.css is the design system. Use its custom properties. Do not introduce a second palette, a second radius scale or a second font stack.
  2. Do not restyle the shared shell — the header, the nav, the footer, the flash stack. They belong to base.css and every page depends on them looking the same.
  3. One module, one stylesheet, one script. app/static/css/<module>.css pulled in with {% block styles %}, app/static/js/<module>.js with {% block scripts %}.
  4. No network at runtime. Vendor into app/static/vendor/. No web fonts — the stack is system-ui and it is fine.
  5. 375px and 1920px both work. Not "degrades acceptably" — works.
  6. Keyboard reachable, sensible ARIA, visible focus, no console errors.

Design tokens#

All of these are :root custom properties in app/static/css/base.css.

Colour#

The cube is the palette. Each CFOP phase owns one cube colour, which is why the app never needs a decorative colour system on top.

Token Value Used for
--phase-cross #0045ad Cross, everywhere
--phase-f2l #009b48 F2L
--phase-oll #f0a500 OLL
--phase-pll #b71234 PLL
--cube-white --cube-yellow --cube-green --cube-blue --cube-red --cube-orange The literal sticker colours

Neutrals are a cool ten-step ramp: --ink-900 (near black) down through --ink-100, plus --ink-050, --paper (#fff) and --canvas (#f7f9fc, the body background).

Semantic colours come in a pair — a strong value and a -soft tint for backgrounds: --accent / --accent-soft, --success / --success-soft, --warn / --warn-soft, --danger / --danger-soft.

Pick semantically, not visually

A "delete" button is --danger because it is destructive, not because red looks right. A phase badge is --phase-oll because it is OLL. If you find yourself reaching for a raw hex value, the token you want probably exists.

Space, radius, shadow, motion#

Group Tokens
Space --space-1--space-8 — 4, 8, 12, 16, 24, 32, 48, 64px
Radius --radius-sm 8, --radius 14, --radius-lg 20, --radius-pill 999
Shadow --shadow-xs--shadow-lg, all low-opacity cool grey
Motion --dur-fast 120ms, --dur 220ms, --dur-slow 420ms, --ease

Use the scale. A one-off margin: 18px is how a design system dies.

Layout#

--content-max: 90vw and --content-cap: 1560px. The .shell class is width: min(var(--content-max), var(--content-cap)), centred, and it is the only thing that should set the page width. On phones --content-max becomes 100vw and the gutters carry the breathing room.

--header-h: 64px is the sticky header height; anything else that sticks must offset by it, and any heading that can be linked to needs scroll-margin-top: calc(var(--header-h) + var(--space-4)).

Component classes#

base.css ships the vocabulary. Reach for these before writing CSS.

Class What it is
.shell The page-width container
.card, .card--flush, .card--quiet, .panel Surfaces
.btn + --primary --ghost --subtle --danger --sm --lg --block --icon Buttons
.btn-group Segmented control, driven by aria-pressed
.field, .field__hint Form rows
.badge + --ok --warn --bad --accent --f2l --oll --pll --cross Pills
.meter A bar driven by --meter-value and --meter-color
.stat, .stat__label, .stat__value Big-number tiles
.alg A move sequence, monospace, click-to-copy
.table-wrap + table.data Tables that scroll instead of overflowing
.empty Designed empty states
.grid--2 --3 --4 --split, .stack, .row, .row--between Layout helpers
.visually-hidden, .skip-link Accessibility plumbing

The macro library#

app/templates/partials/_macros.html is the shared component set. Import what you need rather than re-implementing it:

{% from "partials/_macros.html" import phase_badge, score_meter, alg, star_button %}
Macro Signature Notes
phase_badge (phase, label=None) Coloured phase pill
score_meter (score, size='') Renders Not assessed when score is none — pass None, do not pass 0
status_badge (status) solid / known / learning / anything else
alg (moves, primary=False, copyable=True) Click-to-copy move sequence
star_button (case_key, starred=False) Toggle wired to data-star
stat_tile (label, value, delta=None, tone='')
empty_state (title, body='', action_href=None, action_label=None)
page_head (title, lede=''), used with {% call %} Title block plus an action slot

score_meter deserves emphasis: passing None for an unassessed case is the whole point of the design. 0 says "you are bad at this"; None says "we do not know", and unknown sorts first everywhere in the app.

Templates#

base.html provides the shell and these blocks:

Block For
title <title>
styles Module stylesheet links
body_attrs Attributes on <body>
content The page, already inside <main><div class="shell">
scripts Module script tags

The context processor injects settings, app_name, app_version, nav_items, current_profile and phase_labels everywhere, so no view needs to pass them.

Do not nest a second <main>

base.html already opens <main id="main">. A page that needs an inner content column uses a <div> — see templates/docs/_shell.html.

JavaScript#

Two flavours. app.js is a classic script, loaded on every page, and exposes helpers on window.RT:

Helper Does
RT.toast(message, category) A flash message without a page reload
RT.api(url, options) fetch with JSON headers and useful errors; {json: ...} sets the body and method
RT.copy(text) Clipboard with an execCommand fallback
RT.formatMs(ms, {decimals}) 12.34, 1:02.45, for null

It also owns the mobile nav toggle, flash dismissal and the global [data-copy] handler — put a data-copy="R U R'" (or data-copy="#some-id") on anything and copying works with no extra code.

app/static/js/cube/ is ES modules, loaded with <script type="module">, and is the only place that uses them:

Module Exports
notation.js parseMoves, formatMoves, invertMoves, expandMoves
state.js Cube, mirroring the Python class and its facelet string
track.js parseTrack, dumpTrack, encodeTrack, decodeTrack
viewer3d.js createViewer(el, options)
net.js createNet(el, options) — same load/seek surface as the viewer
timeline.js The shared transport UI

A viewer instance is { load(track), play(), pause(), seek(index, t), setSpeed(x), addSavepoint(label), destroy(), on(evt, cb) }. Because net.js implements load and seek identically, one timeline drives both views.

three.js is vendored in app/static/vendor/three/ and imported by relative path. It is the only third-party JavaScript in the project.

Module conventions#

  • IIFE + 'use strict' for classic scripts; named exports for modules.
  • Query by data- attribute, never by a class used for styling. A class is for CSS; data-star, data-api-form, data-copy are for behaviour, and renaming a class must never break a script.
  • Delegate events from a container rather than binding per element, so server-rendered and dynamically-added nodes behave the same.
  • Guard everything: const el = document.querySelector(...); if (!el) return; A shared script runs on pages that do not contain its widget.

Accessibility#

Not a checklist item; part of "done".

  • Every interactive thing is a real <button> or <a>. If you need a clickable div, you need a button.
  • Visible focus everywhere — :focus-visible is styled globally, do not remove it.
  • Toggles carry aria-pressed, disclosures carry aria-expanded and aria-controls, the current nav item carries aria-current="page".
  • Live regions for things that change without a reload: the flash stack is role="status" aria-live="polite".
  • .visually-hidden for labels that are obvious visually and absent to a screen reader.
  • prefers-reduced-motion is honoured globally in base.css; do not animate around it.
  • Icons are aria-hidden="true" with a text label alongside.

Responsive#

Two breakpoints do most of the work:

  • 860px — the shared shell switches to the collapsible nav and the content goes edge to edge.
  • 900px — module layouts collapse from multi-column to single column.

Below that, 420px is the "small phone" tightening pass. The floor is 375px: an iPhone SE must show every page with no horizontal scrolling of the page body. Wide content — tables, code blocks, diagrams — scrolls inside its own container (.table-wrap, overflow-x: auto), never by pushing the page.

Test it in the browser's device toolbar at 375 × 667 before you call anything finished.

Print#

base.css has a print block that hides the header, footer, action bars and flashes, and flattens shadows. The flat net and the case thumbnails are SVG specifically so they print — a student printing a page of cases to take to a competition is a real use case.

Source: docs/developer/frontend.md