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/(repothundera-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-brandpicks 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):
<script src=".../shell.js" data-app="eam" data-app-name="EAM" data-footer="true"><script type="application/json" data-th-config>{…}</script>anywhere in the pagewindow.ThunderaConfig = {…}set beforeshell.jsrunsThundera.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), thenlinks. - Right (signed in):
actions.signedIn, apps launcher, avatar → user menu. - Right (anonymous): apps launcher, Sign in, Get started (→
auth.thundera.euwith?redirect=<current URL>). - Launcher: every active app from
GET /api/v1/auth/apps[/public]with itsicon_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, youruserMenuitems, system links forsystem-*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-controlson every trigger, Esc returns focus.
Footer
<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"}](keeptextto the first ~400 characters of each page)./or ⌘K focuses search; arrows + Enter navigate. Without an index, search still covers the nav titles. h2/h3get ids,#anchors and an "On this page" entry with scroll-spy.preblocks get a copy button and a language label fromclass="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.
Consent and app gate
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-motioncollapses 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.euand 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)
- Delete the app's top bar, apps menu, avatar menu and footer components.
- Add the slot + scripts (above) and move the app's nav items into
links. - Replace hardcoded brand colours with
data-brand+--brand-*; replace local surface / text colours with--th-*tokens. - Point "Upgrade" buttons at
https://auth.thundera.eu/billing?product=<slug>&tier=<tier>&period=<monthly|yearly>. - Rebuild landing sections with
ui.css,/system+/adminwithsystem.css,/docswithdocs.css. - 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.