@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 +21 -0
- package/README.md +193 -0
- package/app.config.ts +20 -0
- package/assets/css/theme.css +140 -0
- package/components/TspContainer.vue +9 -0
- package/components/TspNavItem.vue +25 -0
- package/components/TspSidebar.vue +26 -0
- package/components/TspSidebarFooter.vue +60 -0
- package/components/TspThemeProvider.vue +14 -0
- package/components/TspThemeToggle.vue +26 -0
- package/components/TspWordmark.vue +9 -0
- package/composables/useTspColorMode.ts +42 -0
- package/nuxt.config.ts +52 -0
- package/package.json +71 -0
- package/plugins/01.tspColorMode.ts +51 -0
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,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
|
+
})
|