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¶
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¶
- One workspace, many courses. Keep the shell and navigation stable while content changes.
- Content before decoration. Use hierarchy, spacing, and typography before color or shadow.
- Dense, not cramped. Prefer compact rows and readable measure over oversized cards.
- Semantic, not course-branded. Course identity comes from code, name, and icon—not a custom palette or bespoke page layout.
- Keyboard and mobile are first-class. Every action must remain reachable without hover.
- 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. Inter remains the default; 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.
Use one selected family across the entire workspace, including code. Do not introduce local
component fonts, serif display faces, or 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-mediumorfont-semiboldfor hierarchy; reservefont-boldfor course codes or rare emphasis. - Headings use
-0.02emtracking and balanced wrapping globally. - Body copy should normally use
leading-6andtext-muted-foregroundonly 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 cool blue-grey, not a pure grey: chroma rises as lightness falls so shadows stay cool rather than muddy. |
| Accent | configurable | Selected in Settings; primary action, focus ring, progress, sidebar identity, and chart-1. |
Surfaces separate by lightness alone (background 2.5 → card 6.7 → popover 8.6 → accent
16.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, input, ring |
Structure, 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 parselch(). Keep it in sync with--backgroundby 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
8px(--radius: 0.5rem), giving a4 / 6 / 8 / 12scale. Use the sharedrounded-sm,rounded-md,rounded-lg, androunded-xlsteps rather than arbitrary values. - Elevation is four
--elevation-*steps, and Tailwind'sshadow-xs,shadow-sm,shadow-md, andshadow-lgare 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. - Normal controls are 32–40px high. Keep icon targets at least 32px and give isolated mobile actions a 40–44px hit area when space allows.
- Default page content is constrained to
max-w-4xlby 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
├── course switcher
├── active course's six universal sections
├── 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. Entering Edit or Draw, opening a paged topic, printing, or using the mobile drawer
restores the two-column in-flow pagination.
The six course sections are stable:
- Overview
- Topics
- Resources
- Progress
- Reviews
- 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 the
PageHeadercomposition 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
EmptyStatecomposition 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
cnhelper fromsrc/lib/utils.tsfor 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
Iconfromsrc/components/ui/Icon.tsx; names must be validlucide-reactexports. - Use
SettingsSectionfor every settings destination section. It owns the heading, the state summary beside it, the description, an optional action, and the collapsed disclosure row. ReusesettingsRowClassNamefor 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 dense, divided-row destination rather than a dashboard of cards. Its general section order is stable: Account & sync, Appearance, Study Hub AI, Reading, Study sessions, Review scheduling, Drawing & canvas, then Local data. Controls stay in a consistent right-hand column on wider screens and 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, and the page header repeats
the highest-level state — sync, AI routing and picker size, appearance, and installed extensions — as
status chips, without status dots — the chip text says what the state is. The sections a learner sets once (Study sessions, Review scheduling,
Drawing & canvas, Local data, Extensions, Platform usage, Admin only) collapse into
a single stack of summary rows below the frequently used ones, and category navigation opens a
collapsed section before scrolling to it. Collapsing is a page-level decision: a section rendered
outside the settings page, without SettingsDisclosureProvider, always renders expanded.
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.
- 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. MotionConfigapplies 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. - Command palette and sheet entrances 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.
- Conditional quiz feedback, calculator results, tab panels, and command/search empty states use the
shared
data-motion-risecue. Values that change in place may usedata-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.16, 1, 0.3, 1) |
Entrances and disclosure |
--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.
- Foundations and application patterns: shadcn/ui, shadcnblocks, ReUI, Beautiful UI, BeUI, and Rare UI.
- Motion and interaction references: Transitions, Magic UI, Aceternity UI, and You do not need animations.
- AI workspace patterns: AI Elements.
- Review tools: UI Skills, Coss UI, and the Design System Checklist.
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
alttext. 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 testandnpm run typecheck.
For historical reasoning, see the hub redesign specification. The code and this guide describe the current contract.