@thespielplatz/tsp-tools-theme
| Install | |
|---|---|
npm install @thespielplatz/tsp-tools-theme |
|
| Latest Version: | 0.3.11 |
| Vue: | 3 |
| License: | MIT |
| Last Updated: | Oct 18, 2026 |
| Links: | npm |
@thespielplatz/tsp-tools-theme
The tsp.tools design-system theme as a Nuxt layer — amber-on-anthracite tokens (light + dark), Nunito / Space Grotesk fonts, a per-area colour-mode policy, and the shared app shell (sidebar, content container, theme toggle).
Wiring this into an app? Read
AGENTS.mdfirst — what this package owns, which file answers which question, the four failures whose only symptom is absence, and one complete worked example. This README is the install and configuration reference.
Built for the platform stack: Nuxt 4 · Nuxt UI v4 · Tailwind v4. It is the extracted, generalised form of the proven piggybank admin implementation (ADR 008, step 3).
Two consumption modes:
- Global —
extendsthe layer and the whole app is themed (e.g. trips). - Scoped — apply the theme to a sub-area only, leaving the rest of the app on default Nuxt UI, with per-area colour mode (e.g. piggybank: admin is themed + toggleable, the public site stays light).
The design system lives here:
docs/design-system.md— tokens, components, rules and gates, read fromassets/css/theme.cssandcomponents/. It is the source of truth for how tsp.tools services look; the planning project'sarchitecture/design-system.mdis a pointer to it.The playground is the reference, never a mockup.
npm run dev.Every prop, slot and emit:
docs/api.md— generated from the components bynpm run docs:apiand verified in the gate, so it cannot go stale.
Install
npm i -D @thespielplatz/tsp-tools-theme
Peer expectations (provide them in the consuming app):
nuxt^4,@nuxt/ui^4 (Tailwind v4 comes with Nuxt UI).- Tabler icons — the theme uses
i-tabler-*(e.g.i-tabler-sun,i-tabler-brand-github). Install@iconify-json/tablerfor offline icons, or rely on Iconify's network fetch. Icons are not bundled. Keep one icon family across the platform; don't mix in a second set.
Extend by package name (
@thespielplatz/tsp-tools-theme) — resolved throughnode_modules, this reliably registers the layer's components, composables and plugin. (A deep relative path such asextends: ['../..']can fail to pick up the source dirs; the package name always works.)
Why @nuxt/icon is pinned at all
The layer declares @nuxt/icon as a direct dependency on a tilde range — currently
~2.5.1. The range is deliberate, even though the original regression is fixed.
@nuxt/icon 2.4.1 changed its plugin to take $fetch from useRequestFetch() and then read
.native off it. During SSR that returns Nitro's request-scoped event.$fetch, which has no
.native property — so Iconify's fetch was set to undefined and every server-side icon load
failed, logging [Icon] failed to load icon … per request. The layer capped at ~2.3.1 to
escape it. 2.5.1 no longer has the bug, so the cap was lifted (2026-09-20).
It stays a tilde range rather than a caret: @nuxt/ui only requires ^2.3.x, so any
re-resolve of the tree can move consumers across minors on its own, and that is precisely how
the broken version reached apps the first time. A tilde means a future 2.6 has to be chosen,
and re-gated, by a human.
The pin is in dependencies rather than overrides on purpose: npm honours overrides only
in the root project, so an overrides entry here would not reach consuming apps.
Any change to this version must be re-gated with npm run test:icons, which boots both
built demo apps, requests every route, and fails on either of @nuxt/icon's two runtime failures
in the server log — failed to load icon and timed out. Icons resolve at runtime, per request;
lint, typecheck and the build all stay green with every glyph missing, which is exactly why this
is easy to miss.
Checking a deployed app is a different job, because you do not have its log — you read the
rendered markup instead, and the obvious selector finds nothing. mask-image: url( never appears;
the glyph is inlined as --svg:url("data:image/svg+xml,…") and mask-image is always
var(--svg). The measured form, and the assertion that keeps its value, are in
docs/design-system.md — stated
once, there, rather than repeated here where the two would drift.
Global usage (whole app themed)
nuxt.config.ts:
export default defineNuxtConfig({
extends: ['@thespielplatz/tsp-tools-theme'],
runtimeConfig: {
public: { tspTheme: { apply: 'global' } }, // this is also the default
},
})
That's it — the colour-mode plugin puts the .tsp-theme scope class on <html>, so every
Nuxt UI component renders amber-on-anthracite, dark by default, toggleable app-wide. Drop the
shell components wherever you like:
<template>
<TspAppShell app-name="trips">
<template #brand>
<TspBrandTile><img src="https://raw.githubusercontent.com/inf0matics/tsp-tools-theme/HEAD/logo.svg" alt="trips"></TspBrandTile>
<TspWordmark name="trips" />
</template>
<template #nav>
<TspNavItem to="/" icon="i-tabler-car" badge="Owner" badge-tone="primary">Fahrten</TspNavItem>
<TspNavGroup to="/reports" icon="i-tabler-file-text" label="Berichte">
<TspNavSubItem to="/reports/tax" icon="i-tabler-receipt">Steuer</TspNavSubItem>
</TspNavGroup>
<TspNavSection label="Extern" />
<TspNavItem to="/handbook" icon="i-tabler-book">Handbuch</TspNavItem>
</template>
<template #footer>
<TspSidebarFooter
v-model="locale"
app-name="trips"
user="fil" user-to="/user"
settings-to="/settings" logout-href="/auth/logout"
:locales="['de', 'en']"
github-link="https://github.com/…" version="v1.2.0" version-link="https://github.com/…/releases/tag/v1.2.0"
tip-jar tool-of
/>
</template>
<NuxtPage />
</TspAppShell>
</template>
TspAppShell is the whole frame — root element, sidebar, content column, the mobile top bar
and the measure. It picks a plain div or <TspThemeProvider> as its root from your
apply setting, so the same layout serves global and scoped consumption. Don't hand-roll it:
that frame was written out by hand in all eight services and no two of them agreed (spec 08).
Scoped usage (one sub-area themed)
nuxt.config.ts:
export default defineNuxtConfig({
extends: ['@thespielplatz/tsp-tools-theme'],
runtimeConfig: {
public: {
tspTheme: {
apply: 'scoped',
pathPrefix: '/admin', // routes that are "themed"
publicMode: 'light', // colour mode forced on every other route
defaultMode: 'dark', // default for themed routes (remembered in a cookie)
},
},
},
})
Wrap the themed area in <TspThemeProvider>; everything outside it stays default Nuxt UI:
<!-- pages/admin/index.vue -->
<template>
<TspThemeProvider class="flex min-h-svh">
<TspSidebar> … </TspSidebar>
<main class="flex-1 min-w-0 bg-default">
<TspContainer> … </TspContainer>
</main>
</TspThemeProvider>
</template>
The colour-mode plugin then forces publicMode on non-pathPrefix routes and uses the
remembered preference inside the themed area — so the public and themed surfaces never fight
over light/dark.
What's in the box
Tokens (assets/css/theme.css) — exposed as Nuxt UI CSS variables under the .tsp-theme
scope:
| Group | Light | Dark |
|---|---|---|
primary (amber) |
#fbad18 |
#fbad18 |
error (tomato) |
#ff6347 |
#ff6347 |
bg (page) |
#f1f3f5 |
#212529 |
bg-muted (the shell) |
#f8f9fa |
#2b3035 |
bg-elevated (raised content) |
#ffffff |
#31373e |
border |
#dee2e6 |
#404041 |
text |
#212529 |
#f0f0f0 |
on-primary (text-inverted) |
#212529 |
#212529 |
Brand colours (amber, tomato) are the same in both modes; only surfaces, borders and text flip.
Three rungs, not two — the shell uses bg-muted, raised content uses bg-elevated. See
docs/design-system.md for the full ladder and the reason it changed.
The amber ramp is also available as Tailwind utilities (*-tsp-amber-{50..950}).
Fonts — Nunito (text + headings) and Space Grotesk (logo), via @nuxt/fonts. Use the
tsp-wordmark class (or <TspWordmark>) for Space Grotesk; everything else is Nunito.
Composables (auto-imported) — useTspColorMode() → { pref, toggle }, the remembered
(cookie) preference for the themed area; useTspNavDrawer() → { isOpen, open, close, toggle },
the mobile drawer's shared state; useTspCopy() → { copied, copy }, clipboard plus a moment of
confirmation, with a fallback for non-secure contexts; useTspReveal() → { revealed, toggle, mask }, show/hide a secret with a fixed-length placeholder.
Components (auto-imported):
| Component | Purpose |
|---|---|
TspThemeProvider |
Scoped wrapper — marks a sub-area as themed (.tsp-theme). |
TspAppShell |
The whole logged-in frame — root, sidebar, content column, top bar, measure. Start here. |
TspSidebar |
The sidebar alone; slots #brand #nav #footer. TspAppShell wraps it for you. |
TspSidebarFooter |
User → [nav items] → Settings → Logout → divider → theme + language → meta icons + version → "A tool of tsp.tools". Rows are props, not slots; nav-items is the one insertion point. |
TspSiteHeader |
Public/marketing header; slots #brand #nav #actions, prop sticky. |
TspSiteFooter |
Public/marketing footer; slots #brand #links #actions. |
TspThemeToggle |
Sun/moon light–dark toggle (uses useTspColorMode). |
TspNavItem |
Sidebar nav link — white at rest, amber text when active. Optional badge. |
TspNavGroup |
A nav item with always-visible sub-items on a hairline spine. |
TspNavSubItem |
A child row — muted, own icon, amber when active. |
TspNavSection |
A rule, optionally labelled (External). |
TspNavBadge |
Outline pill on a nav row; tones primary | neutral. |
TspNavLabel |
Truncates a label and reveals it in full on hover. Used by the rows above. |
TspBrandTile |
The frame around a service's mark — sidebar (24px) or hero (64/96px). |
TspUserCard |
Account card for a service's user page; masked id + manage link. |
TspPageHeader |
The page title and its lede. The heading scale lives here — don't hand-roll an h1. |
TspAccountPage |
The standalone account page, whole: heading + TspUserCard. |
TspMcpSettings |
The whole MCP connection page — server, AI-processing warning, Claude, generic. |
TspFooterRow |
One sidebar-footer row — to / href / button. |
TspLanguageToggle |
DE/EN segmented control; all strings are props. |
TspVersionBadge |
Version pill with a tag icon; a link only when given href. |
TspWordmark |
<TspWordmark name="trips" /> → white name + amber dot. |
TspTopBar |
Mobile top bar — burger, brand, page title. Pairs with TspSidebar. |
TspSectionCard |
Icon + title over content, on the raised surface. |
TspPageTabs |
Area tabs as routes, so the back button works. |
TspCopyField |
Read-only value + copy; secret masks it behind a reveal. |
TspCopyButton |
Copy with a tick confirmation. |
TspCodeBlock |
Config to copy; shown and copied values can differ. |
TspBanner |
Dismissible strip; emits dismiss, remembering it is the app's job. |
TspReleaseNotes |
Release notes; props mirror the tsp-release skill's shape. |
TspToolOf |
"A tool of tsp.tools". Shared by both footers. |
TspContainer |
Centered content column; props width (app | site) and padded. |
App-specific content (branding, nav lists, user identity, GitHub/version values) is injected via
props — none of it lives in the layer. Prefer props over slots for the footer rows every app
has: a slot invites each app to restyle its own copy, which is the drift TspFooterRow exists to
stop. #user survives only for apps needing more than a name.
Open source is derived from github-link. Set it and the meta row shows a GitHub icon and the
version badge links to the release; leave it out and the badge is plain text — version-link
is then ignored, because a closed-source project has no release page to point at. There is no
variant prop; a second source of truth could disagree with the first.
Three of them are whole pages
TspPageHeader, TspAccountPage and TspMcpSettings are not building blocks — they are the
page. Reach for them before assembling one out of TspSectionCard and TspCopyField:
<TspMcpSettings
endpoint="https://todo.tsp.tools/mcp"
:api-key="key"
server-name="todo"
:labels="{ apiKey: t('mcp.key'), … }"
@regenerate="rotate"
/>
That is the whole page — the server card, the third-party-processing warning, the Claude
snippet (redacted on screen, complete on copy) and the generic client card, in that order. It
exists because the order is what drifted: the page reached four copies in three shapes before
it was a component. Every visible string is an optional key on labels; see
docs/api.md for the full interface.
App shell vs site shell
TspSidebar + TspSidebarFooter are the logged-in shell. For a public/marketing surface
use TspSiteHeader + TspSiteFooter with the wide container:
<TspSiteHeader>
<template #brand><NuxtLink to="/"><TspWordmark name="trips" /></NuxtLink></template>
<template #nav><a href="#tools">Tools</a></template>
<template #actions><TspThemeToggle icon-only /></template>
</TspSiteHeader>
<TspContainer width="site"><NuxtPage /></TspContainer>
<TspSiteFooter>
<template #brand><TspWordmark name="trips" /></template>
<template #links><NuxtLink to="/imprint">Imprint</NuxtLink></template>
</TspSiteFooter>
TspContainer defaults to width="app" (max-w-6xl = 1152px, px-6 sm:px-10) — the app/admin
measure. Cap prose on the paragraph (max-w-2xl), not by narrowing the page.
width="site" is max-w-[1240px], px-6 md:px-10, the marketing measure. Both add pt-10 pb-16
unless you pass :padded="false" (which is what the header/footer frames do internally).
Localising the theme toggle
TspThemeToggle takes all four strings as props, named by destination — …ToLight is what the
button shows and announces while dark:
<TspThemeToggle
:label-to-light="t('theme.light')" :label-to-dark="t('theme.dark')"
:aria-label-to-light="t('theme.toLight')" :aria-label-to-dark="t('theme.toDark')"
/>
The visible defaults are Light / Dark — one word, because "mode" earns nothing beside a
sun icon and the label shares a 224px row with the language control. The aria-labels keep the
full "Switch to light mode". Pass icon-only for a compact icon button (the site header and
tight rows); the default slot still receives { isDark, label } if you want other markup.
Why primary isn't set in app.config
app.config is global. Setting ui.colors.primary = 'amber' there would make every Nuxt
UI component amber across the whole app, which breaks scoped mode. Instead all brand theming
lives in the .tsp-theme CSS scope, which custom properties resolve per element — so amber
only applies inside the scope. For global mode the plugin simply puts .tsp-theme on <html>.
The mode groups are written for both layouts: .dark .tsp-theme (scoped) and
.dark.tsp-theme (global, both classes on <html>).
CSS ownership
assets/css/theme.css owns the framework import (@import "tailwindcss"; @import "@nuxt/ui";)
and is added to the Nuxt css array automatically when you extend the layer. How a consumer
handles its own CSS depends on whether it has its own @theme:
-
No own design tokens (global apps, or scoped apps whose non-themed area uses plain Nuxt UI): you don't need any framework import — the layer provides it. If you had your own
@import "tailwindcss", drop it. -
Has its own
@theme(e.g. a scoped app with a separate public-area palette/fonts): keep your own@import "tailwindcss"; @import "@nuxt/ui";in that stylesheet. A@themeblock only compiles inside a Tailwind entry, so it can't piggyback on this layer's stylesheet — yours is a separate Tailwind graph. The duplicate import is intentional and harmless: both graphs share the same runtime CSS variables, and.tsp-themeonly re-points Nuxt UI's variables inside the themed subtree.Proven by the piggybank adoption: the public Piggy palette stays in the app's own
main.css(its own Tailwind entry); the admin tsp tokens come from this layer'stheme.css.
The layer's stylesheet is also exposed for manual import if you ever need it:
@import "@thespielplatz/tsp-tools-theme/assets/css/theme.css".
Develop
npm install
npm run dev # global demo (playground/) → http://localhost:3000
npm run dev:scoped # scoped demo (scoped/) → public vs /admin
npm run lint
npm run build:playground
The playground/ (global) and scoped/ demos are direct-child layers that extends: ['..'].
They are excluded from the published package (files allowlist).
Release
One command (ADR 005):
npm run bump-version
It runs the full gate, then changelogen --release writes CHANGELOG.md, bumps
package.json, commits and tags vX.Y.Z, then pushes the commit and the tag.
The pushed tag is what publishes. .github/workflows/release.yml re-runs the gate on a
clean checkout of the tagged commit and publishes to npm with provenance, so the tarball eight
services install is built from a tagged commit rather than from whatever a laptop had in its
working tree. It also refuses to publish if the tag and package.json disagree.
| Command | |
|---|---|
npm run gate |
everything CI runs — lint, unit, docs:api:check, typecheck, icons, E2E |
npm run bump-version |
the release: gate → changelog + version + tag → push |
npm run release |
break-glass — gate then npm publish from here, for when Actions is down |
There is no npm token. The package uses npm trusted publishing: npmjs.com is configured to trust this repository and this workflow file, GitHub mints a short-lived OIDC token for the run, and npm verifies it. Nothing long-lived is stored in the repo, so there is no secret to rotate and none to leak. Two consequences worth knowing:
id-token: writeinrelease.ymlis the authentication. Remove it and the publish has no way to authenticate at all.- The workflow upgrades npm before publishing. Node 22 bundles npm 10, which has no OIDC support and would silently look for a token that does not exist; trusted publishing needs npm ≥ 11.5.1 and Node ≥ 22.14.0, and the step fails loudly rather than falling back.
There is no provenance, and that is not an oversight. npm only signs provenance for a
public source repository; this one is private, so npm skips the attestation. Asking for it
explicitly does not produce one — it fails the publish with a 422 (Unsupported GitHub Actions source repository visibility: "private"), which is how the first v0.3.1 attempt died. If this
repository is ever made public, set publishConfig.provenance and restore the workflow step that
checks the attestation actually landed.
Trusted publishing is unaffected and is the substantive win either way: there is no token to leak, and the registry records the publisher as GitHub Actions over OIDC rather than a person.
0.3.0 is a special case: it needs a tag, not a bump. Its version was already written into
package.json by hand (0a5c793) and its changelog entry is written, so two of changelogen's
three jobs are done. The third — changelogen --release — cannot run: it refuses a bump whose
result equals the current version (bumpVersion returns false, reported as "Unable to bump
version based on changes"), and -r 0.3.0 on a package already at 0.3.0 is exactly that. So
tag it directly:
npm run gate && git tag -a v0.3.0 -m "v0.3.0" && git push --follow-tags
From 0.3.1 on, npm run bump-version is the path — changelogen picks the next version from the
commits, so there is nothing to force.
License
MIT © TheSpielplatz
Related Packages
A reusable Nuxt foundation for content-driven happydesigns websites and applicat...
A minimal portfolio template and layer built with Nuxt 4, Tailwind CSS 4, and GS...
Battlestack Design System Nuxt — the Nuxt UI theme: token values for light and d...
Northwest University marketing design system as a Nuxt 4 layer — tokens, CSS com...
Cover — revenexx design system for Nuxt. Distributed as a Nuxt layer: generic UI...
Version History
| Version | Released | License |
|---|---|---|
| 0.3.11 | MIT | |
| 0.3.10 | MIT | |
| 0.3.9 | MIT | |
| 0.3.8 | MIT | |
| 0.3.7 | MIT | |
| 0.3.6 | MIT | |
| 0.3.5 | MIT | |
| 0.3.4 | MIT | |
| 0.3.3 | MIT | |
| 0.3.2 | MIT | |
| 0.3.1 | MIT | |
| 0.3.0 | MIT | |
| 0.2.1 | MIT | |
| 0.2.0 | MIT | |
| 0.1.3 | MIT | |
| 0.1.2 | MIT | |
| 0.1.1 | MIT | |
| 0.1.0 | MIT |