Skip to content

Design system

Native extension consumers

Installed native extensions may use the curated catalog in src/plugins/native/uiCatalog.client.ts. Dynamically installed code must use these actual shared primitives rather than private application paths or a recreated theme.

The product feel

Apple platform interaction model

The shared shell follows Apple's layout, sidebar, toolbar, and button guidance. It remains a web application; native Pencil latency, VoiceOver, Safari keyboard behavior, and installed-app lifecycle require device acceptance, not a claim of blanket platform compliance.

  • Navigation and controls occupy a quiet material layer above the reading surface. Dark remains the initial appearance; existing theme, font, accent, and reading preferences remain authoritative.
  • A persistent sidebar needs room for its 256px width and the 848px reading composition. Below 1100px (68.75rem), navigation uses a dismissible sheet and the workspace projects one focused stack. All saved stacks remain available through the pane switcher when there is more than one. Resizing restores the saved multi-pane layout without changing it. This rule also applies to narrow Mac windows and tablet multitasking.
  • Shared controls use 44px minimum boxes when any coarse pointer is available, including tablets with an attached trackpad. Compact desktop controls retain their density. Button carries a stable data-ui="button" marker because composed dialog/popover triggers replace data-slot.
  • Narrow toolbars separate navigation from actions; all actions remain reachable. Course navigation precedes an expandable complete course list. Only the actual course overview selects Overview. A single stack does not need an additional pane-switcher row.
  • Segmented controls render each label once with roving radio focus, arrow navigation, Home/End, and visible selection. No geometry measurement or duplicated clipped text is needed. A running timer shows its countdown directly, without requiring hover.
  • Safe-area insets protect window controls. The visual viewport bounds workspace height when a software keyboard appears; browser pinch zoom never recomposes the page. Input fonts stay at least 16px in touch contexts. Dialogs and search results have bounded, scrollable content.
  • Pane changes are immediate and opaque. Motion is reserved for controls and temporary surfaces. Reduced motion and reduced transparency have explicit fallbacks.

src/components/ui/workspace-appearance.css owns the shared window appearance and adaptive control rules; globals.css owns theme tokens and workspace geometry. Keep course content and ink coordinates out of the appearance layer. Drawing still uses ContentScale to preserve the reference composition: reclaim space through navigation before shrinking a reader. The drawing palette scrolls within its available area so larger controls never make Undo or Clear unreachable.

For UI delivery, inspect desktop, tablet portrait/landscape, narrow windows, phone, keyboard, touch targets, reduced motion, and both supported appearances. Exercise tabs, splits, drawing, notes, search, dialogs, and Settings; preserve state when changing arrangements.

TU/e Study Hub is a persistent study workspace. It borrows the best qualities of Arc and Obsidian:

  • Arc: one stable shell, fast switching between contexts, compact controls, and keyboard-first navigation.
  • Obsidian: content is the focus, navigation is quiet, information can be dense without feeling crowded, and the interface recedes during long reading sessions.
  • Linear/Cursor: restrained hierarchy, predictable rows and panels, short motion, and precise interaction states.

These are behavioral references, not a license to copy another product's chrome. The Study Hub should feel calm, neutral, and academic. It is not a SaaS dashboard, a marketing page, or a set of color-coded course microsites.

Component reference library

Use these as searchable references when an existing shared primitive needs a stronger interaction pattern. Bring back the smallest useful behavior, adapt it to the semantic tokens and compact workspace geometry, and keep the result owned by src/components/ui; do not copy a whole theme or marketing section into the product.

  • Shadcnblocks — application shells, dense lists, and composed shadcn/ui patterns.
  • Magic UI — short React and Motion microinteractions; avoid its looping decorative effects in reading views.
  • Aceternity UI — interaction and transition references; omit glow and ornamental backgrounds that compete with course content.
  • AI Elements — composable AI conversation, response, and tool-state patterns.

Design principles

  1. One workspace, many courses. Keep the shell and navigation stable while content changes.
  2. Content before decoration. Use hierarchy, spacing, and typography before color or shadow.
  3. Dense, not cramped. Prefer compact rows and readable measure over oversized cards.
  4. Semantic, not course-branded. Course identity comes from code, name, and icon—not a custom palette or bespoke page layout.
  5. Keyboard and mobile are first-class. Every action must remain reachable without hover.
  6. The theme is a contract. Components consume semantic tokens and work in light and dark mode.

Typography

Students can choose one application font in Settings. Geist is the default; Inter, IBM Plex Sans, Source Sans 3, Atkinson Hyperlegible Next, and the native system font are also supported. Web fonts are loaded through next/font in src/app/fonts.ts for production; local development aliases that module to src/app/fonts.local.ts so the preview never needs Google Fonts network access. src/app/globals.css maps the active data-font value to --font-sans in both environments.

Geist Mono always loads alongside the selected family as the meta voice — keyboard hints (the Kbd primitive), course codes in chrome, and tabular metadata chips via font-meta. It is the one sanctioned monospace; prose, interface labels, and code blocks never use it.

Use the selected family across the entire workspace, including code. Do not introduce local component fonts, serif display faces, or other monospace styling for visual flavor. The font preference is stored locally under tue-study-hub-font, and the design-system test enforces the approved family set.

Topic reading also exposes local text-size, reading-width, and line-spacing preferences. Text-size changes apply to paragraph, list, quote, and table copy in learning blocks; reading width changes the marked reading measure while leaving intentionally wide diagrams and tables wide. Line spacing owns reading leading independently: compact is 1.45, comfortable is 1.6, and relaxed is 1.8.

Role Size Typical Tailwind class Use
Micro label 10px text-[10px] Keyboard hints and exceptional compact metadata
Metadata 12px text-xs Counts, badges, timestamps, captions
Interface text 14px text-sm Navigation, buttons, controls, secondary copy
Reading text 16px text-base Explanations and sustained prose
Section heading 20px text-xl Major content sections
Page heading 24px text-2xl Normal page titles
Large page heading 30px text-3xl Sparse overview pages only

Rules:

  • Use font-medium or font-semibold for hierarchy; reserve font-bold for course codes or rare emphasis.
  • Headings use -0.02em tracking and balanced wrapping globally.
  • Body copy should normally use leading-6 and text-muted-foreground only when it is genuinely secondary.
  • Numeric progress and counts should use tabular-nums.
  • Do not communicate hierarchy by introducing a component-specific font family.

Color and themes

The palette is a CIE LCH system with semantic roles — the same space Linear generates its themes in. Light and dark values live in src/app/globals.css; Tailwind utilities are generated from the same variables.

Every token is written lch(L% C H). Two hue axes carry the system:

Axis Hue Role
Neutral 266 Every surface, border, and text token. A near-neutral with a whisper of cool: chroma stays under 1 on the surface ramp so the canvas reads as near-black and near-white.
Accent configurable Selected in Settings; primary action, focus ring, progress, sidebar identity, and chart-1.

Surfaces separate by lightness alone (background 2.7 → card 7 → popover 9 → accent 14.5 in dark), which is what makes elevation readable without shadows. Because L is perceptual, one step reads as the same step anywhere on the ramp — that is the reason for the space, not the syntax.

When changing a value, move L and leave C and H alone. Foreground/background pairs are held at WCAG AA; high-chroma values must stay inside the sRGB gamut, since the browser silently clips anything outside it.

Token pair Purpose
background / foreground App canvas and primary text
card / card-foreground Bordered content surfaces
popover / popover-foreground Dialogs, menus, and command surfaces
primary / primary-foreground Primary actions, active emphasis, progress
secondary / secondary-foreground Lower-emphasis actions
muted / muted-foreground Quiet surfaces and secondary text
accent / accent-foreground Hovered and selected rows
destructive / destructive-foreground Destructive actions and errors
success, warning Status feedback
border, border-strong, input, ring Structure (translucent 8% hairlines; border-strong is the 16% hover/active voice), controls, and focus
sidebar-* Workspace navigation equivalents
code-bg, code-fg Code presentation
code-comment, code-string, code-annotation, code-number, code-keyword, code-type Syntax highlighting

The code surface keeps its own near-black ground in both themes, so the code-* syntax tokens are defined once and do not vary by theme.

Use semantic utilities such as bg-card, text-muted-foreground, and border-border. Never place raw hex, RGB, HSL, or OKLCH values inside a component, and never reach for a raw Tailwind palette class such as bg-emerald-500 or text-amber-500 — the palette has no theme contract, so those colors do not move with the accent or the light/dark ramp. Avoid course-specific colors. Semantic status colors should support an icon or label; color alone must not carry meaning.

Hue is reserved for risk and for the accent. destructive marks what cannot be undone and warning marks what needs review before it runs; both stay outline-and-tint rather than a solid fill, so a page never has a shouting button in it. Everything else — toggles that are on, status dots, selected rows — uses primary against muted, which keeps state legible in monochrome and lets the learner's accent choice carry through.

Two places legitimately hold raw hex, and both are boundaries rather than styling:

  • Excalidraw pen colors (src/course-os/services/drawings/penSlots.ts, DrawingToolbar, DrawingOverlay). These are serialized into saved scenes and into the library's own color picker, which parse hex. Changing the notation would invalidate stored drawings.
  • src/app/manifest.ts. The web app manifest is read by OS launchers and splash screens, which do not reliably parse lch(). Keep it in sync with --background by hand.

Gradients and color transitions must declare an interpolation space (in oklch). sRGB interpolation passes through a desaturated midpoint between stops, which shows as grey seams on anything thin.

Shape, spacing, and surfaces

  • The base radius is 10px (--radius: 0.625rem), giving a 6 / 8 / 10 / 14 scale. Use the shared rounded-sm, rounded-md, rounded-lg, and rounded-xl steps rather than arbitrary values.
  • Elevation is four --elevation-* steps, and Tailwind's shadow-xs, shadow-sm, shadow-md, and shadow-lg are rebound onto them. The dark theme uses much heavier alphas because a shadow cast on a near-black canvas barely registers otherwise. Reach for a lighter surface before reaching for a shadow; the ramp exists so elevation is legible without one.
  • Pointer controls are 32–40px high. Touch-capable devices use at least 44px targets, including tab close buttons and menu actions, even when a trackpad is connected.
  • Default page content is constrained to max-w-4xl by the generic course layout.
  • Use borders and small surface shifts for grouping. Shadows are limited to primitive-level affordance; avoid floating glass panels, glow, claymorphism, and decorative gradients.
  • Prefer one outer container with dividers or compact rows over many individually elevated cards.
  • Pills (rounded-full) are for circular controls or true status chips, not every button and card.

Layout and navigation

src/components/AppShell.tsx owns the persistent workspace:

workspace identity
├── search / command palette
├── workspace navigation
├── active course's six universal sections
├── collapsible course library
├── active course tools
└── pinned local profile and Settings

The desktop sidebar collapses through its own labelled control to a 3.5rem icon rail that retains the workspace identity, Search, primary navigation, and compact profile/Settings targets. The expanded and mobile sidebars pin the learner's local display name beside Settings below the scrolling navigation. A missing name is an explicit first-run invitation and remains editable; the value is stored in the backed-up local preference record. The collapse preference persists locally; expanding restores the same mounted navigation tree and scroll position. On mobile, the same information architecture becomes a closed drawer; do not create a second competing navigation model. Course pages render inside the shared shell and scrolling lessons use a comfortable 48rem reading measure by default. Settings can switch the marked reading measure between narrow, comfortable, and wide without changing the shell or navigation geometry.

Every scrolling topic ends in the compact reader dock on desktop: previous and next topic links around one page-level action, in place of the two-column in-flow pagination. Mark page completed appends a reversible, source-scoped block.viewed event without a block ID. It completes every block in that topic, so the topic reads 100% in the topic list, the overview resume, and course mastery; pressing it again removes the marker and restores the derived progress. Collapsing the desktop sidebar additionally enters reader focus mode, where the marked reading measure centers in the remaining pane and plugin inspector panels stay mounted but recede from layout and accessibility navigation. Drawing preserves the reading column so ink stays anchored to its text. Entering Edit or Draw, opening a paged topic, printing, or using the compact drawer restores the in-flow pagination instead of the reader dock.

The six course sections are stable:

  1. Overview
  2. Topics
  3. Resources
  4. Progress
  5. Reviews
  6. Exam Preparation

Scrolling topic pages expose every titled learning block through one on-demand Contents popover in the sticky app header. It uses each block's stable ID for hash navigation, highlights the section currently in view, closes after navigation, and preserves its own scroll position while closed. Once the lesson title leaves the viewport, the same active section appears as a quiet second line in the fixed-height header. Topics using the paged presentation keep their existing picker instead of rendering duplicate outline navigation.

The sticky header keeps course context on the left and Draw at the far-right edge. Contents, the compact Notes display control, and the lesson overflow sit immediately before Draw; Export belongs inside the overflow. Their transient layers render above the drawing canvas without moving the header or changing Draw's position. The lesson stays in one reading column at every sidebar state, so drawing and navigation do not cause text reflow.

Do not introduce a course-specific sidebar or route hierarchy.

The dockable workspace

Inside the shell, src/dockable-workspace arranges open pages as stacks of tabs separated by draggable dividers. Chrome stays deliberately compact so the reading surface remains the dominant layer: tabs are a single 32px row, and every state colour resolves through a theme token.

  • Focused stack carries a one-pixel inset ring in --workspace-focused. Exactly one stack is focused, and it is the only one that contributes reader actions to the app header.
  • Direct docking shows one restrained accent overlay matching the exact rectangle the drop would produce — no permanent docking compass. Dragging starts after six pixels, and a drop the layout refuses removes the preview and leaves the tree untouched.
  • Pane-local chrome means every open topic keeps its own sticky section line; only the focused pane speaks for the single global header.
  • Mobile projects exactly one focused pane. Dividers and drag are absent, the whole tree is preserved, and a compact sheet switcher reaches every hidden stack. Returning to desktop restores every split and ratio.
  • Panes never shrink below 240px; a resize or split that would breach that is refused rather than clamped.

Keyboard: tab strips are one tablist with roving focus and Arrow navigation; dividers are separators that resize by 2% per Arrow and 10% with Shift. Every structural mutation — moved, joined, split, closed, refused — is announced once through a single polite live region at the workspace root.

Components

Shared primitives live in src/components/ui and follow the shadcn “new-york” conventions declared in components.json.

  • Use Button, Input, Textarea, NativeSelect, Slider, Card, Badge, Table, Progress, and the sidebar primitives instead of styling native controls ad hoc.
  • Use Tooltip for quiet affordances and key-term definitions (provider is mounted once in Providers; the first hover waits ~350ms, consecutive hovers open instantly). Use Toast via useToast() for session-shaped feedback — not for every mutation. Use Dialog for centered modals (Sheet remains the drawer), SegmentedControl for exclusive mode switches, and Kbd for keyboard hints set in the meta voice.
  • Use the PageHeader composition for destination-level introductions. Its eyebrow, title, description, metadata, and action slots keep hierarchy consistent while allowing normal, split-action, and bordered reading variants.
  • Use the EmptyState composition when a collection, filter, review queue, or study tool has no content to show. Choose start or centered alignment deliberately; reserve icons and actions for states that benefit from reassurance or a clear recovery path.
  • Use the cn helper from src/lib/utils.ts for conditional class composition.
  • Variants belong in the primitive when they represent a reusable semantic state. One-off page layout belongs at the call site.
  • Keep course data out of UI components. Generic components receive a CourseBundle, CourseDescriptor, or learning block.
  • Use Icon from src/components/ui/Icon.tsx; names must be valid lucide-react exports.
  • Use SettingsSection for every settings destination section. It owns the heading, the state summary beside it, the description, an optional action, and the collapsed disclosure row. Reuse settingsRowClassName for the label/control row inside it.

The automated design-system contract in src/components/ui/design-system.test.ts prevents legacy token vocabulary, native controls outside approved primitives, and competing font families.

Workspace settings

Settings is one focused column navigated by a sticky tab bar — there is no settings sidebar. The seven tabs group the sections: General (Account & sync, Appearance, Study Hub AI), Reading & input (Reading, Drawing & canvas), Study tools (Study sessions, Review scheduling), Data & privacy (Local data), Extensions (the overview plus one anchor per installed or managed extension), Platform (Platform usage, Help & docs), and Admin (Admin only). Sections render expanded inside their tab: one column, divided rows, and controls in a consistent right-hand column on wider screens that move below their labels on narrow screens.

Every section header carries its own state, so the page is scannable without reading rows: the heading is followed by a summary of what the section is currently set to. Search settings… in the sticky header opens a settings command dialog listing every section grouped by its tab, including each installed or managed extension; choosing one switches to the owning tab and scrolls to the section. The header covers the top of the pane scrollport, so anchored scrolls clear it through scroll padding rather than scroll margins on individual sections.

Deep links keep the section id as the URL vocabulary: /settings#<section id> and the ?section= route param land on the owning tab and scroll to the section. Tab strips use roving focus with Arrow-key movement and manual activation. Collapsing stays a page-level decision — a section rendered outside the settings page, without SettingsDisclosureProvider, always renders expanded, and the settings page itself no longer mounts the provider because the tabs replace page-level collapsing.

Study Hub AI settings lead with the automatic routing chain — the server-configured order in which models are tried, read from each model's automaticRank — and then list every configured model in one compact table of model, provider, capability, and picker state. The picker controls only which models appear as manual choices in the composer. Hidden models remain eligible for automatic server routing, and an already pinned conversation retains its model identity until the learner chooses another model or Automatic.

Local-data export uses the complete typed schema-v4 workspace snapshot: progress, course and study preferences, sticky notes, drawing pages, theme, font, accent, sidebar state, and drawing-tool configuration. The active timer, backup metadata, notification permission, offline app files, and unrelated origin keys are deliberately excluded. Older backups remain importable and preserve state their schema did not contain. The UI reports only that a backup download started; an anchor click cannot prove that the browser finished saving the file.

Local data also exposes five category resets—progress, review history, notes, drawings, and appearance and layout—plus reset everything. Each action uses category-specific confirmation and waits for its storage operation before reporting success. Review reset removes scheduling fields while preserving completed work; drawing reset preserves drawing-tool preferences; appearance reset preserves course, timer, sound, and review-session preferences. Backup metadata and unrelated browser data survive every reset.

Timer sound and system notifications are best-effort foreground affordances. Notification permission is requested only from the explicit Enable notifications action and remains owned by the browser. Neither sound nor a system notification is promised after the page or installed app is suspended or terminated.

Contextual annotations

Topic learning blocks support local sticky annotations without becoming a separate notebook or free-form canvas. Double-clicking a block creates a block note; selecting text exposes an explicit Add note action for a precise anchor. Open notes use the shared neutral popover surface, a short leader line, and an explicit collapse control; warning color is reserved for the anchor and connector. Students can give each note a title directly in its header. Collapsed notes become counted margin markers.

Each topic provides three visibility modes: Open notes, Markers only, and Hidden. They are presented as a direct three-state control rather than a select menu. Visibility changes presentation only and never deletes notes. The same actions remain keyboard reachable, while narrower layouts keep the editor in normal document flow. Open notes can be resized from their lower-right handle with a pointer or the arrow keys; their title and dimensions persist with the note. Notes use the platform-owned annotation store and are included in Settings backup, import, and reset.

Interactive learning-block headers reserve a right-side action gutter for annotation controls so the note button never covers a question title, disclosure chevron, or other block-owned action.

Motion

Motion explains state change; it does not decorate the page. The values follow one framework: the strong ease-out cubic-bezier(0.23, 1, 0.32, 1) for anything entering — immediate movement while the eye is watching, then a slow settle, never ease-in, which delays the first frame — and the symmetric cubic-bezier(0.77, 0, 0.175, 1) for elements that morph on screen (the flashcard reveal, crossfades).

Named rules:

  • The Instant Keyboard Rule. Keyboard-initiated surfaces never animate. The command palette is the exemplar: used hundreds of times a day, it appears and closes in zero ms. Frequency decides: the more often an action repeats, the less it may move.
  • The Press Answer Rule. Every pressable lands at scale(0.96) for ~160ms so the interface answers the hand; hover effects are gated behind hover:hover and pointer:fine.
  • The Entrance Rule. Content arrives from scale(0.95) with opacity, an 8px rise, and a short defocus — never from zero. Groups stagger at 40–60ms for one-shot entrances only; routine interactions never cascade.
  • The Asymmetry Rule. Deliberate fills (hold-to-skip: 900ms linear) release fast (200ms ease-out). Toasts are the one deliberately calm exception: 400ms on plain ease in, 200ms ease-out out, timers paused under the pointer.
  • The Origin Rule. Popovers and tooltips scale out of their trigger via the radix-provided transform origin; modals, anchored to nothing, keep their center.
  • The Skip-Delay Rule. Tooltips wait ~350ms once, then open instantly while the pointer moves across neighbours — moving along a toolbar should feel faster than the first hover.

  • Keep transitions around 150–320ms.

  • Shared buttons use a restrained scale(0.96) response on press; reduced-motion users receive the same state change without the scale.
  • MotionConfig applies the user's reduced-motion preference across every Motion component. Local hooks remain appropriate when a component needs a different non-spatial fallback.
  • Shared fields acknowledge hover and focus with border and ring continuity; icon-bearing buttons, native selects, command results, and linked badges add one small directional or scale cue.
  • Animate disclosure, drawer movement, and flashcard state.
  • Pair the 200ms sidebar collapse with the reader focus dock's short delayed fade-and-rise, so the dock arrives after the reading pane begins to settle rather than competing with the shell.
  • Prefer opacity and transform; avoid large layout shifts.
  • Never loop decorative motion in a reading view.
  • Preserve a clear final state when reduced motion is requested.
  • Animate row affordances as one system: a short surface fade, a two-pixel directional icon shift, and an immediate scale(0.96) press response where the whole row is the control.
  • Sheets arrive in 200–300ms and exit faster; their content is visible in the default state, and reduced motion removes the transition without changing the final state. The command palette — keyboard-initiated — animates never.
  • Conditional quiz feedback, calculator results, tab panels, and command/search empty states use the shared data-motion-rise cue. Values that change in place may use data-motion-pop; neither cue loops, and both resolve immediately under reduced motion.

Motion tokens

One curve family and four durations, declared in src/app/globals.css and mirrored for Motion in src/components/ui/motion.ts. Pick from this list rather than writing a curve inline, so unrelated surfaces settle at the same rate.

Token Tailwind Value Use
--motion-ease-standard ease-standard cubic-bezier(0.2, 0, 0, 1) Interface state changes
--motion-ease-emphasized ease-emphasized cubic-bezier(0.23, 1, 0.32, 1) Entrances and disclosure — the strong ease-out
--motion-ease-morph ease-morph cubic-bezier(0.77, 0, 0.175, 1) On-screen morphs: flips, crossfades
--motion-ease-exit ease-exit cubic-bezier(0.4, 0, 1, 1) Exits
--motion-duration-instant duration-100 100ms Press feedback
--motion-duration-fast duration-150 150ms Hover, focus, exits
--motion-duration-base duration-200 200ms Disclosure, drawers, tabs
--motion-duration-slow duration-[320ms] 320ms Entrances, progress travel

motion.ts additionally exports SPRING (duration: 0.3, bounce: 0) for anything that swaps in place, SPRING_LAYOUT for elements that carry layout with them, and the riseVariants / staggerContainer() pair. Every spring in the product is critically damped: overshoot reads as a toy, and this is a study workspace.

Motion primitives

Primitive Use
Stagger / StaggerItem One-shot entrances outside the persistent workspace, or content the user explicitly reveals. Do not apply it to route destinations: workspace navigation keeps content opaque and stable while the next page mounts. Never wrap a list that re-renders on filter or progress changes.
.stagger-rise The same entrance for server components, staggered by CSS animation-delay.
Skeleton / SkeletonText Loading placeholders in the shape of the content they replace. Never inside a reading view.
AnimatedNumber Any number that changes in place. Carries tabular-nums, so the box does not twitch per digit.
CompletionCheck The mark for something finished for good. animate={false} for a state that was already true on load — a check that redraws on every visit stops meaning "you just did this".
AnimatedIcon Icons whose meaning changes. Scale 0.25 → 1, opacity 0 → 1, blur 4px → 0.
Card interactive Cards that are themselves controls: one pixel of lift and one step on the elevation ramp. A press scale is wrong at card size.

Entrances animate opacity, an 8px rise, and a 4px defocus together; exits move half that distance the other way in half the time, because the reader's attention is already on the next thing.

Prefer CSS transitions for interactive state — they retarget mid-flight, so a double-toggle reverses instead of snapping. Reserve keyframes for one-shot sequences that run once and stop. Progress fills scale on a transform rather than animating width, which would relay out the row around them.

Reference shelf

Use external component libraries as pattern references, not as drop-in visual systems. Adapt useful interaction ideas to Study Hub's semantic tokens, compact geometry, accessibility contract, and reduced-motion behavior. Do not copy looping effects, marketing-page spectacle, or a library's theme.

Accessibility and content quality

  • Every control needs an accessible name and visible focus state.
  • Maintain logical heading order; do not pick a heading level for its size.
  • Images require meaningful alt text. Decorative images use empty alt text.
  • Tables use the shared semantic table components.
  • Do not hide essential actions behind hover.
  • Error, warning, success, and progress states require text or an icon in addition to color.
  • Verify both themes, keyboard navigation, mobile layout, and print behavior for reading content.

UI change checklist

  • Works with every supported font and uses the documented type scale.
  • Uses semantic tokens in both light and dark mode.
  • Reuses or extends a shared primitive.
  • Preserves the single shell and universal course navigation.
  • Works at narrow mobile and desktop widths.
  • Has visible focus, accessible names, and non-color status cues.
  • Uses motion only to explain state.
  • Passes npm run test and npm run typecheck.

For historical reasoning, see the hub redesign specification. The code and this guide describe the current contract.