@thespielplatz/tsp-tools-theme
v0.2.1
Published
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.
Downloads
56
Maintainers
Readme
@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).
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).
Install
npm i -D @thespielplatz/tsp-tools-themePeer 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 capped at ~2.3.1
The layer declares @nuxt/icon: ~2.3.1 as a direct dependency. This is a temporary cap
around an upstream regression, not a preference.
@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 is set to undefined and every server-side icon load
fails, logging [Icon] failed to load icon … per request. @nuxt/ui only requires
^2.3.1, so any re-resolve of the tree silently moves consumers onto the broken version.
The cap 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. 2.3.1
satisfies @nuxt/ui's own range, so there is one hoisted copy and no duplication.
Remove the cap once a fixed @nuxt/icon ships. To check, drop the pin and look for
failed to load icon in the server log of a production build — the build itself stays green
either way, which is exactly why this is easy to miss.
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>
<div class="flex min-h-svh bg-default text-default">
<TspSidebar>
<template #brand><TspWordmark>trips<span class="text-primary">.</span></TspWordmark></template>
<template #nav>
<TspNavItem to="/" icon="i-tabler-car">Fahrten</TspNavItem>
<TspNavItem to="/settings" icon="i-tabler-settings">Einstellungen</TspNavItem>
</template>
<template #footer>
<TspSidebarFooter github-link="https://github.com/…" version="v1.2.0">
<template #user>…your user link…</template>
<template #logout>…your logout…</template>
</TspSidebarFooter>
</template>
</TspSidebar>
<main class="flex-1 min-w-0 bg-default">
<TspContainer><NuxtPage /></TspContainer>
</main>
</div>
</template>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 | #f8f9fa | #212529 |
| surface (bg-elevated) | #ffffff | #2b3035 |
| border | #dee2e6 | #404041 |
| text | #212529 | #f0f0f0 |
| on-primary (text-inverted) | #212529 | #212529 |
Brand colours (amber, tomato) are the same in both modes; only bg/surface/border/text flip.
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.
Composable — useTspColorMode() → { pref, toggle }, the remembered (cookie) preference
for the themed area.
Components (auto-imported):
| Component | Purpose |
|---|---|
| TspThemeProvider | Scoped wrapper — marks a sub-area as themed (.tsp-theme). |
| TspSidebar | App-shell sidebar frame; slots #brand #nav #footer. |
| TspSidebarFooter | User → Logout → divider → theme toggle → GitHub + version. |
| 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 (muted → filled-amber when active). |
| TspWordmark | Space Grotesk + amber brand wordmark. |
| TspContainer | Centered content column; props width (app | site) and padded. |
App-specific content (branding, nav lists, user identity, GitHub/version values) is injected via slots/props — none of it lives in the layer.
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>tsp<span class="text-primary">.</span>tools</TspWordmark></NuxtLink></template>
<template #nav><a href="#tools">Tools</a></template>
<template #actions><TspThemeToggle /></template>
</TspSiteHeader>
<TspContainer width="site"><NuxtPage /></TspContainer>
<TspSiteFooter>
<template #brand><TspWordmark>tsp<span class="text-primary">.</span>tools</TspWordmark></template>
<template #links><NuxtLink to="/imprint">Imprint</NuxtLink></template>
</TspSiteFooter>TspContainer defaults to width="app" (max-w-4xl, px-6 sm:px-10) — the app/admin measure.
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 defaults are English. The default slot receives { isDark, label } if you want different
markup (an icon-only square button, say) while keeping the mechanism.
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:playgroundThe playground/ (global) and scoped/ demos are direct-child layers that extends: ['..'].
They are excluded from the published package (files allowlist).
Release
The version bump and the git tag are not done by an npm script:
Bump
package.json.version, commit, and push tomain.Publish a GitHub Release for that commit — GitHub creates the
vX.Y.Ztag on publish.Ship the package:
npm run release # lint, then npm publish
npm run release deliberately only lints and publishes. It never bumps the version or tags,
so it can't drift from the version that was already committed and tagged in steps 1–2.
License
MIT © TheSpielplatz
