@thespielplatz/tsp-tools-theme

tsp.tools design-system theme as a Nuxt layer (Nuxt UI v4 + Tailwind v4): amber-on-anthracite tokens (light + dark), Nunito/Space Grotesk fonts, per-area colour mode, and the shared app shell. Use globally via `extends`, or scoped to a sub-area.
3,240
Install
npm install @thespielplatz/tsp-tools-theme
Latest Version:0.3.11
Vue:3
License:MIT
Last Updated:Oct 18, 2026
Links: npm
Maintainer: thespielplatz-org

@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.md first — 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 — extends the 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 from assets/css/theme.css and components/. It is the source of truth for how tsp.tools services look; the planning project's architecture/design-system.md is 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 by npm run docs:api and 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/tabler for 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 through node_modules, this reliably registers the layer's components, composables and plugin. (A deep relative path such as extends: ['../..'] 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 @theme block 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-theme only 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's theme.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: write in release.yml is 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

@happydesigns/ui

A reusable Nuxt foundation for content-driven happydesigns websites and applicat...

3,059 1
@minch/bentolio

A minimal portfolio template and layer built with Nuxt 4, Tailwind CSS 4, and GS...

3,384 11
@battlestack/theme

Battlestack Design System Nuxt — the Nuxt UI theme: token values for light and d...

254 3
@northwestu/nuxt-nu-design-kit

Northwest University marketing design system as a Nuxt 4 layer — tokens, CSS com...

192
@revenexx/cover

Cover — revenexx design system for Nuxt. Distributed as a Nuxt layer: generic UI...

15,376

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