Design system (Shell v2)

Thundera Shell v2

The single design standard for every Thundera surface: one top bar, one apps launcher, one footer, one set of design tokens, and three page kits (landing, system, docs). Zero dependencies, framework-agnostic — it works the same in React SPAs, server-rendered HTML and static pages.

  • Live: https://thundera.eu/shell/v2/ · Guide: https://thundera.eu/docs/shell
  • Source: portal/src/landing/shell/v2/ (repo thundera-group/portal) · Version: 2.0.3
  • Examples: landing · system · docs · bar only

Files

File What it is Budget (gzip)
tokens.css Design tokens, dark/light/auto themes, per-app brand presets, opt-in base ≤ 12 KB
shell.css Top bar, apps launcher, user menu, mobile drawer, footer, banner, toasts ≤ 12 KB
shell.js Renders the chrome, session, apps catalogue, window.Thundera API ≤ 15 KB
ui.css + ui.js Landing kit and shared primitives (buttons, badges, cards, code) ≤ 12 KB
system.css + system.js App / admin / system pages: frame, sidebar, tables, forms, dialogs ≤ 12 KB
docs.css + docs.js Docs pages: nav, prose, TOC, search, callouts, prev/next ≤ 12 KB
consent.js GDPR analytics consent (same cookie/API as v1, plus enabled()) —
app-gate.js Maintenance / disabled gate for /app surfaces (unchanged from v1) —

The tests (node tests/system/shell-v2.test.js) enforce the budgets.

Caching and pinning

URL Cache Use it when
https://thundera.eu/shell/v2/<file> 5 min + stale-while-revalidate Default. You get every 2.x fix within minutes.
https://thundera.eu/shell/v2.0.3/<file> 1 year, immutable You need a frozen copy (native webviews, release builds).

Breaking changes get a new major directory (/shell/v3/). v1 (/shell/v1/) keeps serving unchanged until every app has migrated.

Quick start — static HTML

<!doctype html>
<html lang="en" class="th" data-brand="whisper" data-th-theme="dark">
<head>
  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
  <link rel="stylesheet" href="https://thundera.eu/shell/v2/tokens.css">
  <link rel="stylesheet" href="https://thundera.eu/shell/v2/shell.css">
  <link rel="stylesheet" href="https://thundera.eu/shell/v2/ui.css">
  <script type="application/json" data-th-config>
  { "app": "whisper",
    "links": [ {"label": "Product", "href": "/#product"}, {"label": "Pricing", "href": "/#pricing"},
               {"label": "Docs", "href": "/docs"}, {"label": "Status", "href": "https://thundera.eu/status"} ],
    "actions": { "signedIn": [ {"label": "Open app", "href": "/app", "kind": "primary"} ] } }
  </script>
  <script src="https://thundera.eu/shell/v2/shell.js" defer></script>
  <script src="https://thundera.eu/shell/v2/ui.js" defer></script>
</head>
<body>
  <header data-th-bar></header>   <!-- reserves the bar height: zero layout shift -->
  <main id="main"> … </main>
  <div data-th-footer></div>      <!-- optional: the ecosystem footer renders here -->
</body>
</html>

That is the whole integration. The shell reads the session from auth.thundera.eu, fills the apps launcher from the live catalogue (apps.icon_url), highlights the current app and renders Sign in / Get started for anonymous visitors.

  • data-brand picks your product's colours (see Brand and theme).
  • class="th" turns on the base styles (font, colours, links). Leave it off inside an existing app whose CSS you do not want to touch — the shell itself is fully scoped.
  • <header data-th-bar> must be the first element in <body>. Without it the shell inserts one, which shifts your content by the bar height once.

React / Vite

Put the slot and the scripts in index.html (outside the React root), then talk to window.Thundera from components.

<!-- index.html -->
<html lang="en" data-brand="photomemory" data-th-theme="dark">
<head>
  <link rel="stylesheet" href="https://thundera.eu/shell/v2/tokens.css">
  <link rel="stylesheet" href="https://thundera.eu/shell/v2/shell.css">
  <script src="https://thundera.eu/shell/v2/shell.js" data-app="photomemory" defer></script>
</head>
<body><header data-th-bar></header><div id="root"></div></body>
</html>
// useThundera.ts — session + nav for the current route
import { useEffect, useState } from 'react';
declare global { interface Window { Thundera?: any } }

export function useThunderaUser() {
  const [user, setUser] = useState(window.Thundera?.user ?? null);
  useEffect(() => {
    window.Thundera?.ready.then(setUser);
    return window.Thundera?.onUserChange(setUser);
  }, []);
  return user;
}

// In your router layout: keep the bar's links and active state in sync.
useEffect(() => {
  window.Thundera?.configure({ links: [
    { label: 'Library', href: '/app' }, { label: 'Albums', href: '/app/albums' },
    { label: 'Family', href: '/app/family' } ], active: location.pathname });
}, [location.pathname]);

Delete the app's own top bar component (ThunderaNav.tsx, TopBar.tsx, …) after the switch.

Server templates

Any template language works — emit the same markup. Example (Jinja / EJS style):

<html lang="en" class="th" data-brand="{{ app_slug }}" data-th-theme="dark">
<head>
  {% include "thundera-head.html" %}   {# the three <link>s + shell.js #}
  <script type="application/json" data-th-config>{{ shell_config | tojson }}</script>
</head>
<body><header data-th-bar></header> … <div data-th-footer></div></body>

shell_config is a plain object (app, links, actions, …). Serialize it with your framework's JSON helper so it is escaped correctly.

Configuration

Three sources, merged in this order (later wins):

  1. <script src=".../shell.js" data-app="eam" data-app-name="EAM" data-footer="true">
  2. <script type="application/json" data-th-config>{…}</script> anywhere in the page
  3. window.ThunderaConfig = {…} set before shell.js runs
  4. Thundera.configure({…}) at runtime (re-renders immediately)
Key Default Meaning
app "thundera" Your apps.slug. Selects the brand preset and the highlighted launcher tile.
name catalogue name Name next to your mark.
href "/" Where your mark links (your landing).
mark catalogue icon_url URL of your compact mark (generated by your brand/generate.py).
links [] Primary nav: [{label, href, active?, external?}]. ≤ 6 items. Active state is automatic (prefix match on pathname); pass active to force it.
active — Href to mark active (SPAs call configure({active: location.pathname})).
actions Sign in + Get started {anonymous: [...], signedIn: [...]} buttons {label, href, kind: "primary"\|"ghost"}.
userMenu [] Extra user-menu items {label, href, icon?} (icons: settings card book shield server).
system — Your own admin page(s) for system roles: {label, href} or an array. "Thundera system" is always added.
docs https://thundera.eu/docs Docs link in the user menu and drawer. Point it at your /docs.
status https://thundera.eu/status Status link in the launcher.
footer null true appends the footer; or {tagline, product: [...], columns: [...]}. A [data-th-footer] slot always renders.
theme "dark" Default theme when the user has not chosen one: dark, light, auto.
themeSwitch true false for dark-only apps (hides the switch, ignores the saved choice).
auth true false skips the session probe (purely static pages).
signIn true false drops the mobile Sign in link, for sites with no account (pair with actions.anonymous: []). The avatar still appears for a visitor who is already signed in.
banner true Maintenance + announcements banner below the bar.
signOut "reload" After sign-out: "reload", "login", or a URL.
main auto Selector for the skip-link target (defaults to main, #main, #root, #app).
strings — UI text in another language: {"<English text>": "<translation>"} for the bar, launcher, drawer, user menu and footer labels (e.g. "Products", "Open menu", the footer tagline, "Built in the EU"). Missing keys stay English. Set <html lang> yourself.

Brand and theme

Every product owns its colours in <app>/brand/tokens.json (its own repo). portal/scripts/shell-brands.py --sync turns them into [data-brand="<slug>"] presets in tokens.css, with accents adjusted for 4.5:1 contrast in both themes. Your app sets one attribute and never writes a colour literal:

<html data-brand="eam">  <!-- whisper eam photomemory aitrade jarvis swarm thundercode ai thundera -->
Token Use
--brand-1, --brand-2, --brand-3 Gradient stops (marks, glows, text gradients)
--brand-grad, --brand-grad-3 Ready-made gradients
--brand-accent / --brand-on Solid accent for buttons/links and the text colour on it (theme-aware)
--brand-soft, --brand-line, --brand-glow Tinted fill, tinted border, coloured shadow

data-brand also works on any element, so a card can wear a sibling app's colours inside your page (the thundera.eu product grid does this). The group identity — thundera, auth, portal — is Thundera orange and stays orange for every app on auth.thundera.eu.

Changed your brand/tokens.json? Ask in your status file's Requests for a re-sync (the portal owner runs python3 scripts/shell-brands.py --sync and commits). New app without a preset? Same — until then it falls back to the group orange.

Theme. data-th-theme on <html> is dark (default), light or auto (follows the OS). It is nestable: <section data-th-theme="dark"> keeps a section dark on a light page. The user's choice from the user menu is stored in localStorage["th.theme"] and applied by shell.js. To avoid a flash for light-theme users, inline this before the stylesheets (only if your CSP allows inline scripts):

<script>try{var t=localStorage.getItem('th.theme');if(t)document.documentElement.dataset.thTheme=t}catch(e){}</script>

Core tokens (all --th-*): surfaces --th-bg --th-bg-2 --th-surface-1…4 --th-glass --th-overlay; text --th-text --th-text-2 --th-text-3; borders --th-border --th-border-2 --th-border-3; semantic --th-success-text --th-warning-text --th-danger-text --th-info-text + *-soft fills; type --th-font-sans --th-font-mono --th-fs-2xs…5xl (fluid) --th-fw-* --th-lh-*; space --th-space-1…32 --th-section-y --th-gutter; radii --th-radius-xs…2xl/full; elevation --th-shadow-1…4; motion --th-ease* --th-dur-1…5 (collapse to 1 ms under reduced motion); layers --th-z-*; layout --th-bar-h --th-container* --th-sidebar-w. Breakpoints: 640 / 900 (shell switches to mobile) / 1200 / 1440.

Fonts: the system stack, with Inter first if the device has it. No web font is required.

Top bar, launcher, user menu

  • Left: Thundera mark (→ thundera.eu) / your mark + name (→ href), then links.
  • Right (signed in): actions.signedIn, apps launcher, avatar → user menu.
  • Right (anonymous): apps launcher, Sign in, Get started (→ auth.thundera.eu with ?redirect=<current URL>).
  • Launcher: every active app from GET /api/v1/auth/apps[/public] with its icon_url, current app highlighted, maintenance badge, search filter (type to filter, Enter opens the first match), arrow keys move across the grid, Esc closes, "All apps" + Status links.
  • User menu: name, email, plan badge (user.plan), Account settings, Billing & plans (hidden for organization members), Docs, your userMenu items, system links for system-* roles, theme switch, Privacy choices (when consent is enabled), Sign out.
  • Mobile (< 900 px): compact bar (marks, launcher, avatar or Sign in, menu button) and a full-height drawer with your links, account actions, theme and sign-out. The drawer traps focus, closes on Esc / scrim / navigation and locks page scroll.
  • Sticky with backdrop blur, safe-area insets, skip link to your main, every control labelled, aria-expanded/aria-controls on every trigger, Esc returns focus.

<div data-th-footer></div> renders: brand + tagline, your product column (defaults to your links), Products (live catalogue), Resources (Docs, Status, Downloads, Plans & billing), Company (About, Privacy, Terms, Contact, Privacy choices), copyright and social links. Customize with footer: {tagline, product: [{label, href}], columns: [{title, links}]}.

JavaScript API — window.Thundera

v1-compatible; v2 additions are marked ★.

Member Description
ready Promise<user \| null> — resolves after the session probe and catalogue load.
user {id, email, name, roles, avatar, plan, account_type, org_role} or null.
apps Catalogue [{slug, name, url, icon_url, status, tagline}].
app {slug, name, icon} of the current app.
appStatus {status, maintenance_message} for the current app.
★ version "2.0.3".
★ config The merged configuration (read-only copy).
★ configure(partial) Merge config and re-render (links, active, actions…).
onUserChange(cb) Subscribe; returns an unsubscribe function.
signOut() POST /auth/logout, scrubs per-app storage, then signOut behaviour.
toast(msg, kind?, ms?) kind: info success warning error.
fetch(url, init?) fetch with credentials: 'include' and JSON Accept.
navigateTo(slug) Go to another app.
refresh() Re-read session, catalogue, status.
theme.get() / .set(v) / ★.resolved() / ★.onChange(cb) Theme preference dark/light/auto.

The shell is read-only with respect to the session: it calls GET /api/v1/auth/me?optional=1 (anonymous visitors get 200 {"user":null} instead of a logged 401; never the rotating /auth/session), never refreshes tokens, never redirects on 401. Your app owns its own refresh. When a different user signs in on the same browser, the shell scrubs every <app>_* key from local/session storage (the cross-user leak guard).

Landing kit (ui.css + ui.js)

Load ui.css (and ui.js for reveal, counters, copy buttons and live pricing). Reference: examples/landing.html.

Component Markup
Layout .th-container (-wide, -narrow), .th-section (-tight, -alt), .th-section-head.th-center
Type .th-eyebrow, .th-display, .th-h1/2/3, .th-lede, .th-grad-text, .th-muted
Buttons .th-btn + -primary -secondary -ghost -link -danger, sizes -sm -lg -block
Badges .th-badge + -brand -success -warning -danger -info; .th-dot(.th-dot-live); .th-pill
Hero .th-hero > .th-hero-bg + .th-container.th-hero-grid > .th-hero-copy + .th-hero-mark (.th-hero-center variant). Put your generated animated mark <img> in .th-hero-mark (+ <span class="th-orbit">).
Logo strip .th-logos-label + .th-logos > span > img
Features .th-features > .th-feature > .th-icon + h3 + p (.th-features-plain)
Bento .th-bento > div with .th-span-3/4/6, .th-row-2, .th-bento-glow
Split .th-split(.th-split-reverse) > div + .th-media; .th-window-bar; .th-checks
Metrics .th-metrics > .th-metric > .th-metric-value[data-th-count] + .th-metric-label
Steps ol.th-steps > li > h3 + p
Pricing <div data-th-pricing="<slug>">fallback</div> — renders your plans from the central catalogue with a monthly/yearly toggle; data-th-src to use another endpoint.
Testimonial figure.th-quote > blockquote + figcaption
FAQ .th-faq > details > summary + p
CTA .th-cta > h2 + .th-lede + .th-hero-actions
Code .th-code > .th-code-head? + pre > code (copy button added); tokens .th-tok-c/k/s/n/f
Cards .th-card (a.th-card lifts), .th-icon, .th-link-arrow; ecosystem .th-apps > a.th-app-card[data-brand] (add data-th-apps to the .th-apps element and the shell fills it from the live apps registry, hiding disabled apps and the current app; data-th-apps="all" keeps the current one. Keep static cards inside as the no-JS snapshot)
Reveal data-th-reveal on any block, data-th-stagger on a parent. Off under reduced motion; on-screen blocks never flash.

System kit (system.css + system.js)

Every app's /system, /admin and settings use this so they look like one product. Reference: examples/system.html.

Component Markup
Frame .th-frame > aside.th-sidebar + main.th-frame-main > .th-page
Sidebar .th-side-head, .th-side-scroll > .th-side-group > .th-side-title + a.th-side-link > svg + .th-side-label (+ .th-count), .th-side-foot. [data-th-sidebar-toggle] collapses (remembered) on desktop and opens the sheet on mobile (.th-side-toggle-d / .th-side-toggle-m).
Page header .th-page-head > div > ol.th-crumbs + h1.th-page-title + p.th-page-desc + .th-page-actions
Stats .th-stats > .th-stat > .th-stat-label + .th-stat-value + .th-stat-foot > .th-delta-up/down (+ svg.th-spark)
Panels .th-panel > .th-panel-head + .th-panel-body + .th-panel-foot; grids .th-grid-2/3; dl.th-kv
Tables .th-table-wrap > table.th-table(.th-table-stack) — sticky header, .th-num, .th-actions-cell, .th-cell-main; .th-table-stack stacks into labelled rows < 640 px. .th-pager.
Forms .th-form, .th-form-grid, .th-field > .th-label + .th-input/.th-select/.th-textarea + .th-hint/.th-error-text; aria-invalid="true"; .th-input-group > .th-addon; .th-check, .th-switch (role="switch"), .th-setting rows
Tabs .th-tabs[role=tablist] > button.th-tab[role=tab][aria-controls] + [role=tabpanel] (arrow keys wired); .th-tabs-pill
Status .th-status-ok/warn/bad/info, .th-alert(-success/-warning/-danger/-info) (in ui.css), .th-empty-state
Loading .th-skeleton.th-skel-title/.th-skel-line/.th-skel-block
Dialog <dialog class="th-dialog"> + .th-dialog-head/body/foot; [data-th-dialog-open="#id"], [data-th-dialog-close]; ThunderaSystem.confirm(msg, {title, ok, danger}) → Promise<boolean>
Charts .th-chart container (series colours --th-chart-1…6), .th-legend, .th-bars > i[style="--v:40%"], .th-meter > i
Toasts Thundera.toast('Saved', 'success')

Docs kit (docs.css + docs.js)

Every app's /docs uses this layout. Reference: this page.

<div class="th-docs" data-th-docs-index="/docs/search.json">
  <nav class="th-docs-nav" aria-label="Documentation">
    <a class="th-docs-back" href="/">← Back to Whisper</a>
    <div class="th-docs-search"><input type="search" placeholder="Search docs" aria-label="Search docs"><kbd>⌘K</kbd></div>
    <section><h2>Getting started</h2><ul><li><a href="/docs">Overview</a></li></ul></section>
  </nav>
  <main class="th-docs-main" id="main">
    <article class="th-prose"><h1>Title</h1><p class="th-lead">…</p><h2>Section</h2>…</article>
    <nav class="th-docs-pager" data-auto aria-label="Pagination"></nav>
    <div class="th-docs-meta"><span>Updated …</span></div>
  </main>
  <aside class="th-docs-toc"><h2>On this page</h2><ul data-th-toc></ul></aside>
</div>
  • Search index: a JSON array [{"title", "url", "section", "text"}] (keep text to the first ~400 characters of each page). / or ⌘K focuses search; arrows + Enter navigate. Without an index, search still covers the nav titles.
  • h2/h3 get ids, # anchors and an "On this page" entry with scroll-spy.
  • pre blocks get a copy button and a language label from class="language-xxx".
  • Callouts: <div class="th-callout th-callout-tip|warning|danger"><strong>Title</strong><p>…</p></div>.
  • .th-docs-pager[data-auto] fills prev/next from the nav order.
  • Mobile: the nav becomes a sheet behind a "Menu" button with the current page name.

Pages that load analytics must load consent first:

<script src="https://thundera.eu/shell/v2/consent.js"></script>  <!-- before analytics -->

With a shell mounted, the floating pill is hidden and "Privacy choices" appears in the user menu, the drawer and the footer. ThunderaConsent.enabled() (new) tells you whether consent is switched on. app-gate.js is unchanged from v1: load it early on /app surfaces.

Content-Security-Policy

Allow, at minimum:

script-src  https://thundera.eu
style-src   https://thundera.eu
img-src     https://thundera.eu https://www.gravatar.com data:
connect-src https://auth.thundera.eu

The kit sets no inline style attributes from markup and no inline event handlers; dynamic values use the CSSOM, which CSP allows.

Accessibility and performance

  • Every interactive element is a real <a>/<button> with an accessible name; menus use the disclosure pattern; the drawer and dialogs trap focus; Esc always closes and returns focus; visible focus rings use the brand accent.
  • Text tokens meet WCAG AA on their surfaces in both themes; brand accents are adjusted to ≥ 4.5:1 by the generator.
  • prefers-reduced-motion collapses every duration and disables reveal/ambient animation.
  • No layout shift: the bar slot is sized by CSS before JS runs; images in the shell carry dimensions; the footer slot reserves space.
  • No web fonts, no icon fonts, no third-party requests except auth.thundera.eu and avatars.

Migrating

From Shell v1

v1 v2
shell/v1/shell.css + shell.js shell/v2/tokens.css + shell.css + shell.js
data-app-slug, data-app-name, data-app-icon data-app, data-app-name, data-app-mark (old names still work)
bar inserted at body start (shifts content) add <header data-th-bar></header> first in <body>
--tdh-* variables --th-* tokens + data-brand
[data-tdh-theme] [data-th-theme] (v2 still sets data-tdh-theme to the resolved value)
shell/v1/consent.js, app-gate.js shell/v2/consent.js, app-gate.js (drop-in)

window.Thundera keeps every v1 member. Load either v1 or v2 on a page, never both (v2 steps aside if v1 is already present).

From a per-app nav (whisper ThunderaNav.tsx, settings.html copies, custom top bars)

  1. Delete the app's top bar, apps menu, avatar menu and footer components.
  2. Add the slot + scripts (above) and move the app's nav items into links.
  3. Replace hardcoded brand colours with data-brand + --brand-*; replace local surface / text colours with --th-* tokens.
  4. Point "Upgrade" buttons at https://auth.thundera.eu/billing?product=<slug>&tier=<tier>&period=<monthly|yearly>.
  5. Rebuild landing sections with ui.css, /system + /admin with system.css, /docs with docs.css.
  6. Screenshot at 390×844 and 1440×900 in both themes; run your E2E suite.

Changing the kit

The shell is owned by the portal repo. Do not fork or override its internals in an app — if you need something it lacks, ask for it (status-file Requests in the modernization program, or an issue on thundera-group/portal). Gates: node tests/system/shell-v2.test.js (budgets, desktop + iPhone behaviour, anonymous vs signed-in, no CLS) and python3 scripts/shell-brands.py --check.