@thespielplatz/tsp-tools-theme 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 TheSpielplatz
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,193 @@
1
+ # @thespielplatz/tsp-tools-theme
2
+
3
+ The **tsp.tools design-system theme** as a Nuxt layer — amber-on-anthracite tokens
4
+ (light + dark), Nunito / Space Grotesk fonts, a per-area colour-mode policy, and the
5
+ shared app shell (sidebar, content container, theme toggle).
6
+
7
+ Built for the platform stack: **Nuxt 4 · Nuxt UI v4 · Tailwind v4**. It is the extracted,
8
+ generalised form of the proven piggybank admin implementation (ADR 008, step 3).
9
+
10
+ Two consumption modes:
11
+
12
+ - **Global** — `extends` the layer and the whole app is themed (e.g. _trips_).
13
+ - **Scoped** — apply the theme to a sub-area only, leaving the rest of the app on default
14
+ Nuxt UI, with per-area colour mode (e.g. _piggybank_: admin is themed + toggleable, the
15
+ public site stays light).
16
+
17
+ ---
18
+
19
+ ## Install
20
+
21
+ ```bash
22
+ npm i -D @thespielplatz/tsp-tools-theme
23
+ ```
24
+
25
+ Peer expectations (provide them in the consuming app):
26
+
27
+ - **`nuxt` ^4**, **`@nuxt/ui` ^4** (Tailwind v4 comes with Nuxt UI).
28
+ - **Tabler icons** — the theme uses `i-tabler-*` (e.g. `i-tabler-sun`, `i-tabler-brand-github`).
29
+ Install `@iconify-json/tabler` for offline icons, or rely on Iconify's network fetch. Icons
30
+ are **not** bundled. Keep one icon family across the platform; don't mix in a second set.
31
+
32
+ > **Extend by package name** (`@thespielplatz/tsp-tools-theme`) — resolved through
33
+ > `node_modules`, this reliably registers the layer's components, composables and plugin.
34
+ > (A deep relative path such as `extends: ['../..']` can fail to pick up the source dirs;
35
+ > the package name always works.)
36
+
37
+ ---
38
+
39
+ ## Global usage (whole app themed)
40
+
41
+ `nuxt.config.ts`:
42
+
43
+ ```ts
44
+ export default defineNuxtConfig({
45
+ extends: ['@thespielplatz/tsp-tools-theme'],
46
+ runtimeConfig: {
47
+ public: { tspTheme: { apply: 'global' } }, // this is also the default
48
+ },
49
+ })
50
+ ```
51
+
52
+ That's it — the colour-mode plugin puts the `.tsp-theme` scope class on `<html>`, so every
53
+ Nuxt UI component renders amber-on-anthracite, dark by default, toggleable app-wide. Drop the
54
+ shell components wherever you like:
55
+
56
+ ```vue
57
+ <template>
58
+ <div class="flex min-h-svh bg-default text-default">
59
+ <TspSidebar>
60
+ <template #brand><TspWordmark>trips<span class="text-primary">.</span></TspWordmark></template>
61
+ <template #nav>
62
+ <TspNavItem to="/" icon="i-tabler-car">Fahrten</TspNavItem>
63
+ <TspNavItem to="/settings" icon="i-tabler-settings">Einstellungen</TspNavItem>
64
+ </template>
65
+ <template #footer>
66
+ <TspSidebarFooter github-link="https://github.com/…" version="v1.2.0">
67
+ <template #user>…your user link…</template>
68
+ <template #logout>…your logout…</template>
69
+ </TspSidebarFooter>
70
+ </template>
71
+ </TspSidebar>
72
+ <main class="flex-1 min-w-0 bg-default">
73
+ <TspContainer><NuxtPage /></TspContainer>
74
+ </main>
75
+ </div>
76
+ </template>
77
+ ```
78
+
79
+ ## Scoped usage (one sub-area themed)
80
+
81
+ `nuxt.config.ts`:
82
+
83
+ ```ts
84
+ export default defineNuxtConfig({
85
+ extends: ['@thespielplatz/tsp-tools-theme'],
86
+ runtimeConfig: {
87
+ public: {
88
+ tspTheme: {
89
+ apply: 'scoped',
90
+ pathPrefix: '/admin', // routes that are "themed"
91
+ publicMode: 'light', // colour mode forced on every other route
92
+ defaultMode: 'dark', // default for themed routes (remembered in a cookie)
93
+ },
94
+ },
95
+ },
96
+ })
97
+ ```
98
+
99
+ Wrap the themed area in `<TspThemeProvider>`; everything outside it stays default Nuxt UI:
100
+
101
+ ```vue
102
+ <!-- pages/admin/index.vue -->
103
+ <template>
104
+ <TspThemeProvider class="flex min-h-svh">
105
+ <TspSidebar> … </TspSidebar>
106
+ <main class="flex-1 min-w-0 bg-default">
107
+ <TspContainer> … </TspContainer>
108
+ </main>
109
+ </TspThemeProvider>
110
+ </template>
111
+ ```
112
+
113
+ The colour-mode plugin then forces `publicMode` on non-`pathPrefix` routes and uses the
114
+ remembered preference inside the themed area — so the public and themed surfaces never fight
115
+ over light/dark.
116
+
117
+ ---
118
+
119
+ ## What's in the box
120
+
121
+ **Tokens** (`assets/css/theme.css`) — exposed as Nuxt UI CSS variables under the `.tsp-theme`
122
+ scope:
123
+
124
+ | Group | Light | Dark |
125
+ |---|---|---|
126
+ | `primary` (amber) | `#fbad18` | `#fbad18` |
127
+ | `error` (tomato) | `#ff6347` | `#ff6347` |
128
+ | `bg` | `#f8f9fa` | `#212529` |
129
+ | `surface` (`bg-elevated`) | `#ffffff` | `#2b3035` |
130
+ | `border` | `#dee2e6` | `#404041` |
131
+ | `text` | `#212529` | `#f0f0f0` |
132
+ | `on-primary` (`text-inverted`) | `#212529` | `#212529` |
133
+
134
+ Brand colours (amber, tomato) are the same in both modes; only bg/surface/border/text flip.
135
+ The amber ramp is also available as Tailwind utilities (`*-tsp-amber-{50..950}`).
136
+
137
+ **Fonts** — Nunito (text + headings) and Space Grotesk (logo), via `@nuxt/fonts`. Use the
138
+ `tsp-wordmark` class (or `<TspWordmark>`) for Space Grotesk; everything else is Nunito.
139
+
140
+ **Composable** — `useTspColorMode()` → `{ pref, toggle }`, the remembered (cookie) preference
141
+ for the themed area.
142
+
143
+ **Components** (auto-imported):
144
+
145
+ | Component | Purpose |
146
+ |---|---|
147
+ | `TspThemeProvider` | Scoped wrapper — marks a sub-area as themed (`.tsp-theme`). |
148
+ | `TspSidebar` | App-shell sidebar frame; slots `#brand` `#nav` `#footer`. |
149
+ | `TspSidebarFooter` | User → Logout → divider → theme toggle → GitHub + version. |
150
+ | `TspThemeToggle` | Sun/moon light–dark toggle (uses `useTspColorMode`). |
151
+ | `TspNavItem` | Sidebar nav link (muted → filled-amber when active). |
152
+ | `TspWordmark` | Space Grotesk + amber brand wordmark. |
153
+ | `TspContainer` | Centered content column (`max-w-4xl`, `px-6 sm:px-10`, `pt-10 pb-16`). |
154
+
155
+ App-specific content (branding, nav lists, user identity, GitHub/version values) is injected
156
+ via slots/props — none of it lives in the layer.
157
+
158
+ ---
159
+
160
+ ## Why `primary` isn't set in `app.config`
161
+
162
+ `app.config` is global. Setting `ui.colors.primary = 'amber'` there would make **every** Nuxt
163
+ UI component amber across the whole app, which breaks scoped mode. Instead all brand theming
164
+ lives in the `.tsp-theme` CSS scope, which custom properties resolve per element — so amber
165
+ only applies inside the scope. For global mode the plugin simply puts `.tsp-theme` on `<html>`.
166
+ The mode groups are written for both layouts: `.dark .tsp-theme` (scoped) **and**
167
+ `.dark.tsp-theme` (global, both classes on `<html>`).
168
+
169
+ ## CSS ownership
170
+
171
+ `assets/css/theme.css` owns the framework import (`@import "tailwindcss"; @import "@nuxt/ui";`)
172
+ so a consumer does **not** add its own. A scoped consumer that already imports the framework in
173
+ its own stylesheet should drop that import and let this layer provide it (then keep only its own
174
+ app-specific tokens in a CSS file with no framework import).
175
+
176
+ ---
177
+
178
+ ## Develop
179
+
180
+ ```bash
181
+ npm install
182
+ npm run dev # global demo (playground/) → http://localhost:3000
183
+ npm run dev:scoped # scoped demo (scoped/) → public vs /admin
184
+ npm run lint
185
+ npm run build:playground
186
+ ```
187
+
188
+ The `playground/` (global) and `scoped/` demos are direct-child layers that `extends: ['..']`.
189
+ They are excluded from the published package (`files` allowlist).
190
+
191
+ ## License
192
+
193
+ MIT © TheSpielplatz
package/app.config.ts ADDED
@@ -0,0 +1,20 @@
1
+ // Nuxt UI preset for the tsp.tools theme.
2
+ //
3
+ // IMPORTANT — why `primary` is NOT set here:
4
+ // `app.config` is global, so setting `ui.colors.primary = 'tsp-amber'` would
5
+ // make EVERY Nuxt UI component amber across the whole app. That breaks the
6
+ // scoped consumption mode (where only a sub-area should be themed). Instead the
7
+ // amber primary — and the light/dark surfaces and danger — are supplied by the
8
+ // `.tsp-theme` CSS scope (see assets/css/theme.css), which works for BOTH the
9
+ // global and scoped modes (the colour-mode plugin puts `.tsp-theme` on <html>
10
+ // for global apps).
11
+ //
12
+ // A purely-global consumer MAY additionally set `ui.colors.primary` in its own
13
+ // app.config if it prefers the app-config route, but it is not required.
14
+ export default defineAppConfig({
15
+ ui: {
16
+ // Large radius by default (design-system.md → Radius). The `.tsp-theme`
17
+ // scope also sets `--ui-radius`, this keeps non-scoped previews consistent.
18
+ // Components read semantic colours, which the `.tsp-theme` scope overrides.
19
+ },
20
+ })
@@ -0,0 +1,140 @@
1
+ /*
2
+ * tsp.tools design-system theme — tokens for Nuxt UI v4 / Tailwind v4.
3
+ *
4
+ * This stylesheet owns the framework import (tailwindcss + @nuxt/ui) so a
5
+ * consumer does NOT add its own — see README "CSS ownership".
6
+ *
7
+ * All semantic theming lives under the `.tsp-theme` scope class rather than on
8
+ * `:root`. That single mechanism powers both consumption modes:
9
+ * - GLOBAL: the colour-mode plugin adds `.tsp-theme` to <html>, so the whole
10
+ * app is themed.
11
+ * - SCOPED: a consumer wraps a sub-area in <TspThemeProvider> (a `.tsp-theme`
12
+ * element); everything outside it keeps default Nuxt UI.
13
+ *
14
+ * Because custom properties resolve per element, the brand colours only apply
15
+ * inside `.tsp-theme` — the rest of a scoped app is untouched. The mode groups
16
+ * are written for BOTH layouts: `.dark .tsp-theme` (scoped — class on a child of
17
+ * <html>.dark) and `.dark.tsp-theme` (global — both classes on <html>).
18
+ */
19
+ @import "tailwindcss";
20
+ @import "@nuxt/ui";
21
+
22
+ @theme static {
23
+ /* Fonts (design-system.md → Fonts). Nunito = text + headings; Space Grotesk
24
+ = logo/wordmark only. */
25
+ --font-tsp: 'Nunito', sans-serif;
26
+ --font-tsp-logo: 'Space Grotesk', sans-serif;
27
+
28
+ /* Amber ramp — the brand primary. Anchored on the brand tokens:
29
+ 400 = #fbad18 (primary), 500 = #f7941e (hover), 600 = #f47920 (active). */
30
+ --color-tsp-amber-50: #fff8e9;
31
+ --color-tsp-amber-100: #feecc2;
32
+ --color-tsp-amber-200: #fdd988;
33
+ --color-tsp-amber-300: #fcc54e;
34
+ --color-tsp-amber-400: #fbad18;
35
+ --color-tsp-amber-500: #f7941e;
36
+ --color-tsp-amber-600: #f47920;
37
+ --color-tsp-amber-700: #c75f17;
38
+ --color-tsp-amber-800: #9e4c16;
39
+ --color-tsp-amber-900: #7f4015;
40
+ --color-tsp-amber-950: #461f07;
41
+
42
+ /* Tomato ramp — danger (design-system.md → Brand colours: `#ff6347`). */
43
+ --color-tsp-tomato-50: #fff1ee;
44
+ --color-tsp-tomato-100: #ffe0d9;
45
+ --color-tsp-tomato-200: #ffc3b5;
46
+ --color-tsp-tomato-300: #ff9d85;
47
+ --color-tsp-tomato-400: #ff7a5c;
48
+ --color-tsp-tomato-500: #ff6347;
49
+ --color-tsp-tomato-600: #ed4a2b;
50
+ --color-tsp-tomato-700: #c8381d;
51
+ --color-tsp-tomato-800: #a5301b;
52
+ --color-tsp-tomato-900: #882c1c;
53
+ --color-tsp-tomato-950: #4a1109;
54
+ }
55
+
56
+ /* Brand colours + radii + font — mode-independent. */
57
+ .tsp-theme {
58
+ font-family: var(--font-tsp);
59
+
60
+ /* primary → amber */
61
+ --ui-color-primary-50: var(--color-tsp-amber-50);
62
+ --ui-color-primary-100: var(--color-tsp-amber-100);
63
+ --ui-color-primary-200: var(--color-tsp-amber-200);
64
+ --ui-color-primary-300: var(--color-tsp-amber-300);
65
+ --ui-color-primary-400: var(--color-tsp-amber-400);
66
+ --ui-color-primary-500: var(--color-tsp-amber-500);
67
+ --ui-color-primary-600: var(--color-tsp-amber-600);
68
+ --ui-color-primary-700: var(--color-tsp-amber-700);
69
+ --ui-color-primary-800: var(--color-tsp-amber-800);
70
+ --ui-color-primary-900: var(--color-tsp-amber-900);
71
+ --ui-color-primary-950: var(--color-tsp-amber-950);
72
+ --ui-primary: var(--color-tsp-amber-400);
73
+
74
+ /* danger/error → tomato */
75
+ --ui-color-error-50: var(--color-tsp-tomato-50);
76
+ --ui-color-error-100: var(--color-tsp-tomato-100);
77
+ --ui-color-error-200: var(--color-tsp-tomato-200);
78
+ --ui-color-error-300: var(--color-tsp-tomato-300);
79
+ --ui-color-error-400: var(--color-tsp-tomato-400);
80
+ --ui-color-error-500: var(--color-tsp-tomato-500);
81
+ --ui-color-error-600: var(--color-tsp-tomato-600);
82
+ --ui-color-error-700: var(--color-tsp-tomato-700);
83
+ --ui-color-error-800: var(--color-tsp-tomato-800);
84
+ --ui-color-error-900: var(--color-tsp-tomato-900);
85
+ --ui-color-error-950: var(--color-tsp-tomato-950);
86
+ --ui-error: var(--color-tsp-tomato-500);
87
+
88
+ /* text/icon on top of amber fills — amber is light, so dark text in BOTH
89
+ modes (the `on-primary` token). */
90
+ --ui-text-inverted: #212529;
91
+
92
+ --ui-radius: 0.5rem;
93
+ }
94
+
95
+ /* Dark surfaces (default) — tsp anthracite. */
96
+ .dark .tsp-theme,
97
+ .dark.tsp-theme {
98
+ --ui-bg: #212529;
99
+ --ui-bg-muted: #2b3035;
100
+ --ui-bg-elevated: #2b3035;
101
+ --ui-bg-accented: #404041;
102
+ --ui-bg-inverted: #f0f0f0;
103
+
104
+ --ui-border: #404041;
105
+ --ui-border-muted: #343a40;
106
+ --ui-border-accented: #404041;
107
+ --ui-border-inverted: #f0f0f0;
108
+
109
+ --ui-text-dimmed: #6d6e70;
110
+ --ui-text-muted: #adb5bd;
111
+ --ui-text-toned: #ced4da;
112
+ --ui-text: #f0f0f0;
113
+ --ui-text-highlighted: #ffffff;
114
+ }
115
+
116
+ /* Light surfaces — tsp light theme (design-system.md → Light theme). */
117
+ .light .tsp-theme,
118
+ .light.tsp-theme {
119
+ --ui-bg: #f8f9fa;
120
+ --ui-bg-muted: #ffffff;
121
+ --ui-bg-elevated: #ffffff;
122
+ --ui-bg-accented: #dee2e6;
123
+ --ui-bg-inverted: #212529;
124
+
125
+ --ui-border: #dee2e6;
126
+ --ui-border-muted: #e9ecef;
127
+ --ui-border-accented: #ced4da;
128
+ --ui-border-inverted: #212529;
129
+
130
+ --ui-text-dimmed: #9ca3af;
131
+ --ui-text-muted: #6d6e70;
132
+ --ui-text-toned: #343a40;
133
+ --ui-text: #212529;
134
+ --ui-text-highlighted: #16191c;
135
+ }
136
+
137
+ /* The wordmark uses Space Grotesk; everything else stays Nunito. */
138
+ .tsp-theme .tsp-wordmark {
139
+ font-family: var(--font-tsp-logo);
140
+ }
@@ -0,0 +1,9 @@
1
+ <!--
2
+ Centered content column (design-system.md → Layout): max-w-4xl with
3
+ px-6 sm:px-10 / pt-10 pb-16. The background stays full-bleed behind it.
4
+ -->
5
+ <template>
6
+ <div class="max-w-4xl mx-auto px-6 sm:px-10 pt-10 pb-16">
7
+ <slot />
8
+ </div>
9
+ </template>
@@ -0,0 +1,25 @@
1
+ <!--
2
+ Sidebar nav link: muted by default, filled-amber when active. Pass a Tabler
3
+ icon name (`i-tabler-*`) and the route.
4
+ -->
5
+ <template>
6
+ <NuxtLink
7
+ :to="to"
8
+ class="flex items-center gap-2.5 px-3 py-2 rounded-md text-sm font-bold text-muted hover:text-highlighted hover:bg-default"
9
+ active-class="!text-inverted bg-primary hover:!bg-primary"
10
+ >
11
+ <UIcon
12
+ v-if="icon"
13
+ :name="icon"
14
+ class="text-lg"
15
+ />
16
+ <slot />
17
+ </NuxtLink>
18
+ </template>
19
+
20
+ <script setup lang="ts">
21
+ defineProps<{
22
+ to: string
23
+ icon?: string
24
+ }>()
25
+ </script>
@@ -0,0 +1,26 @@
1
+ <!--
2
+ App-shell sidebar frame (the generic structure from the piggybank admin). The
3
+ app supplies content via slots; no app-specific branding/nav lives here.
4
+
5
+ #brand — wordmark / logo (inside an inset bottom-bordered header)
6
+ #nav — TspNavItem links
7
+ #footer — typically <TspSidebarFooter>
8
+ -->
9
+ <template>
10
+ <aside class="flex flex-col w-56 shrink-0 bg-elevated border-r border-default py-5">
11
+ <div
12
+ v-if="$slots.brand"
13
+ class="mx-2 px-2 pb-4 mb-3 border-b border-default"
14
+ >
15
+ <slot name="brand" />
16
+ </div>
17
+
18
+ <nav class="flex flex-col gap-0.5 px-2">
19
+ <slot name="nav" />
20
+ </nav>
21
+
22
+ <div class="mt-auto px-3 pt-3">
23
+ <slot name="footer" />
24
+ </div>
25
+ </aside>
26
+ </template>
@@ -0,0 +1,60 @@
1
+ <!--
2
+ The sidebar bottom block, in canonical order:
3
+ User label → #user → #logout → divider → theme toggle → GitHub + version.
4
+
5
+ App-specific identity (the user link, the logout target) come in as slots; the
6
+ GitHub/version come in as props. The theme toggle is built in.
7
+ -->
8
+ <template>
9
+ <div>
10
+ <p
11
+ v-if="$slots.user"
12
+ class="px-1 mb-1 text-[10px] uppercase tracking-wide text-dimmed"
13
+ >
14
+ User
15
+ </p>
16
+ <slot name="user" />
17
+ <slot name="logout" />
18
+
19
+ <TspThemeToggle class="w-full mt-2 pt-2.5 pb-1.5 border-t border-default" />
20
+
21
+ <div
22
+ v-if="githubLink || version"
23
+ class="flex items-center gap-1.5 px-1 pt-1.5 text-xs text-muted"
24
+ >
25
+ <a
26
+ v-if="githubLink"
27
+ :href="githubLink"
28
+ target="_blank"
29
+ rel="noopener noreferrer"
30
+ class="flex items-center gap-1 hover:text-highlighted"
31
+ >
32
+ <UIcon name="i-tabler-brand-github" />
33
+ GitHub
34
+ </a>
35
+ <a
36
+ v-if="versionLink"
37
+ :href="versionLink"
38
+ target="_blank"
39
+ rel="noopener noreferrer"
40
+ class="ml-auto rounded-full border border-default px-2 py-0.5 hover:text-highlighted hover:border-accented"
41
+ >
42
+ {{ version }}
43
+ </a>
44
+ <span
45
+ v-else-if="version"
46
+ class="ml-auto rounded-full border border-default px-2 py-0.5"
47
+ >
48
+ {{ version }}
49
+ </span>
50
+ </div>
51
+ </div>
52
+ </template>
53
+
54
+ <script setup lang="ts">
55
+ defineProps<{
56
+ githubLink?: string
57
+ version?: string
58
+ versionLink?: string
59
+ }>()
60
+ </script>
@@ -0,0 +1,14 @@
1
+ <!--
2
+ Scoped-mode wrapper: marks a sub-area as themed. Everything inside resolves
3
+ the tsp tokens (amber primary, anthracite/light surfaces); everything outside
4
+ keeps default Nuxt UI. In global mode you don't need this — the colour-mode
5
+ plugin puts `.tsp-theme` on <html> for you.
6
+
7
+ Extra classes pass through to the root, e.g.
8
+ <TspThemeProvider class="flex min-h-svh">.
9
+ -->
10
+ <template>
11
+ <div class="tsp-theme bg-default text-default">
12
+ <slot />
13
+ </div>
14
+ </template>
@@ -0,0 +1,26 @@
1
+ <!--
2
+ Light/dark toggle. Flips the remembered preference (useTspColorMode); the
3
+ colour-mode plugin applies it. Default content is an icon + label; override via
4
+ the default slot (receives `{ isDark }`). The button is unstyled-ish so it fits
5
+ the sidebar footer or a top bar — pass classes to taste.
6
+ -->
7
+ <template>
8
+ <button
9
+ type="button"
10
+ data-testid="tsp-theme-toggle"
11
+ :aria-label="isDark ? 'Switch to light mode' : 'Switch to dark mode'"
12
+ class="flex items-center gap-2 px-1 py-1.5 rounded-md text-xs text-muted hover:text-highlighted hover:bg-default"
13
+ @click="toggle"
14
+ >
15
+ <UIcon
16
+ :name="isDark ? 'i-tabler-sun' : 'i-tabler-moon'"
17
+ class="text-base"
18
+ />
19
+ <slot :is-dark="isDark">{{ isDark ? 'Light mode' : 'Dark mode' }}</slot>
20
+ </button>
21
+ </template>
22
+
23
+ <script setup lang="ts">
24
+ const { pref, toggle } = useTspColorMode()
25
+ const isDark = computed(() => pref.value === 'dark')
26
+ </script>
@@ -0,0 +1,9 @@
1
+ <!--
2
+ Brand wordmark in Space Grotesk + amber. Put the app name (and optional logo)
3
+ in the default slot, e.g. <TspWordmark>trips<span class="text-primary">.</span></TspWordmark>.
4
+ -->
5
+ <template>
6
+ <div class="tsp-wordmark font-bold text-xl tracking-tight text-primary">
7
+ <slot />
8
+ </div>
9
+ </template>
@@ -0,0 +1,42 @@
1
+ export type TspMode = 'light' | 'dark'
2
+
3
+ export interface TspThemeOptions {
4
+ /** 'global' = whole app themed; 'scoped' = only `pathPrefix` routes. */
5
+ apply: 'global' | 'scoped'
6
+ /** Scoped mode: route prefix that counts as "themed". */
7
+ pathPrefix: string
8
+ /** Scoped mode: colour mode forced on non-themed (public) routes. */
9
+ publicMode: TspMode
10
+ /** Default colour mode for themed routes (remembered via cookie). */
11
+ defaultMode: TspMode
12
+ }
13
+
14
+ export const useTspThemeOptions = (): TspThemeOptions => {
15
+ const cfg = useRuntimeConfig().public.tspTheme as Partial<TspThemeOptions> | undefined
16
+ return {
17
+ apply: cfg?.apply ?? 'global',
18
+ pathPrefix: cfg?.pathPrefix ?? '/admin',
19
+ publicMode: cfg?.publicMode ?? 'light',
20
+ defaultMode: cfg?.defaultMode ?? 'dark',
21
+ }
22
+ }
23
+
24
+ // The themed area's own colour-mode preference, remembered in a cookie so it is
25
+ // readable during SSR and survives reloads. Kept separate from @nuxtjs/color-
26
+ // mode's global state so that forcing the public area to `publicMode` (scoped
27
+ // mode) can never erase the user's choice.
28
+ export const useTspColorMode = () => {
29
+ const { defaultMode } = useTspThemeOptions()
30
+
31
+ const pref = useCookie<TspMode>('tsp_color_mode', {
32
+ default: () => defaultMode,
33
+ sameSite: 'lax',
34
+ maxAge: 60 * 60 * 24 * 365,
35
+ })
36
+
37
+ const toggle = () => {
38
+ pref.value = pref.value === 'dark' ? 'light' : 'dark'
39
+ }
40
+
41
+ return { pref, toggle }
42
+ }
package/nuxt.config.ts ADDED
@@ -0,0 +1,52 @@
1
+ import { dirname, join } from 'node:path'
2
+ import { fileURLToPath } from 'node:url'
3
+
4
+ // Absolute path to this layer's own dir, so `css` resolves correctly when the
5
+ // layer is `extends`-ed from another project (layer-relative `~`/`@` aliases are
6
+ // unreliable across the extends boundary).
7
+ const layerDir = dirname(fileURLToPath(import.meta.url))
8
+
9
+ export default defineNuxtConfig({
10
+ modules: ['@nuxt/ui'],
11
+
12
+ // The token stylesheet (imports tailwindcss + @nuxt/ui itself, so a consumer
13
+ // does not add its own framework import — see README "CSS ownership").
14
+ css: [join(layerDir, './assets/css/theme.css')],
15
+
16
+ // Platform fonts. @nuxt/fonts ships with @nuxt/ui, so the `fonts` key works
17
+ // without listing the module explicitly.
18
+ fonts: {
19
+ families: [
20
+ { name: 'Nunito', provider: 'google', weights: [400, 600, 700, 800, 900] },
21
+ { name: 'Space Grotesk', provider: 'google', weights: [500, 700] },
22
+ ],
23
+ experimental: {
24
+ processCSSVariables: true,
25
+ },
26
+ },
27
+
28
+ // Platform default is dark. In scoped mode the colour-mode plugin overrides
29
+ // this per-route (themed area → cookie pref, public area → publicMode).
30
+ colorMode: {
31
+ preference: 'dark',
32
+ fallback: 'dark',
33
+ },
34
+
35
+ runtimeConfig: {
36
+ public: {
37
+ // Consumers override these in their own nuxt.config to pick a mode.
38
+ tspTheme: {
39
+ // 'global' → the whole app is themed (the scope class is put on <html>).
40
+ // 'scoped' → only `pathPrefix` routes are themed; wrap them in
41
+ // <TspThemeProvider>. The rest stays default Nuxt UI.
42
+ apply: 'global',
43
+ // Scoped mode: route prefix that counts as "themed".
44
+ pathPrefix: '/admin',
45
+ // Scoped mode: colour mode forced on non-themed (public) routes.
46
+ publicMode: 'light',
47
+ // Default colour mode for themed routes (remembered via cookie).
48
+ defaultMode: 'dark',
49
+ },
50
+ },
51
+ },
52
+ })
package/package.json ADDED
@@ -0,0 +1,71 @@
1
+ {
2
+ "name": "@thespielplatz/tsp-tools-theme",
3
+ "version": "0.1.0",
4
+ "description": "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.",
5
+ "author": "inf0matics",
6
+ "license": "MIT",
7
+ "homepage": "https://github.com/inf0matics/tsp-tools-theme#readme",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/inf0matics/tsp-tools-theme.git"
11
+ },
12
+ "bugs": "https://github.com/inf0matics/tsp-tools-theme/issues",
13
+ "keywords": [
14
+ "nuxt",
15
+ "nuxt-layer",
16
+ "nuxt-ui",
17
+ "tailwindcss",
18
+ "design-system",
19
+ "theme",
20
+ "dark-mode",
21
+ "tsp"
22
+ ],
23
+ "type": "module",
24
+ "private": false,
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "exports": {
29
+ ".": "./nuxt.config.ts"
30
+ },
31
+ "main": "./nuxt.config.ts",
32
+ "files": [
33
+ "nuxt.config.ts",
34
+ "app.config.ts",
35
+ "assets/css",
36
+ "components",
37
+ "composables",
38
+ "plugins",
39
+ "README.md",
40
+ "LICENSE"
41
+ ],
42
+ "scripts": {
43
+ "lint": "eslint .",
44
+ "dev": "nuxi dev playground",
45
+ "dev:scoped": "nuxi dev scoped",
46
+ "build:playground": "nuxi build playground",
47
+ "build:scoped": "nuxi build scoped",
48
+ "release": "npm run lint && changelogen --release && git push --follow-tags && npm publish",
49
+ "bump-patch": "npx changelogen@latest --release"
50
+ },
51
+ "dependencies": {
52
+ "@nuxt/ui": "^4.3.0"
53
+ },
54
+ "peerDependencies": {
55
+ "@iconify-json/tabler": "^1.2.0",
56
+ "nuxt": "^4.0.0"
57
+ },
58
+ "peerDependenciesMeta": {
59
+ "@iconify-json/tabler": {
60
+ "optional": true
61
+ }
62
+ },
63
+ "devDependencies": {
64
+ "@iconify-json/tabler": "^1.2.35",
65
+ "@nuxt/eslint-config": "^1.12.1",
66
+ "changelogen": "^0.6.2",
67
+ "eslint": "^9.39.2",
68
+ "nuxt": "^4.2.2",
69
+ "vue-tsc": "3.2.2"
70
+ }
71
+ }
@@ -0,0 +1,51 @@
1
+ // Per-area colour mode for the tsp theme. One plugin, two modes (see the layer
2
+ // `runtimeConfig.public.tspTheme`):
3
+ //
4
+ // GLOBAL — the whole app is themed. The scope class `.tsp-theme` is placed on
5
+ // <html> (SSR + client) and colour mode is the remembered cookie
6
+ // preference (default dark), toggling the entire app.
7
+ //
8
+ // SCOPED — only routes under `pathPrefix` are themed (wrap them in
9
+ // <TspThemeProvider>). Those routes use the cookie preference;
10
+ // every other route is forced to `publicMode` (e.g. light), so the
11
+ // public area and the themed area never fight over colour mode.
12
+ //
13
+ // The mode is always derived from the route, never from colour-mode's own
14
+ // persisted value. We set preference/value during setup (SSR + first render),
15
+ // on every route change, and when the preference changes (the toggle), and we
16
+ // re-assert the <html> class on `app:mounted` — colour-mode reads localStorage
17
+ // on mount and would otherwise win.
18
+ export default defineNuxtPlugin((nuxtApp) => {
19
+ const colorMode = useColorMode()
20
+ const route = useRoute()
21
+ const options = useTspThemeOptions()
22
+ const { pref } = useTspColorMode()
23
+
24
+ // Global: keep the scope class on <html> across SSR + client navigations.
25
+ if (options.apply === 'global') {
26
+ useHead({ htmlAttrs: { class: 'tsp-theme' } })
27
+ }
28
+
29
+ const isThemed = (path: string) =>
30
+ options.apply === 'global' || path.startsWith(options.pathPrefix)
31
+
32
+ const modeForPath = (path: string): TspMode =>
33
+ isThemed(path) ? pref.value : options.publicMode
34
+
35
+ const apply = (path: string) => {
36
+ const mode = modeForPath(path)
37
+ colorMode.preference = mode
38
+ colorMode.value = mode
39
+ if (import.meta.client) {
40
+ const el = document.documentElement
41
+ el.classList.remove('dark', 'light')
42
+ el.classList.add(mode)
43
+ el.style.colorScheme = mode
44
+ }
45
+ }
46
+
47
+ apply(route.path)
48
+ watch(() => route.path, apply)
49
+ watch(pref, () => apply(route.path))
50
+ nuxtApp.hook('app:mounted', () => nextTick(() => apply(route.path)))
51
+ })