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#
base.cssis the design system. Use its custom properties. Do not introduce a second palette, a second radius scale or a second font stack.- Do not restyle the shared shell — the header, the nav, the footer, the
flash stack. They belong to
base.cssand every page depends on them looking the same. - One module, one stylesheet, one script.
app/static/css/<module>.csspulled in with{% block styles %},app/static/js/<module>.jswith{% block scripts %}. - No network at runtime. Vendor into
app/static/vendor/. No web fonts — the stack issystem-uiand it is fine. - 375px and 1920px both work. Not "degrades acceptably" — works.
- 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-copyare 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-visibleis styled globally, do not remove it. - Toggles carry
aria-pressed, disclosures carryaria-expandedandaria-controls, the current nav item carriesaria-current="page". - Live regions for things that change without a reload: the flash stack is
role="status" aria-live="polite". .visually-hiddenfor labels that are obvious visually and absent to a screen reader.prefers-reduced-motionis honoured globally inbase.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.