@thespielplatz/tsp-tools-theme 0.2.0 → 0.3.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.
Files changed (39) hide show
  1. package/CHANGELOG.md +114 -0
  2. package/README.md +147 -32
  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 -11
  38. package/page-meta.d.ts +11 -0
  39. package/plugins/01.tspColorMode.ts +12 -0
@@ -0,0 +1,35 @@
1
+ <!--
2
+ A child row inside a TspNavGroup. Carries its own icon, in its own indented
3
+ column — the level is already carried by the indent, the spine and the muting,
4
+ so the icon does not have to do that work too.
5
+
6
+ Muted at rest against the parent's white (spec 04, reading 2), amber when it
7
+ is the current route — amber means "you are here" at every level. The icon
8
+ inherits the row colour rather than setting its own, so it follows through
9
+ rest, hover and active without three rules. Children are the narrowest rows in
10
+ the sidebar, so they get the same hover reveal as their parents.
11
+ -->
12
+ <template>
13
+ <NuxtLink
14
+ :to="to"
15
+ data-testid="tsp-nav-sub-item"
16
+ class="group relative flex shrink-0 items-center gap-2.5 h-8 px-3 rounded-md text-sm font-normal text-muted hover:text-highlighted hover:bg-default"
17
+ active-class="tsp-text-brand"
18
+ >
19
+ <UIcon
20
+ v-if="icon"
21
+ :name="icon"
22
+ class="text-lg"
23
+ />
24
+ <TspNavLabel><slot /></TspNavLabel>
25
+ </NuxtLink>
26
+ </template>
27
+
28
+ <script setup lang="ts">
29
+ defineProps<{
30
+ /** Internal route. A child row is always internal. */
31
+ to: string
32
+ /** Tabler icon name, e.g. `i-tabler-list-details`. */
33
+ icon?: string
34
+ }>()
35
+ </script>
@@ -0,0 +1,46 @@
1
+ <!--
2
+ Tabs across the top of an area, above the page title — the shape trips uses
3
+ for Settings / MCP Server.
4
+
5
+ <TspPageTabs :items="[
6
+ { label: 'Settings', to: '/settings', icon: 'i-tabler-adjustments' },
7
+ { label: 'MCP Server', to: '/settings/mcp', icon: 'i-tabler-plug' },
8
+ ]" />
9
+
10
+ ROUTES, not local state: these are pages, so each tab is a link and the
11
+ browser's back button works. For switching views WITHIN one page, use plain
12
+ buttons — see /content.
13
+
14
+ Active is amber TEXT plus an underline, using `.tsp-text-brand` rather than
15
+ `text-primary`, so it steps down to amber-600 on light where #fbad18 fails as
16
+ text. Same rule as the active nav item (ADR 018).
17
+ -->
18
+ <template>
19
+ <nav
20
+ data-testid="tsp-page-tabs"
21
+ class="flex gap-6 border-b border-default mb-8 overflow-x-auto tsp-scroll-quiet"
22
+ >
23
+ <NuxtLink
24
+ v-for="item in items"
25
+ :key="item.to"
26
+ :to="item.to"
27
+ data-testid="tsp-page-tab"
28
+ class="-mb-px flex shrink-0 items-center gap-1.5 border-b-2 border-transparent pb-2.5 text-sm font-bold text-muted transition-colors hover:text-highlighted"
29
+ active-class="!border-current tsp-text-brand"
30
+ >
31
+ <UIcon
32
+ v-if="item.icon"
33
+ :name="item.icon"
34
+ class="text-base"
35
+ />
36
+ {{ item.label }}
37
+ </NuxtLink>
38
+ </nav>
39
+ </template>
40
+
41
+ <script setup lang="ts">
42
+ defineProps<{
43
+ /** One entry per view: `{ label, to, icon? }`. `to` is a ROUTE, so the back button works. */
44
+ items: { label: string, to: string, icon?: string }[]
45
+ }>()
46
+ </script>
@@ -0,0 +1,143 @@
1
+ <!--
2
+ Release notes for one or more versions — the overview the tsp-release skill
3
+ writes, rendered.
4
+
5
+ The shape mirrors that skill exactly, so an app passes its RELEASES array
6
+ straight in rather than mapping it:
7
+
8
+ { version, date, showBanner?, showPopup?, important?: Item[], other?: Item[] }
9
+ Item = string | { text, compare?: { before, after }, beforeImage?, afterImage? }
10
+
11
+ `important` and `other` are separated because they are read differently: the
12
+ first is what made the release worth announcing, the second is what a curious
13
+ reader wants after that. Flattening them into one list loses the editorial
14
+ judgement the skill asked a human to make.
15
+
16
+ This renders the SAME content in the page and in the modal. Two renderings
17
+ would drift, and the drift would be invisible until someone compared them.
18
+ -->
19
+ <template>
20
+ <div class="flex flex-col gap-8">
21
+ <article
22
+ v-for="entry in releases"
23
+ :key="entry.version"
24
+ data-testid="tsp-release-entry"
25
+ >
26
+ <header class="flex items-baseline gap-3">
27
+ <h3 class="text-base font-bold text-highlighted">
28
+ {{ entry.version }}
29
+ </h3>
30
+ <time
31
+ v-if="entry.date"
32
+ :datetime="entry.date"
33
+ class="text-xs text-dimmed"
34
+ >{{ entry.date }}</time>
35
+ </header>
36
+
37
+ <template
38
+ v-for="group in groups"
39
+ :key="group.key"
40
+ >
41
+ <div v-if="entry[group.key]?.length">
42
+ <p class="mt-4 mb-2 text-xs font-bold uppercase tracking-wide text-muted">
43
+ {{ group.key === 'important' ? importantLabel : otherLabel }}
44
+ </p>
45
+ <ul class="flex flex-col gap-3">
46
+ <li
47
+ v-for="(item, i) in entry[group.key]"
48
+ :key="i"
49
+ data-testid="tsp-release-item"
50
+ class="flex gap-2.5 text-sm text-muted"
51
+ >
52
+ <UIcon
53
+ :name="group.icon"
54
+ class="mt-0.5 shrink-0 text-base"
55
+ :class="group.key === 'important' ? 'tsp-text-brand' : 'text-dimmed'"
56
+ />
57
+ <div class="min-w-0">
58
+ <p>{{ text(item) }}</p>
59
+
60
+ <!-- before/after, as the skill's `compare` captions -->
61
+ <div
62
+ v-if="compare(item)"
63
+ data-testid="tsp-release-compare"
64
+ class="mt-2 grid gap-2 sm:grid-cols-2"
65
+ >
66
+ <div
67
+ v-for="side in (['before', 'after'] as const)"
68
+ :key="side"
69
+ class="rounded-md border border-default p-3"
70
+ >
71
+ <p
72
+ class="mb-1 text-[10px] font-bold uppercase tracking-wide"
73
+ :class="side === 'after' ? 'tsp-text-brand' : 'text-dimmed'"
74
+ >
75
+ {{ side === 'before' ? beforeLabel : afterLabel }}
76
+ </p>
77
+ <img
78
+ v-if="image(item, side)"
79
+ :src="image(item, side)"
80
+ :alt="`${side}: ${text(item)}`"
81
+ class="mb-2 w-full rounded-sm border border-default"
82
+ >
83
+ <p class="text-xs text-dimmed">
84
+ {{ compare(item)?.[side] }}
85
+ </p>
86
+ </div>
87
+ </div>
88
+ </div>
89
+ </li>
90
+ </ul>
91
+ </div>
92
+ </template>
93
+ </article>
94
+ </div>
95
+ </template>
96
+
97
+ <script setup lang="ts">
98
+ export interface TspReleaseItemObject {
99
+ text: string
100
+ compare?: { before: string, after: string }
101
+ beforeImage?: string
102
+ afterImage?: string
103
+ }
104
+ export type TspReleaseItem = string | TspReleaseItemObject
105
+ export interface TspRelease {
106
+ version: string
107
+ date?: string
108
+ showBanner?: boolean
109
+ showPopup?: boolean
110
+ important?: TspReleaseItem[]
111
+ other?: TspReleaseItem[]
112
+ }
113
+
114
+ withDefaults(defineProps<{
115
+ /** Newest first. Mirrors the `tsp-release` skill's RELEASES shape, so a service can pass its
116
+ * own array straight through.
117
+ */
118
+ releases: TspRelease[]
119
+ /** Heading over the `important` items. */
120
+ importantLabel?: string
121
+ /** Heading over the `other` items. */
122
+ otherLabel?: string
123
+ /** Caption on the left side of a before/after pair. */
124
+ beforeLabel?: string
125
+ /** Caption on its right side. */
126
+ afterLabel?: string
127
+ }>(), {
128
+ importantLabel: 'Highlights',
129
+ otherLabel: 'Also in this release',
130
+ beforeLabel: 'Before',
131
+ afterLabel: 'After',
132
+ })
133
+
134
+ const groups = [
135
+ { key: 'important' as const, icon: 'i-tabler-sparkles' },
136
+ { key: 'other' as const, icon: 'i-tabler-point' },
137
+ ]
138
+
139
+ const text = (i: TspReleaseItem) => (typeof i === 'string' ? i : i.text)
140
+ const compare = (i: TspReleaseItem) => (typeof i === 'string' ? undefined : i.compare)
141
+ const image = (i: TspReleaseItem, side: 'before' | 'after') =>
142
+ (typeof i === 'string' ? undefined : side === 'before' ? i.beforeImage : i.afterImage)
143
+ </script>
@@ -0,0 +1,52 @@
1
+ <!--
2
+ A titled content card: icon + title in a header row, content below.
3
+
4
+ <TspSectionCard icon="i-tabler-server" title="MCP Server"> … </TspSectionCard>
5
+
6
+ This is the shape trips uses on its settings pages and it generalises: an
7
+ icon and a short title make a card say what it is without a heading competing
8
+ with the page title above it.
9
+
10
+ Sits on `bg-elevated` — the raised CONTENT surface, distinct from the shell's
11
+ `bg-muted`. And `rounded-xl`, written here rather than left to Nuxt UI's
12
+ `rounded-lg`, because this IS the layer's card and the 12px rule applies.
13
+
14
+ No cards inside cards (ADR 018): the code block and fields it holds are
15
+ bordered, not filled a second time.
16
+ -->
17
+ <template>
18
+ <section
19
+ data-testid="tsp-section-card"
20
+ class="rounded-xl border border-default bg-elevated p-5"
21
+ >
22
+ <div
23
+ v-if="title || icon"
24
+ class="flex items-center gap-2 mb-4"
25
+ >
26
+ <UIcon
27
+ v-if="icon"
28
+ :name="icon"
29
+ class="text-lg shrink-0 tsp-text-brand"
30
+ />
31
+ <!-- Gated too: the wrapper allows an icon-only card, which would
32
+ otherwise emit an empty <h2> that heading navigation lands on. -->
33
+ <h2
34
+ v-if="title"
35
+ data-testid="tsp-section-card-title"
36
+ class="text-sm font-bold text-highlighted"
37
+ >
38
+ {{ title }}
39
+ </h2>
40
+ </div>
41
+ <slot />
42
+ </section>
43
+ </template>
44
+
45
+ <script setup lang="ts">
46
+ defineProps<{
47
+ /** Short card title. Omit for an untitled card — no empty heading is emitted. */
48
+ title?: string
49
+ /** Tabler icon name. Amber, because it labels rather than decorates. */
50
+ icon?: string
51
+ }>()
52
+ </script>
@@ -5,9 +5,52 @@
5
5
  #brand — wordmark / logo (inside an inset bottom-bordered header)
6
6
  #nav — TspNavItem links
7
7
  #footer — typically <TspSidebarFooter>
8
+
9
+ STICKY at `sm` and above: exactly the viewport's height, pinned to the top.
10
+ Without that it is a flex child of a `min-h-svh` row, so on a long page it
11
+ grows to the DOCUMENT's height and the footer rows — settings, logout, theme,
12
+ language — scroll away with the content. Navigation that leaves the screen is
13
+ not navigation.
14
+
15
+ RESPONSIVE. At `sm` and above it is an ordinary column. Below `sm` it becomes
16
+ an off-canvas drawer over a backdrop, opened by <TspTopBar>'s burger through
17
+ useTspNavDrawer(). 640px is the breakpoint the platform's own mockup already
18
+ used, so services that hand-built a drawer match.
19
+
20
+ The nav scrolls when it has to, with no visible scrollbar (`tsp-scroll-quiet`)
21
+ — a track beside 32px rows in a 224px column is clutter. Rows carry `shrink-0`
22
+ and the nav `min-h-0`: in a fixed-height column an overflowing flex child
23
+ shrinks by default, which silently squashed 32px rows to 23px once the
24
+ sidebar stopped growing with the page.
25
+
26
+ Fallthrough attrs are bound explicitly to the <aside>: this template has two
27
+ roots (backdrop + aside), and Vue cannot auto-inherit onto a fragment, so a
28
+ consumer's `class` was being dropped silently.
29
+
30
+ The drawer closes on backdrop click, on Escape, on leaving mobile, and ON
31
+ ROUTE CHANGE — a drawer
32
+ that survives navigation is the classic mobile bug: you tap a link, the page
33
+ behind changes, and the menu is still sitting on top of it.
8
34
  -->
9
35
  <template>
10
- <aside class="flex flex-col w-56 shrink-0 bg-elevated border-r border-default py-5">
36
+ <div
37
+ v-if="isOpen"
38
+ data-testid="tsp-nav-backdrop"
39
+ class="sm:hidden fixed inset-0 z-40 bg-black/50"
40
+ @click="close"
41
+ />
42
+
43
+ <aside
44
+ id="tsp-sidebar"
45
+ v-bind="$attrs"
46
+ data-testid="tsp-sidebar"
47
+ :inert="mobileHidden"
48
+ class="flex flex-col w-56 shrink-0 bg-muted border-r border-default py-5
49
+ sm:sticky sm:top-0 sm:h-svh
50
+ max-sm:fixed max-sm:inset-y-0 max-sm:left-0 max-sm:z-50 max-sm:transition-transform"
51
+ :class="isOpen ? 'max-sm:translate-x-0' : 'max-sm:-translate-x-full'"
52
+ :aria-hidden="mobileHidden"
53
+ >
11
54
  <div
12
55
  v-if="$slots.brand"
13
56
  class="mx-2 px-2 pb-4 mb-3 border-b border-default"
@@ -15,12 +58,62 @@
15
58
  <slot name="brand" />
16
59
  </div>
17
60
 
18
- <nav class="flex flex-col gap-0.5 px-2">
61
+ <nav class="flex min-h-0 flex-col gap-0.5 px-2 overflow-y-auto tsp-scroll-quiet">
19
62
  <slot name="nav" />
20
63
  </nav>
21
64
 
22
- <div class="mt-auto px-3 pt-3">
65
+ <div class="mt-auto shrink-0 px-3 pt-3">
23
66
  <slot name="footer" />
24
67
  </div>
25
68
  </aside>
26
69
  </template>
70
+
71
+ <script setup lang="ts">
72
+ defineOptions({ inheritAttrs: false })
73
+
74
+ const { isOpen, close } = useTspNavDrawer()
75
+ const route = useRoute()
76
+
77
+ watch(() => route.fullPath, close)
78
+
79
+ // Off-screen is not the same as hidden: a translated-away drawer is still in
80
+ // the tab order and still read aloud. Only applies below `sm`, so it is tracked
81
+ // against the viewport rather than assumed.
82
+ const isMobile = ref(false)
83
+ const mobileHidden = computed(() => (isMobile.value && !isOpen.value ? 'true' : undefined))
84
+
85
+ onMounted(() => {
86
+ const mq = window.matchMedia('(max-width: 639px)')
87
+ const sync = () => {
88
+ isMobile.value = mq.matches
89
+ // Leaving mobile with the drawer open would otherwise strand it: visually
90
+ // gone, still "open", still holding the scroll lock.
91
+ if (!mq.matches) close()
92
+ }
93
+ sync()
94
+ mq.addEventListener('change', sync)
95
+ onUnmounted(() => mq.removeEventListener('change', sync))
96
+
97
+ const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') close() }
98
+ window.addEventListener('keydown', onKey)
99
+ onUnmounted(() => window.removeEventListener('keydown', onKey))
100
+ })
101
+
102
+ // A drawer over the page should not let the page scroll behind it.
103
+ const setBodyScroll = (locked: boolean) => {
104
+ if (import.meta.client) document.body.style.overflow = locked ? 'hidden' : ''
105
+ }
106
+
107
+ // Keyed on BOTH. Keyed on isOpen alone, widening past `sm` with the drawer
108
+ // open left <body> locked forever: the drawer and backdrop are sm:hidden, so
109
+ // they vanish, the watcher never re-fires, and the page cannot scroll with no
110
+ // control left to unlock it.
111
+ watch([isOpen, isMobile], ([open, mobile]) => setBodyScroll(open && mobile))
112
+
113
+ // Released on unmount as well as on close. A layout swap or an HMR reload while
114
+ // the drawer is open would otherwise leave `overflow: hidden` on <body>
115
+ // forever, and the page could never scroll again.
116
+ onUnmounted(() => {
117
+ if (isOpen.value) setBodyScroll(false)
118
+ })
119
+ </script>