Skip to content

Looks

Astrolabe has three looks: the default look, which needs nothing, and two built-in style templates, playful and notebook. A look is a named set of design token overrides for the light and the dark theme, plus ::part() declarations for components. It changes how an exercise looks, never how it works or grades.

The player and the editor render in their own shadow roots, so your page CSS cannot break them. You style them through three documented surfaces: design tokens (--astl-* custom properties), themes and parts; a look bundles tokens and parts under a name that authors pick.

The same exercise in each look, running in this page (switch the site theme to see their dark values):

the notebook look
<astl-doc lang="en" style-template="notebook">
<h2>Animals</h2>
<p>An elephant is <astl-gap id="g1" answer="grey"><astl-alt>gray</astl-alt></astl-gap>.</p>
<astl-choice id="q1">
<astl-prompt>How many legs does a spider have?</astl-prompt>
<astl-option id="a" correct>Eight</astl-option>
<astl-option id="b">Six</astl-option>
<astl-option id="c">Ten</astl-option>
</astl-choice>
</astl-doc>

No style-template anywhere: the base tokens.

Animals

An elephant is gray.

How many legs does a spider have? Eight Six Ten

style-template="playful": rounder shapes, bolder lines and brighter colours.

Animals

An elephant is gray.

How many legs does a spider have? Eight Six Ten

style-template="notebook": cream paper on a kraft desk, ink-blue text, a book face and small corners.

Animals

An elephant is gray.

How many legs does a spider have? Eight Six Ten

Their colours, parts and contrast ratios: Built-in style templates.

What a look never changes:

  • Grading and behaviour. A look styles; the same source grades the same in every look.
  • High contrast. In theme="hc" and under forced-colors: active no look applies, so readers who need high contrast always get it.
  • Reduced motion. Under prefers-reduced-motion: reduce durations stay zero, whatever a look sets.
  • Verdicts. A look's ::part() rules cannot touch borders, outlines, colours, display or text size (see the rules), and its colour tokens keep the contrast pairs, so a verdict keeps its icon, word and border style.

Authors write style-template on <astl-doc> for the whole exercise, or on one component:

<astl-doc lang="en" style-template="notebook">
<p>A young cat is called a <astl-gap id="cat" answer="kitten"></astl-gap>.</p>
<astl-choice id="sky" style-template="playful" variant="cards">
<astl-prompt>What colour is a clear sky at noon?</astl-prompt>
<astl-option id="blue" correct>Blue</astl-option>
<astl-option id="green">Green</astl-option>
</astl-choice>
</astl-doc>
  • The closest one wins: the component's look, then the document's, then the player's default, which the host sets with <astl-player style-template="notebook"> (or the styleTemplate property).
  • Scopes. The document's look styles the whole player: the exercise canvas and the chrome (page navigation, timer, buttons, result). A component's look styles that element and everything inside it; tokens it does not set come from the document's look.
  • In the editor. The inspector of <astl-editor> shows Look after every component's own fields ("Same as the exercise" removes it), and Look of the whole exercise for <astl-doc> when the cursor is outside every component: "Default look", "Playful", "Notebook", or your own looks, labelled from their catalogs. The preview plays the exercise in its looks, the way a student sees it. With no looks registered, the editor offers no look controls.
  • Unknown names are ignored. A name the player does not know (such as the old calm), or a look not made for that place (a component-only look on <astl-doc>, a look limited to other tags), changes nothing: the exercise plays in the look around it. The engine reports the warning STYLE_TEMPLATE_UNKNOWN with { name, tag } and keeps the attribute in the view, so a player that knows more looks can still use it.

Variants such as cards are another thing: they come with a component and may change how it behaves (Variants and versions); a look restyles any variant.

Theme What it is
light dark text on light surfaces
dark light text on dark surfaces
hc high contrast: black on white, every text pair at least 7:1, selection marked with a bar and bold text
auto the default of <astl-player> and <astl-editor>: light or dark, following prefers-color-scheme
  • <astl-player theme="hc"> and <astl-editor theme="hc"> pick the theme; each writes it to its own data-theme, and <astl-editor> passes it to its kit and preview.
  • The kit elements used on their own read data-theme directly (<astl-inspector data-theme="dark">; without it, light). <astl-preview> passes its theme on to the player inside it.
  • Looks have light and dark values; with auto they follow prefers-color-scheme like the themes do (a value set for light only never reaches a dark system).
  • Under forced-colors: active (Windows high contrast) every colour token becomes a system colour, whatever the theme. Under prefers-reduced-motion: reduce both durations become 0ms.

Every colour, font, size, space, radius, width and duration is a token. The full list with values, swatches and contrast ratios: Design tokens.

Each element carries the tokens on its own host, so override them on the element itself. A value set on a parent (such as body) does not reach inside.

astl-player,
astl-editor {
--astl-accent: #6b2fb3;
--astl-focus: #5a2599;
--astl-font-body: "Atkinson Hyperlegible", system-ui, sans-serif;
--astl-radius-m: 12px;
--astl-target-min: 48px;
}

Component elements (<astl-gap>, <astl-choice>) sit inside the player and inherit its tokens, so one rule on astl-player styles them too. Tokens set on <astl-editor> reach its kit, its preview player and its TipTap surface. The kit elements used on their own (astl-insert-menu, astl-inspector, astl-issues, astl-preview) take the rule each.

A rule without a theme selector applies to every theme. To change one theme only, add its attribute (the player and the editor write their theme to data-theme):

astl-player[data-theme="dark"] {
--astl-accent: #c7a6ff;
}
@media (prefers-color-scheme: dark) {
astl-player[data-theme="auto"] {
--astl-accent: #c7a6ff;
}
}

Keep the contrast pairs. Text on its backgrounds needs at least 4.5:1 (7:1 in high contrast); borders, focus rings and verdict colours at least 3:1. The token reference lists every pair that has to hold; --astl-on-accent on --astl-accent, for example, is the text of the primary button. Never shrink --astl-target-min below 44px or remove the focus ring (--astl-focus, --astl-focus-width).

Keep picks apart from verdicts. A picked option or card shows --astl-selected where a checked one shows the verdict colour, so --astl-selected stays at least 25 (CIEDE2000) from --astl-success and --astl-danger, and --astl-selected-soft at least 7 from their soft fills: a teal pick next to a green "correct" would read as checked. The check reports it as pick-distance.

The page around the player. To style your own page with the same tokens and themes:

  • With the script-tag bundles, link astrolabe.css (in dist/cdn/). It sets the tokens on :root and on .astl-theme, with the same data-theme values:

    <html data-theme="auto">
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/astrolabe@0.1/dist/cdn/astrolabe.css" />
  • With a bundler, write the stylesheet for any selector with tokensCss from astrolabe/player or astrolabe/editor, as the editor examples do. An adopted stylesheet is not an inline style, so it works under a strict Content Security Policy:

    import { tokensCss } from 'astrolabe/player';
    const page = new CSSStyleSheet();
    page.replaceSync(tokensCss(':root'));
    document.adoptedStyleSheets = [...document.adoptedStyleSheets, page];

tokensCss(selector) writes the light values on the selector, the data-theme="dark", "hc" and "auto" rules, system colours for forced-colors: active and zero durations for prefers-reduced-motion: reduce. Then use the properties in your CSS, for example background: var(--astl-canvas).

Looks win over host tokens. A look's values apply inside the player, over the --astl-* values you set on <astl-player> itself: the look the author picked is the one the student sees. To adjust a built-in look, register your own look under another name.

Two kinds of tokens do a job beyond one colour or size:

  • The group palette: --astl-palette-1 to --astl-palette-8, each with a soft fill --astl-palette-<n>-soft. Components that tell groups apart give them the slots in order and start again after the eighth: the categories of <astl-categorise> (an author can pin one to a slot with <astl-category palette="3">), the joined lines of matching, the pairs turned up in memory, the words found in a word search. The slots are never red or green (the verdict colours), neighbours stay apart for red-green colour blindness, each colour reads as text on the page and on its soft fill, and a group always has a label or a pattern too, so colour never carries the meaning alone. Override them only together and keep those rules; their values per theme are on the token reference. Under forced colours they become CanvasText and Canvas.
  • The separator switch: --astl-separator-display is none, and a look sets it to inline to show a separator between options laid out as words, such as the buttons of <astl-select variant="buttons"> ("mat / hat"). The built-in notebook look does, as printed exercises do. The separator is decoration: screen readers skip it.

Parts are named elements inside a shadow root that you can style with ::part().

Element Parts
<astl-player> player (the root), header (page counter, progress, timer), canvas (the exercise), result (score and counts), footer (navigation and actions), problem-details (the errors of an invalid source)
<astl-editor> surface, insert-menu, inspector, issues, preview
<astl-insert-menu> root, title, button, icon
<astl-inspector>, <astl-issues> root, title
<astl-preview> root, title, button, player (the <astl-player> inside)
astl-player::part(result) {
border-inline-start: 4px solid var(--astl-accent);
}
astl-editor::part(preview) {
margin-block-start: 2rem;
}

Each component also declares part names in its manifest (cssParts), for example input and verdict on <astl-gap>; the component reference lists them. The player mounts components inside its own shadow root, so these parts are reachable only where you place a component element in a scope of your own. Inside <astl-player>, style components through the tokens, or through the parts of a look.

storybook.ts
import type { StyleTemplate } from 'astrolabe/player';
export const storybook: StyleTemplate = {
name: 'storybook',
version: 1,
scopes: ['document', 'component'],
messages: 'acme.style.storybook',
catalogs: [
{
namespace: 'acme.style.storybook',
locale: 'en',
messages: { label: 'Storybook', description: 'Round cards and a book face.' },
},
{
namespace: 'acme.style.storybook',
locale: 'pl',
messages: { label: 'Bajka', description: 'Okrągłe karty i książkowa czcionka.' },
},
],
tokens: { 'font-display': 'Georgia, serif', 'radius-m': '14px', 'radius-l': '20px' },
light: { accent: '#7a2fb3', focus: '#7a2fb3', selected: '#1f5bc6', 'selected-soft': '#e8effc' },
dark: { accent: '#d0a8ff', focus: '#d0a8ff', selected: '#8fb8ff', 'selected-soft': '#16284a' },
parts: { 'astl-choice': { option: { 'box-shadow': '0 2px 0 var(--astl-line)' } } },
};
Field Meaning
name What authors write in style-template: lowercase kebab-case, 1 to 40 characters, starting with a letter. Keep it apart from variant names such as cards.
version Bump it when the look changes visibly; exercises keep naming the look.
scopes document, component or both.
components Tags it supports at the component scope; leave it out for every registered component.
messages, catalogs The i18n namespace and its catalogs, with a label (the inspector's option) and a one-line description, in English and Polish.
tokens Token overrides for light and dark, by token name without --astl- (Design tokens).
light, dark Overrides on top of tokens for one theme.
parts ::part() declarations by component tag, then part name (the manifest's cssParts, listed on each component page).

Rules the player enforces:

  • Part properties are limited to shape, spacing and type: corner radii, box-shadow, padding, gaps, font-family, font-style, letter-spacing and line-height (PART_PROPERTIES in @astrolabe/tokens). A ::part() rule from outside beats the component's own styles, so borders, outlines, colours, display and text size stay with the component: they carry the verdict border styles, focus rings, selection and contrast. Other properties are dropped.
  • Values must be safe: a value with {, }, ;, <, >, a backslash, a line break, a comment opener, an unclosed quote or unbalanced ( ) or [ ] is dropped, so a look can never break the stylesheet around it.
  • Write part values with tokens (var(--astl-line)), so they follow the theme.

Colours: a look may set a few colour tokens, as above, or the whole scheme, as the built-ins do. The colours it leaves out come from the scheme around it, and a component look's text also sits on the backgrounds of the scope around it, where the component paints none; the check covers both.

Register it in all three places, with the built-in looks, since an option replaces its default ([storybook] alone would leave only storybook and the default look):

import { builtInStyleTemplates, definePlayer } from 'astrolabe/player';
import { createEngine } from 'astrolabe/server';
import { defineEditor } from 'astrolabe/editor';
import { storybook } from './storybook.js';
const styleTemplates = [...builtInStyleTemplates, storybook];
definePlayer({ styleTemplates }); // players apply it
const engine = createEngine({ secret: process.env.ASTL_SECRET, styleTemplates }); // validation knows it
defineEditor({ styleTemplates }); // the inspector offers it and the preview shows it
  • definePlayer and defineEditor can be called again with more looks: a second call adds, and players already on the page restyle at once. A name that another look already took throws (two copies of a bundle would otherwise mix silently).
  • createEngine throws for a name registered twice or a name that is not lowercase kebab-case.
  • The script-tag bundles register the built-in looks on load and export definePlayer (and astrolabe-editor.js defineEditor), so a page adds its own look with one more call, such as definePlayer({ styleTemplates: [storybook] }). For the in-browser engine of astrolabe.js that look is unknown, which is only a warning: the exercise compiles and the player applies the look.
  • With the @astrolabe/* packages, pass the same list to definePlayer, createEngine and createAuthoring.

checkStyleTemplate from @astrolabe/tokens (add it as a dev dependency) returns the problems of a look, an empty list when it is fine: the name, version, scopes, component tags, messages, token names, unsafe values, colours that are not #rgb or #rrggbb (their contrast cannot be checked), part properties and names, accessible minimums (--astl-target-min at least 44px, --astl-focus-width 2px, --astl-outline-width 1px and text sizes 14px, written in px or rem), and contrast. Every text pair must reach 4.5:1 and every border, focus ring and verdict colour 3:1, in light and in dark (the pairs are on the Design tokens page); component looks are also checked against the backgrounds around them.

import { builtInManifests } from 'astrolabe/engine';
import { builtInStyleTemplates, checkStyleTemplate } from '@astrolabe/tokens';
import { storybook } from './storybook.js';
// Tags and part names must exist in the manifests.
expect(checkStyleTemplate(storybook, { manifests: builtInManifests })).toEqual([]);
// The same look on components inside an exercise that uses a built-in look.
for (const around of builtInStyleTemplates) {
expect(checkStyleTemplate(storybook, { within: [around] })).toEqual([]);
}

contrastRatio(a, b) (WCAG 2) and templateColors(theme, templates) (the colours of a theme with looks applied in order) are exported for your own checks.

Every <astl-player> adopts one generated stylesheet, shared by all players on the page and rebuilt in place when registrations change. The document's look is mirrored on the player's root as data-style-template; the host's default matches the player's own style-template attribute and applies only when the view names no document look the player knows. Cascade layers order the scopes (player default, then document, then component), every rule excludes [data-theme="hc"], and the whole sheet sits inside @media (forced-colors: none). Constructed stylesheets are not inline styles, so looks work under the strict Content Security Policy of the security guide.