@thespielplatz/tsp-tools-theme 0.2.1 → 0.3.1

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.
Files changed (39) hide show
  1. package/CHANGELOG.md +144 -0
  2. package/README.md +148 -44
  3. package/assets/css/theme.css +86 -6
  4. package/components/TspBanner.vue +97 -0
  5. package/components/TspBrandTile.vue +46 -0
  6. package/components/TspCodeBlock.vue +68 -0
  7. package/components/TspContainer.vue +10 -2
  8. package/components/TspCopyButton.vue +40 -0
  9. package/components/TspCopyField.vue +102 -0
  10. package/components/TspFooterRow.vue +56 -0
  11. package/components/TspLanguageToggle.vue +75 -0
  12. package/components/TspNavBadge.vue +36 -0
  13. package/components/TspNavGroup.vue +58 -0
  14. package/components/TspNavItem.vue +68 -12
  15. package/components/TspNavLabel.vue +56 -0
  16. package/components/TspNavSection.vue +33 -0
  17. package/components/TspNavSubItem.vue +35 -0
  18. package/components/TspPageTabs.vue +46 -0
  19. package/components/TspReleaseNotes.vue +143 -0
  20. package/components/TspSectionCard.vue +52 -0
  21. package/components/TspSidebar.vue +96 -3
  22. package/components/TspSidebarFooter.vue +332 -37
  23. package/components/TspSiteFooter.vue +47 -16
  24. package/components/TspSiteHeader.vue +18 -2
  25. package/components/TspThemeToggle.vue +24 -5
  26. package/components/TspToolOf.vue +42 -0
  27. package/components/TspTopBar.vue +69 -0
  28. package/components/TspUserCard.vue +127 -0
  29. package/components/TspVersionBadge.vue +49 -0
  30. package/components/TspWordmark.vue +36 -4
  31. package/composables/useTspCopy.ts +72 -0
  32. package/composables/useTspNavDrawer.ts +14 -0
  33. package/composables/useTspReveal.ts +18 -0
  34. package/docs/api.md +394 -0
  35. package/docs/design-system.md +297 -0
  36. package/nuxt.config.ts +11 -0
  37. package/package.json +24 -13
  38. package/page-meta.d.ts +11 -0
  39. package/plugins/01.tspColorMode.ts +12 -0
@@ -0,0 +1,297 @@
1
+ # tsp.tools Design System
2
+
3
+ **This file is the source of truth for how tsp.tools services look.**
4
+
5
+ It lives in the package that ships the design, not beside it. Values here are read from
6
+ `assets/css/theme.css` and the components in `components/`; if the two ever disagree, the code is
7
+ right and this file is a bug. The planning project's `architecture/design-system.md` is a pointer
8
+ to this document, not a second copy — one description of the design, in the repo that implements
9
+ it (ADR 008, ADR 017).
10
+
11
+ The **playground is the reference**, never a mockup. `npm run dev` and look at it.
12
+
13
+ Last reconciled with the code: 2026-09-22, theme `0.3.0`.
14
+
15
+ **The complete prop, slot and emit surface is [`api.md`](api.md)** — generated from the
16
+ components, checked in the gate, so it cannot drift. This file is the *why*; that one is the
17
+ signature.
18
+
19
+ ---
20
+
21
+ ## Stack and installation
22
+
23
+ Nuxt 4 · Nuxt UI 4 · Tailwind 4. The package is a Nuxt **layer**: it ships the tokens, the fonts,
24
+ the colour-mode plugin and the shell components pre-configured. Extend it; do not restate tokens.
25
+
26
+ ```bash
27
+ npm i -D @thespielplatz/tsp-tools-theme
28
+ ```
29
+
30
+ ```ts
31
+ export default defineNuxtConfig({
32
+ extends: ['@thespielplatz/tsp-tools-theme'],
33
+ runtimeConfig: { public: { tspTheme: { apply: 'global' } } },
34
+ })
35
+ ```
36
+
37
+ ### Two consumption modes
38
+
39
+ | Mode | What is themed | Used by |
40
+ |---|---|---|
41
+ | `global` | the whole app; the plugin puts `.tsp-theme` on `<html>` | most services |
42
+ | `scoped` | only routes under `pathPrefix`, wrapped in `<TspThemeProvider>`; the rest stays default Nuxt UI | piggybank (`/admin`) |
43
+
44
+ **Anything that touches the scope class must be checked in both.** A change verified only
45
+ globally is half verified — `playground/` is the global demo, `scoped/` the scoped one, and the
46
+ E2E runs against both.
47
+
48
+ `primary` is deliberately **not** set in `app.config`. `app.config` is global, so it would make
49
+ every Nuxt UI component amber even in a scoped consumer's unthemed area. Brand theming comes from
50
+ the `.tsp-theme` CSS scope, which resolves per element.
51
+
52
+ ---
53
+
54
+ ## Tokens
55
+
56
+ ### Brand
57
+
58
+ `--color-tsp-amber-50 … 950`. The two that matter:
59
+
60
+ | Token | Value | Use |
61
+ |---|---|---|
62
+ | `--color-tsp-amber-400` | `#fbad18` | the brand. Fills, and text on dark. |
63
+ | `--color-tsp-amber-600` | `#f47920` | the same brand as **text on light**, where `#fbad18` fails contrast. |
64
+
65
+ `--ui-primary` is amber-400 in both modes, because it paints **fills**, which are dark-text-on-
66
+ amber either way. For amber **text** use `--tsp-text-brand` / `.tsp-text-brand`, which steps down
67
+ to amber-600 on light. Getting this wrong is the single most repeated mistake in this codebase.
68
+
69
+ ### Surfaces — three rungs, and they are not interchangeable
70
+
71
+ | Token | Dark | Light | Means |
72
+ |---|---|---|---|
73
+ | `--ui-bg` | `#212529` | `#f1f3f5` | the page |
74
+ | `--ui-bg-muted` | `#2b3035` | `#f8f9fa` | **the shell** — sidebar, top bar |
75
+ | `--ui-bg-elevated` | `#31373e` | `#ffffff` | **raised content** — cards, the user card |
76
+ | `--ui-bg-accented` | `#404041` | `#dee2e6` | pressed/selected chrome |
77
+
78
+ `muted` and `elevated` were once the same value, which left a card no surface of its own: it
79
+ matched either the page or the sidebar, and it matched the sidebar in stash, gage and trips. They
80
+ are separate rungs now. **The shell uses `muted`; content uses `elevated`.**
81
+
82
+ ### Text
83
+
84
+ | Token | Dark | Light |
85
+ |---|---|---|
86
+ | `--ui-text-highlighted` | `#ffffff` | `#16191c` |
87
+ | `--ui-text` | `#f0f0f0` | `#212529` |
88
+ | `--ui-text-toned` | `#ced4da` | `#343a40` |
89
+ | `--ui-text-muted` | `#adb5bd` | `#6d6e70` |
90
+ | `--ui-text-dimmed` | `#6d6e70` | `#9ca3af` |
91
+
92
+ ### Borders
93
+
94
+ `--ui-border` `#404041` / `#dee2e6` · `--ui-border-muted` `#343a40` / `#e9ecef` ·
95
+ `--ui-border-accented` `#404041` / `#ced4da`.
96
+
97
+ ### Radius
98
+
99
+ `--ui-radius` is **`0.25rem`**. Nuxt UI derives its whole scale from it:
100
+
101
+ | Class | Multiple | Px | Used for |
102
+ |---|---|---|---|
103
+ | `rounded-sm` | 1× | 4 | inner chips |
104
+ | `rounded-md` | 1.5× | 6 | rows, buttons, inputs, toggles |
105
+ | `rounded-lg` | 2× | 8 | Nuxt UI's own default |
106
+ | `rounded-xl` | 3× | 12 | **cards** |
107
+
108
+ **The 12px card is a rule each app applies, not a layer default.** `UCard` hardcodes
109
+ `rounded-lg`, so a plain card is 8px — write `rounded-xl` on cards you want at 12px. The layer
110
+ does not override `ui.card.slots.root`, for the same reason it does not set `primary`: it is
111
+ global and would reach a scoped consumer's unthemed area. There is no `TspCard`.
112
+
113
+ > Nuxt UI runs **tailwind-merge** over the `class` prop, so `rounded-xl` *replaces*
114
+ > `rounded-lg` rather than sitting beside it. Do not select on the default class in tests.
115
+
116
+ ### Fonts
117
+
118
+ Nunito everywhere; Space Grotesk for the wordmark only (`.tsp-wordmark` / `<TspWordmark>`).
119
+ Both from Google via `@nuxt/fonts`, which ships with Nuxt UI.
120
+
121
+ ### Icons
122
+
123
+ Tabler, `i-tabler-*`. Install `@iconify-json/tabler` for offline resolution. **One icon family
124
+ across the platform** — do not mix in a second set.
125
+
126
+ ### Utilities the layer adds
127
+
128
+ | Class | What |
129
+ |---|---|
130
+ | `.tsp-theme` | the scope class; everything above resolves inside it |
131
+ | `.tsp-text-brand` | mode-correct amber **text** |
132
+ | `.tsp-border-brand` | mode-correct amber **left border** |
133
+ | `.tsp-scroll-quiet` | scrolls, no visible scrollbar track |
134
+ | `.tsp-wordmark` | Space Grotesk |
135
+
136
+ `.tsp-text-brand` is written **unlayered** so it beats Tailwind's `text-*` utilities without
137
+ `!important`.
138
+
139
+ ---
140
+
141
+ ## Layout
142
+
143
+ `<TspContainer>` centres content. `width="app"` (default) is `max-w-6xl` (1152px), `px-6 sm:px-10`;
144
+ `width="site"` is `max-w-[1240px]`, `px-6 md:px-10`. Both add `pt-10 pb-16` unless
145
+ `:padded="false"`. The background stays full-bleed; only the column is constrained.
146
+
147
+ **Components never cap the page width.** A layer component that puts a `max-w-*` on its own root
148
+ overrides the container from the inside, and the app cannot undo it without fighting
149
+ `tailwind-merge`. Width is the container's job; a service that wants a block narrower wraps it.
150
+
151
+ The app measure is **not** a reading measure — it is sized for the tables, card grids and settings
152
+ panels an app page is actually made of. Cap prose where the prose is (`max-w-2xl` on the
153
+ paragraph), not by narrowing the whole page around it.
154
+
155
+ **The scrollbar gutter is always reserved** (`html.tsp-theme, html:has(.tsp-theme)`). The column
156
+ is centred, so without it the whole page slides sideways by half a scrollbar the moment a route
157
+ is tall enough to scroll. Browsers with overlay scrollbars are unaffected, and a consumer's
158
+ unthemed public pages are deliberately not touched.
159
+
160
+ **Responsive.** `sm` (640px) is the shell's breakpoint. At and above it the sidebar is a column,
161
+ sticky at viewport height. Below it the sidebar becomes an off-canvas drawer and `<TspTopBar>`
162
+ appears.
163
+
164
+ ---
165
+
166
+ ## Components
167
+
168
+ All auto-imported. App-specific content arrives as **props**, not slots, for the rows every app
169
+ has — a slot invites each app to restyle its copy, which is the drift the layer exists to stop.
170
+
171
+ ### The logged-in shell
172
+
173
+ | Component | Purpose |
174
+ |---|---|
175
+ | `TspSidebar` | The frame. Slots `#brand` `#nav` `#footer`. Sticky on desktop, drawer below `sm`. |
176
+ | `TspTopBar` | Mobile only: burger (left) · brand · page title. |
177
+ | `TspSidebarFooter` | User → Settings → Logout → divider → theme + language → meta icons + version → attribution. |
178
+ | `TspFooterRow` | One footer row; `to` / `href` / neither (button). |
179
+ | `TspNavItem` | Nav row. White at rest, amber when active. `to` or `href`, optional `badge`. |
180
+ | `TspNavGroup` | A nav row with always-visible children on a hairline spine. |
181
+ | `TspNavSubItem` | A child row — muted, own icon. |
182
+ | `TspNavSection` | A rule, optionally labelled. |
183
+ | `TspNavBadge` | Outline pill; `tone` `primary` \| `neutral`. |
184
+ | `TspNavLabel` | Truncates a label, reveals it in full on hover. Used by the rows above. |
185
+ | `TspVersionBadge` | Version pill with a tag icon. A link only with `href`; `unread` marks unseen notes. |
186
+ | `TspLanguageToggle` | DE/EN segmented control. |
187
+ | `TspThemeToggle` | Light/dark. `icon-only` for tight places. |
188
+ | `TspToolOf` | “A tool of tsp.tools”. Shared by both footers. |
189
+
190
+ ### The public shell
191
+
192
+ | Component | Purpose |
193
+ |---|---|
194
+ | `TspSiteHeader` | Slots `#brand` `#nav` `#actions`; `sticky`, and `bare` for a landing page (no bar). |
195
+ | `TspSiteFooter` | Compact centred link row; `tool-of` adds the attribution. |
196
+
197
+ ### Content
198
+
199
+ | Component | Purpose |
200
+ |---|---|
201
+ | `TspContainer` | The measure. |
202
+ | `TspSectionCard` | Icon + title over content, on `bg-elevated`. |
203
+ | `TspPageTabs` | Area tabs as **routes**, so the back button works. |
204
+ | `TspCopyField` | Read-only value + copy; `secret` masks behind a reveal. |
205
+ | `TspCopyButton` | Copy with a tick confirmation. |
206
+ | `TspCodeBlock` | Config to copy. Separate shown and copied values. |
207
+ | `TspUserCard` | Account card: name, masked id, manage link. |
208
+ | `TspBanner` | Dismissible strip. Emits `dismiss`; remembering it is the app's job. |
209
+ | `TspReleaseNotes` | Release notes. Props mirror the `tsp-release` skill's `RELEASES` shape. |
210
+
211
+ ### Brand
212
+
213
+ | Component | Purpose |
214
+ |---|---|
215
+ | `TspWordmark` | `<TspWordmark name="trips" />` → white `trips` + amber dot. No slot. |
216
+ | `TspBrandTile` | The frame around a mark: `sidebar` (24px) or `hero` (64/96px). Paints no fill. |
217
+ | `TspThemeProvider` | Scoped mode: marks a sub-tree as themed. |
218
+
219
+ ### Composables
220
+
221
+ All four are auto-imported; signatures and return shapes in [`api.md`](api.md).
222
+
223
+ | Composable | For |
224
+ |---|---|
225
+ | `useTspColorMode()` | the themed area's own light/dark preference, in a cookie so SSR can read it |
226
+ | `useTspNavDrawer()` | the mobile drawer's shared open state (`useState`, not a module ref, which would leak across requests on the server) |
227
+ | `useTspCopy()` | copy + a moment of confirmation, with the fallback for a non-secure context (plain HTTP on a LAN address — how a service gets tested on a phone) |
228
+ | `useTspReveal()` | show/hide a secret and the fixed-length placeholder to render instead of it |
229
+
230
+ ---
231
+
232
+ ## Rules
233
+
234
+ **The wordmark is the app name, lowercase, terminated by an amber dot.** `trips.` `gage.`
235
+ `stash.` The component owns the dot; there is no slot. `tsp.tools` is the *domain of the tool
236
+ set*, not a tool's name, so it is never a wordmark subject.
237
+
238
+ **Amber fills mean exactly one thing: primary buttons.** Nothing in the sidebar carries an amber
239
+ fill at rest — not the active nav item, not a role badge, not the language toggle. The E2E sweeps
240
+ for it on every run. The brand tile is the one deliberate exception, and it is SVG artwork rather
241
+ than a CSS background.
242
+
243
+ **The nav reads in three tones:** top-level rows white, children muted, the current route amber —
244
+ at either level.
245
+
246
+ **No cards inside cards.** A bordered, filled surface does not contain another. Use spacing, a
247
+ rule, or a heading.
248
+
249
+ **Every visible string is a prop with an English default.** No component hardcodes copy. Where a
250
+ sentence contains a link, only the leading words are translatable and the component owns the
251
+ link — an app must never hand-roll markup to translate a sentence.
252
+
253
+ **Masking is real, not cosmetic.** A hidden secret is *not rendered*. Covering it with CSS leaves
254
+ it in the page source, copyable and readable by anything walking the DOM.
255
+
256
+ **Open source is derived from `github-link`**, not a separate flag: the link is the consequence
257
+ of the fact, and a second source of truth could disagree with the first.
258
+
259
+ **Area tabs** are for one area's own views; a **nav group** is for a service with nested areas.
260
+ Both use `.tsp-text-brand` when active.
261
+
262
+ ---
263
+
264
+ ## Gates
265
+
266
+ | Command | Covers |
267
+ |---|---|
268
+ | `npm run lint` | eslint |
269
+ | `npm run docs:api:check` | `docs/api.md` still matches the components |
270
+ | `npm run typecheck` | both demo apps (`nuxi typecheck`, i.e. vue-tsc — `tsc --noEmit` cannot resolve SFCs) |
271
+ | `npm run test:icons` | boots both built apps and fails on one `failed to load icon` |
272
+ | `npm run test:e2e` | Playwright, both consumption modes |
273
+ | `npm test` | icons + e2e |
274
+ | `npm run gate` | all of the above, in the order CI runs them |
275
+
276
+ `.github/workflows/ci.yml` runs the gate on every push and pull request, building both demo apps
277
+ once instead of the four times the scripts would do back to back. A version tag additionally runs
278
+ `release.yml`, which re-runs the gate on a clean checkout and publishes to npm with provenance.
279
+
280
+ **A green pipeline does not prove icons render.** Icons resolve at runtime, per request, so lint,
281
+ typecheck and build all pass with every glyph missing. `test:icons` is part of the gate, not
282
+ optional (ADR 008).
283
+
284
+ The same trap has bitten twice more: `resolveComponent()` in a template expression renders a
285
+ literal `<NuxtLink>` element with the build still green, and a Tailwind class collapse only shows
286
+ in the browser. **Measure the rendered page**, which is what the E2E does.
287
+
288
+ ---
289
+
290
+ ## Decisions
291
+
292
+ In the planning project's `decisions/`:
293
+
294
+ - **ADR 008** — why this is a layer; the scoped/global mechanism; the icon trap
295
+ - **ADR 017** — one shell for every service; the layer is the source
296
+ - **ADR 018** (+ its 2026-09-21 and 2026-09-22 amendments) — the quiet shell, what `0.3.0`
297
+ revised, and the content measure
package/nuxt.config.ts CHANGED
@@ -9,6 +9,17 @@ const layerDir = dirname(fileURLToPath(import.meta.url))
9
9
  export default defineNuxtConfig({
10
10
  modules: ['@nuxt/ui'],
11
11
 
12
+ // Register the layer's own type augmentation with the CONSUMER's TS program.
13
+ // Shipping the .d.ts in `files` is not enough: a consumer resolves this layer
14
+ // inside node_modules, and none of Nuxt's generated tsconfig include globs
15
+ // (`../*.d.ts`, `../../*.d.ts`, `../layers/*/*.d.ts`) reach there. It only
16
+ // typechecked here because the demo apps extend '..' by relative path.
17
+ hooks: {
18
+ 'prepare:types': ({ references }) => {
19
+ references.push({ path: join(layerDir, 'page-meta.d.ts') })
20
+ },
21
+ },
22
+
12
23
  // The token stylesheet (imports tailwindcss + @nuxt/ui itself, so a consumer
13
24
  // does not add its own framework import — see README "CSS ownership").
14
25
  css: [join(layerDir, './assets/css/theme.css')],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thespielplatz/tsp-tools-theme",
3
- "version": "0.2.1",
3
+ "version": "0.3.1",
4
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
5
  "author": "inf0matics",
6
6
  "license": "MIT",
@@ -37,23 +37,32 @@
37
37
  "assets/css",
38
38
  "components",
39
39
  "composables",
40
+ "page-meta.d.ts",
40
41
  "plugins",
42
+ "docs",
41
43
  "README.md",
44
+ "CHANGELOG.md",
42
45
  "LICENSE"
43
46
  ],
44
47
  "scripts": {
45
48
  "lint": "eslint .",
46
- "dev": "nuxi dev playground",
47
- "dev:scoped": "nuxi dev scoped",
49
+ "docs:api": "node scripts/gen-api.mjs",
50
+ "docs:api:check": "node scripts/gen-api.mjs --check",
51
+ "dev": "node scripts/dev.mjs playground",
52
+ "dev:scoped": "node scripts/dev.mjs scoped",
48
53
  "build:playground": "nuxi build playground",
49
54
  "build:scoped": "nuxi build scoped",
50
55
  "typecheck": "nuxi typecheck playground && nuxi typecheck scoped",
51
- "test:icons": "npm run build:playground && node scripts/check-icons.mjs",
52
- "release": "npm run lint && npm run typecheck && npm run test:icons && npm publish"
56
+ "test:icons": "npm run build:playground && npm run build:scoped && node scripts/check-icons.mjs",
57
+ "gate": "npm run lint && npm run docs:api:check && npm run typecheck && npm run test:icons && npm run test:e2e",
58
+ "bump-version": "npm run gate && node scripts/bump-version.mjs && git push --follow-tags",
59
+ "release": "npm run gate && npm publish",
60
+ "test:e2e": "playwright test",
61
+ "test": "npm run test:icons && npm run test:e2e"
53
62
  },
54
63
  "dependencies": {
55
- "@nuxt/icon": "~2.3.1",
56
- "@nuxt/ui": "^4.10.0"
64
+ "@nuxt/icon": "~2.5.1",
65
+ "@nuxt/ui": "^4.11.1"
57
66
  },
58
67
  "peerDependencies": {
59
68
  "@iconify-json/tabler": "^1.2.0",
@@ -65,11 +74,13 @@
65
74
  }
66
75
  },
67
76
  "devDependencies": {
68
- "@iconify-json/tabler": "^1.2.37",
69
- "@nuxt/eslint-config": "^1.16.0",
70
- "@types/node": "^26.1.2",
71
- "eslint": "^10.8.0",
72
- "nuxt": "^4.5.1",
73
- "vue-tsc": "3.3.8"
77
+ "@iconify-json/tabler": "^1.2.38",
78
+ "@nuxt/eslint-config": "^1.17.0",
79
+ "@playwright/test": "^1.63.0",
80
+ "@types/node": "^26.6.2",
81
+ "changelogen": "^0.6.2",
82
+ "eslint": "^10.11.0",
83
+ "nuxt": "^4.5.2",
84
+ "vue-tsc": "3.3.11"
74
85
  }
75
86
  }
package/page-meta.d.ts ADDED
@@ -0,0 +1,11 @@
1
+ // `definePageMeta({ title })` is how a page tells <TspTopBar> what to show on
2
+ // mobile, where the sidebar is hidden and nothing else says where you are.
3
+ // Declared here so it typechecks in consumers, not only in this repo.
4
+ declare module '#app' {
5
+ interface PageMeta {
6
+ /** Shown by TspTopBar when no `title` prop is passed. */
7
+ title?: string
8
+ }
9
+ }
10
+
11
+ export {}
@@ -26,12 +26,24 @@ export default defineNuxtPlugin((nuxtApp) => {
26
26
  useHead({ htmlAttrs: { class: 'tsp-theme' } })
27
27
  }
28
28
 
29
+
29
30
  const isThemed = (path: string) =>
30
31
  options.apply === 'global' || path.startsWith(options.pathPrefix)
31
32
 
32
33
  const modeForPath = (path: string): TspMode =>
33
34
  isThemed(path) ? pref.value : options.publicMode
34
35
 
36
+ // ...and emit the MODE class server-side too. Without this the light/dark
37
+ // class was only ever written under `import.meta.client`, so the first paint
38
+ // was decided by colour-mode's own inline script reading its `nuxt-color-mode`
39
+ // localStorage key (default "dark") — not the `tsp_color_mode` cookie this
40
+ // layer introduced. Measured: `<html class="tsp-theme">` came back with no
41
+ // mode class for either cookie value, in both apps. The result was a
42
+ // wrong-mode flash on every cold load: a light user saw dark, and a scoped
43
+ // consumer's deliberately-light public area flashed dark on a first visit.
44
+ const ssrMode = computed(() => modeForPath(route.path))
45
+ useHead({ htmlAttrs: { class: () => ssrMode.value } })
46
+
35
47
  const apply = (path: string) => {
36
48
  const mode = modeForPath(path)
37
49
  colorMode.preference = mode