@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,68 @@
1
+ <!--
2
+ A block of configuration to copy — a JSON snippet, a shell line.
3
+
4
+ <TspCodeBlock :code="config" />
5
+ <TspCodeBlock :code="config" copy-label="Copy with API key" />
6
+
7
+ Bordered on the page surface rather than filled again: it lives inside a
8
+ TspSectionCard, and a filled box inside a filled card is the cards-inside-
9
+ cards ADR 018 rules out.
10
+
11
+ The copy button is a real labelled button, not an icon in the corner, because
12
+ what it copies is often not exactly what is shown — a config with the key
13
+ redacted on screen and filled in on copy. An icon cannot say that; a label can.
14
+ -->
15
+ <template>
16
+ <div>
17
+ <pre
18
+ data-testid="tsp-code-block"
19
+ class="overflow-x-auto rounded-md border border-default bg-default p-4 font-mono text-xs leading-relaxed text-muted tsp-scroll-quiet"
20
+ ><code>{{ code }}</code></pre>
21
+
22
+ <div
23
+ v-if="copyValue || code"
24
+ class="mt-3 flex flex-wrap items-center gap-x-3 gap-y-2"
25
+ >
26
+ <!-- shrink-0 + nowrap: in a flex row beside the help text the button was
27
+ being squeezed until its own label wrapped onto two lines. The help
28
+ text wraps instead — it is prose, the label is not. -->
29
+ <UButton
30
+ color="primary"
31
+ icon="i-tabler-copy"
32
+ class="shrink-0 whitespace-nowrap"
33
+ @click="copyAll"
34
+ >
35
+ {{ copied ? copiedLabel : copyLabel }}
36
+ </UButton>
37
+ <p
38
+ v-if="help"
39
+ class="min-w-0 flex-1 text-xs text-dimmed"
40
+ >
41
+ {{ help }}
42
+ </p>
43
+ </div>
44
+ </div>
45
+ </template>
46
+
47
+ <script setup lang="ts">
48
+ const props = withDefaults(defineProps<{
49
+ /** What is SHOWN. */
50
+ code: string
51
+ /** What is COPIED, when that differs — e.g. the same config with the real key. */
52
+ copyValue?: string
53
+ /** Button label at rest. */
54
+ copyLabel?: string
55
+ /** Button label for the moment after a successful copy. */
56
+ copiedLabel?: string
57
+ /** One muted line under the block — e.g. what the placeholder stands for. */
58
+ help?: string
59
+ }>(), {
60
+ copyValue: undefined,
61
+ copyLabel: 'Copy',
62
+ copiedLabel: 'Copied',
63
+ help: undefined,
64
+ })
65
+
66
+ const { copied, copy } = useTspCopy()
67
+ const copyAll = () => copy(props.copyValue ?? props.code)
68
+ </script>
@@ -2,7 +2,13 @@
2
2
  Centered content column.
3
3
 
4
4
  `width` picks the measure (ADR 008, finding 3):
5
- app — max-w-4xl, the logged-in app/admin measure (design-system.md → Layout). Default.
5
+ app — max-w-6xl (1152px), the logged-in app/admin measure
6
+ (design-system.md → Layout). Default. It was max-w-4xl (896px),
7
+ inherited from tsp.tools' local container and never actually
8
+ measured: beside the 224px sidebar that left ~160px of dead page on
9
+ each side at 1440px and ~400px at 1920px, squeezing exactly the
10
+ tables and card grids that need the room. Prose is capped per page
11
+ instead, where the paragraph is.
6
12
  site — 1240px, the marketing measure from the approved storefront mockup.
7
13
 
8
14
  `padded` controls the vertical rhythm (pt-10 pb-16). It suits a page body, so it
@@ -17,7 +23,9 @@
17
23
 
18
24
  <script setup lang="ts">
19
25
  const props = withDefaults(defineProps<{
26
+ /** `app` = 1152px, the logged-in measure. `site` = 1240px, the marketing one. */
20
27
  width?: 'app' | 'site'
28
+ /** Vertical rhythm (`pt-10 pb-16`). Off for header/footer frames. */
21
29
  padded?: boolean
22
30
  }>(), {
23
31
  width: 'app',
@@ -26,7 +34,7 @@ const props = withDefaults(defineProps<{
26
34
 
27
35
  const classes = computed(() => [
28
36
  'mx-auto',
29
- props.width === 'site' ? 'max-w-[1240px] px-6 md:px-10' : 'max-w-4xl px-6 sm:px-10',
37
+ props.width === 'site' ? 'max-w-[1240px] px-6 md:px-10' : 'max-w-6xl px-6 sm:px-10',
30
38
  props.padded ? 'pt-10 pb-16' : '',
31
39
  ])
32
40
  </script>
@@ -0,0 +1,40 @@
1
+ <!--
2
+ Copy-to-clipboard button. Confirms by swapping its own icon for a tick for a
3
+ moment — a copy that gives no feedback leaves you pressing it twice.
4
+
5
+ The copying itself is useTspCopy(), shared with TspCodeBlock: the two had the
6
+ same secure-context fallback written out twice, and had already drifted (only
7
+ one of them set `readonly` on the fallback textarea).
8
+ -->
9
+ <template>
10
+ <button
11
+ type="button"
12
+ data-testid="tsp-copy-button"
13
+ :aria-label="copied ? copiedLabel : label"
14
+ :title="copied ? copiedLabel : label"
15
+ class="flex size-8 shrink-0 items-center justify-center rounded-md text-muted hover:text-highlighted hover:bg-default"
16
+ @click="copy"
17
+ >
18
+ <UIcon
19
+ :name="copied ? 'i-tabler-check' : 'i-tabler-copy'"
20
+ :class="['text-lg', copied ? 'tsp-text-brand' : '']"
21
+ />
22
+ </button>
23
+ </template>
24
+
25
+ <script setup lang="ts">
26
+ const props = withDefaults(defineProps<{
27
+ /** The string put on the clipboard. */
28
+ value: string
29
+ /** Button label at rest. */
30
+ label?: string
31
+ /** Button label for the moment after a successful copy. */
32
+ copiedLabel?: string
33
+ }>(), {
34
+ label: 'Copy',
35
+ copiedLabel: 'Copied',
36
+ })
37
+
38
+ const { copied, copy: run } = useTspCopy()
39
+ const copy = () => run(props.value)
40
+ </script>
@@ -0,0 +1,102 @@
1
+ <!--
2
+ A read-only value with a copy button — an endpoint, a server URL, an API key.
3
+
4
+ <TspCopyField label="Endpoint" value="https://trips.tsp.tools/mcp" />
5
+ <TspCopyField label="API key" :value="key" secret help="Treat this like a password." />
6
+
7
+ `secret` masks the value behind a reveal, the same rule TspUserCard follows:
8
+ WHILE HIDDEN THE VALUE IS NOT RENDERED. Covering it with CSS leaves it in the
9
+ page source, copyable and readable by anything walking the DOM — which is not
10
+ masking, it only looks like it. Copy still copies the real value: the point is
11
+ that it is not on screen, not that it is unreachable.
12
+
13
+ Monospace, because these are values you compare character by character.
14
+ -->
15
+ <template>
16
+ <div>
17
+ <!-- `for` pointed at a <code>, which is not a labelable element, so the
18
+ association did nothing. aria-labelledby names the value directly. -->
19
+ <p
20
+ v-if="label"
21
+ :id="`${id}-label`"
22
+ class="block mb-1 text-xs font-bold text-toned"
23
+ >{{ label }}</p>
24
+
25
+ <div class="flex items-center gap-1 rounded-md border border-default bg-default pl-3 pr-1 py-1">
26
+ <code
27
+ :id="id"
28
+ :aria-labelledby="label ? `${id}-label` : undefined"
29
+ data-testid="tsp-copy-field-value"
30
+ class="flex-1 min-w-0 truncate font-mono text-sm text-muted"
31
+ >{{ secret && !revealed ? mask : value }}</code>
32
+
33
+ <button
34
+ v-if="secret"
35
+ type="button"
36
+ data-testid="tsp-copy-field-reveal"
37
+ :aria-pressed="revealed"
38
+ :aria-label="revealed ? hideLabel : revealLabel"
39
+ :title="revealed ? hideLabel : revealLabel"
40
+ class="flex size-8 shrink-0 items-center justify-center rounded-md text-muted hover:text-highlighted hover:bg-elevated"
41
+ @click="toggle"
42
+ >
43
+ <UIcon
44
+ :name="revealed ? 'i-tabler-eye-off' : 'i-tabler-eye'"
45
+ class="text-lg"
46
+ />
47
+ </button>
48
+
49
+ <TspCopyButton
50
+ :value="value"
51
+ :label="copyLabel"
52
+ :copied-label="copiedLabel"
53
+ />
54
+ </div>
55
+
56
+ <div
57
+ v-if="help || $slots.help"
58
+ class="mt-1.5 flex items-start gap-3 text-xs text-dimmed"
59
+ >
60
+ <span class="flex-1">{{ help }}</span>
61
+ <slot name="help" />
62
+ </div>
63
+ </div>
64
+ </template>
65
+
66
+ <script setup lang="ts">
67
+ const props = withDefaults(defineProps<{
68
+ /** What is shown and copied. Not rendered at all while masked. */
69
+ value: string
70
+ /** Field label above the value. */
71
+ label?: string
72
+ /** One muted line under the field. */
73
+ help?: string
74
+ /** Mask the value behind a reveal. */
75
+ secret?: boolean
76
+ /** Accessible name for the eye while the value is hidden. */
77
+ revealLabel?: string
78
+ /** Accessible name for the eye while the value is shown. */
79
+ hideLabel?: string
80
+ /** Accessible name for the copy button at rest. */
81
+ copyLabel?: string
82
+ /** Accessible name for it just after a successful copy. */
83
+ copiedLabel?: string
84
+ }>(), {
85
+ label: undefined,
86
+ help: undefined,
87
+ secret: false,
88
+ revealLabel: 'Show',
89
+ hideLabel: 'Hide',
90
+ copyLabel: 'Copy',
91
+ copiedLabel: 'Copied',
92
+ })
93
+
94
+ const id = useId()
95
+ const { revealed, toggle, mask } = useTspReveal(28)
96
+
97
+ // A rotated secret must start masked. The "Regenerate" action this component is
98
+ // designed to sit beside swaps `value` on the SAME instance, so without this
99
+ // the brand-new key appears in the clear with no fresh disclosure — at the one
100
+ // moment masking matters most.
101
+ watch(() => props.value, () => { revealed.value = false })
102
+ </script>
@@ -0,0 +1,56 @@
1
+ <!--
2
+ One row of the sidebar footer nav — icon + label, styled exactly like a
3
+ TspNavItem at rest. This exists because gage, pogo and trips each carried the
4
+ same `footerRowClass` string in their own layout; no consumer should write it
5
+ again (theme 0.3.0).
6
+
7
+ Fixed 32px height rather than vertical padding, so it matches the other rows
8
+ in TspSidebarFooter even when one of them holds a control (spec 03).
9
+
10
+ With `to`, the row goes amber on its own route, like TspNavItem — a footer row
11
+ that is a real destination (the user page) has to be able to say "you are
12
+ here". `href` and `button` rows have no route to match, so they never do.
13
+
14
+ Renders a <NuxtLink> with `to`, an <a> with `href`, or a <button> with
15
+ neither:
16
+
17
+ <TspFooterRow href="/sign-out" icon="i-tabler-logout">Logout</TspFooterRow>
18
+ -->
19
+ <template>
20
+ <component
21
+ :is="tag"
22
+ v-bind="linkProps"
23
+ data-testid="tsp-footer-row"
24
+ class="flex items-center gap-2.5 h-8 px-3 rounded-md text-sm font-normal text-muted hover:text-highlighted hover:bg-default"
25
+ :active-class="to ? 'tsp-text-brand' : undefined"
26
+ >
27
+ <UIcon
28
+ v-if="icon"
29
+ :name="icon"
30
+ class="text-lg"
31
+ />
32
+ <slot />
33
+ </component>
34
+ </template>
35
+
36
+ <script setup lang="ts">
37
+ const props = defineProps<{
38
+ /** Internal route — renders a NuxtLink. */
39
+ to?: string
40
+ /** External or non-router href — renders an <a>. */
41
+ href?: string
42
+ /** Tabler icon name. Every footer row carries one. */
43
+ icon?: string
44
+ }>()
45
+
46
+ const tag = computed(() => {
47
+ if (props.to) return resolveComponent('NuxtLink')
48
+ return props.href ? 'a' : 'button'
49
+ })
50
+
51
+ const linkProps = computed(() => {
52
+ if (props.to) return { to: props.to }
53
+ if (props.href) return { href: props.href }
54
+ return { type: 'button' as const }
55
+ })
56
+ </script>
@@ -0,0 +1,75 @@
1
+ <!--
2
+ DE/EN segmented control — the component gage, trips, pogo and tsp-tools each
3
+ built by hand (theme 0.3.0).
4
+
5
+ It owns APPEARANCE ONLY. Which locales exist, and how a choice is persisted,
6
+ stay with the app: pass the codes in, bind v-model, react in the app.
7
+
8
+ Every string is a prop with an English default (ADR 008, finding 4 — the same
9
+ mistake TspThemeToggle had to be fixed for in 0.2.0). `labels` are the
10
+ accessible names; the visible text is the code itself, uppercased, unless the
11
+ default slot says otherwise.
12
+
13
+ <TspLanguageToggle
14
+ v-model="locale"
15
+ :locales="['de', 'en']"
16
+ :group-label="t('shell.language')"
17
+ :labels="{ de: t('shell.german'), en: t('shell.english') }"
18
+ />
19
+
20
+ The selected segment is amber TEXT on a raised surface, not an amber fill:
21
+ ADR 018 leaves the fill to primary buttons, and this control usually sits in
22
+ the sidebar footer.
23
+ -->
24
+ <template>
25
+ <div
26
+ data-testid="tsp-language-toggle"
27
+ role="group"
28
+ :aria-label="groupLabel"
29
+ class="inline-flex items-center gap-0.5 rounded-md border border-default p-0.5"
30
+ >
31
+ <button
32
+ v-for="code in locales"
33
+ :key="code"
34
+ type="button"
35
+ :data-testid="`tsp-lang-${code}`"
36
+ :aria-pressed="code === modelValue"
37
+ :aria-label="labelFor(code)"
38
+ :title="labelFor(code)"
39
+ :lang="code"
40
+ class="rounded-sm px-2 py-0.5 text-xs font-bold uppercase transition-colors"
41
+ :class="code === modelValue
42
+ ? 'bg-accented tsp-text-brand'
43
+ : 'text-muted hover:text-highlighted'"
44
+ @click="select(code)"
45
+ >
46
+ <slot
47
+ :locale="code"
48
+ :label="labelFor(code)"
49
+ >{{ code }}</slot>
50
+ </button>
51
+ </div>
52
+ </template>
53
+
54
+ <script setup lang="ts">
55
+ const props = withDefaults(defineProps<{
56
+ /** The active locale code. */
57
+ modelValue?: string
58
+ /** The codes to offer, in display order. */
59
+ locales?: string[]
60
+ /** aria-label for the group. */
61
+ groupLabel?: string
62
+ /** Accessible name per code (aria-label + title). Falls back to the code. */
63
+ labels?: Record<string, string>
64
+ }>(), {
65
+ modelValue: undefined,
66
+ locales: () => ['de', 'en'],
67
+ groupLabel: 'Language',
68
+ labels: () => ({ de: 'German', en: 'English' }),
69
+ })
70
+
71
+ const emit = defineEmits<{ 'update:modelValue': [code: string] }>()
72
+
73
+ const labelFor = (code: string) => props.labels[code] ?? code.toUpperCase()
74
+ const select = (code: string) => emit('update:modelValue', code)
75
+ </script>
@@ -0,0 +1,36 @@
1
+ <!--
2
+ The small pill on a nav row — stash's OWNER / MEMBER (spec 05).
3
+
4
+ TONES ARE PRESENTATIONAL, NOT SEMANTIC (`primary` | `neutral`). The layer has
5
+ no idea what roles a service has, and naming them `owner`/`member` here would
6
+ bake one app's permission model into eight. The app decides which tone means
7
+ what.
8
+
9
+ BOTH TONES ARE OUTLINES. ADR 018 gives the amber fill to primary buttons
10
+ alone, and the sidebar is swept for amber fills at rest — an amber pill here
11
+ would be exactly the "one small filled block competing with the primary
12
+ button" that ADR was written about.
13
+ -->
14
+ <template>
15
+ <span
16
+ data-testid="tsp-nav-badge"
17
+ :data-tone="tone"
18
+ class="shrink-0 rounded-full border px-1.5 py-px text-[10px] font-bold uppercase tracking-wide"
19
+ :class="tone === 'primary'
20
+ ? 'tsp-text-brand border-current'
21
+ : 'text-muted border-default'"
22
+ >
23
+ <slot />
24
+ </span>
25
+ </template>
26
+
27
+ <script setup lang="ts">
28
+ withDefaults(defineProps<{
29
+ /** `primary` = amber outline, `neutral` = grey. Outline only — a fill in the sidebar would
30
+ * read as a primary button (ADR 018).
31
+ */
32
+ tone?: 'primary' | 'neutral'
33
+ }>(), {
34
+ tone: 'neutral',
35
+ })
36
+ </script>
@@ -0,0 +1,58 @@
1
+ <!--
2
+ A nav item that owns sub-items (spec 04, variant A).
3
+
4
+ <TspNavGroup to="/fahrten" icon="i-tabler-car" label="Fahrten">
5
+ <TspNavSubItem to="/fahrten/alle">Alle Fahrten</TspNavSubItem>
6
+ </TspNavGroup>
7
+
8
+ Children are ALWAYS VISIBLE. The collapsible variant was rejected: it needs an
9
+ open/closed value that has to survive navigation and then be remembered per
10
+ user, which is a persistence question this layer has no business answering.
11
+
12
+ The parent is an ordinary TspNavItem and needs no special colour — under
13
+ reading 2 every top-level row is already white.
14
+
15
+ The indent is measured, not guessed: `ml-[1.375rem]` puts the hairline under
16
+ the centre of the parent's icon (px-3 = 12px, plus half of the 18px icon), so
17
+ the spine hangs from the icon rather than floating between columns.
18
+ -->
19
+ <template>
20
+ <div
21
+ data-testid="tsp-nav-group"
22
+ class="flex shrink-0 flex-col gap-0.5"
23
+ >
24
+ <TspNavItem
25
+ :to="to"
26
+ :icon="icon"
27
+ :badge="badge"
28
+ :badge-tone="badgeTone"
29
+ >
30
+ {{ label }}
31
+ </TspNavItem>
32
+ <div
33
+ data-testid="tsp-nav-group-children"
34
+ class="ml-[1.375rem] pl-3 border-l border-default flex flex-col gap-0.5"
35
+ >
36
+ <slot />
37
+ </div>
38
+ </div>
39
+ </template>
40
+
41
+ <script setup lang="ts">
42
+ withDefaults(defineProps<{
43
+ /** Route of the parent row itself — the group is a destination, not just a heading. */
44
+ to: string
45
+ /** Tabler icon name for the parent row. */
46
+ icon?: string
47
+ /** Text of the parent row. */
48
+ label: string
49
+ /** Optional pill on the right, e.g. a role. */
50
+ badge?: string
51
+ /** Tone of that pill; see TspNavBadge. */
52
+ badgeTone?: 'primary' | 'neutral'
53
+ }>(), {
54
+ icon: undefined,
55
+ badge: undefined,
56
+ badgeTone: 'neutral',
57
+ })
58
+ </script>
@@ -1,25 +1,81 @@
1
1
  <!--
2
- Sidebar nav link: muted by default, filled-amber when active. Pass a Tabler
3
- icon name (`i-tabler-*`) and the route.
2
+ Sidebar nav link: WHITE at rest, amber TEXT on a transparent background when
3
+ active. Pass a Tabler icon name (`i-tabler-*`) and the route.
4
+
5
+ White at rest is spec 04 (reading 2) REVISING ADR 018, which had these rows
6
+ muted for a quieter shell. What survives from that ADR: no amber fill at rest
7
+ anywhere, rows stay weight 400, and amber means "you are here". Sub-items
8
+ inside a TspNavGroup stay muted, so the nav reads white → muted → amber.
9
+
10
+ Row metrics are stash's: 32 px tall, 6 px radius, weight 400. The same row
11
+ style is used by TspFooterRow — keep the two class strings in step.
4
12
  -->
5
13
  <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"
14
+ <component
15
+ :is="tag"
16
+ v-bind="linkProps"
17
+ data-testid="tsp-nav-item"
18
+ class="group relative flex shrink-0 items-center gap-2.5 h-8 px-3 rounded-md text-sm font-normal text-highlighted hover:bg-default"
19
+ :active-class="to ? 'tsp-text-brand' : undefined"
10
20
  >
11
21
  <UIcon
12
22
  v-if="icon"
13
23
  :name="icon"
14
- class="text-lg"
24
+ class="text-lg shrink-0"
15
25
  />
16
- <slot />
17
- </NuxtLink>
26
+ <!-- The LABEL gives way, not the badge: rows are a fixed 32px, so letting
27
+ a long name push the badge onto a second line (as stash does) would
28
+ break the row rhythm. TspNavLabel gives the truncated text back on
29
+ hover. -->
30
+ <TspNavLabel><slot /></TspNavLabel>
31
+ <TspNavBadge
32
+ v-if="badge"
33
+ :tone="badgeTone"
34
+ class="ml-auto"
35
+ >{{ badge }}</TspNavBadge>
36
+ </component>
18
37
  </template>
19
38
 
20
39
  <script setup lang="ts">
21
- defineProps<{
22
- to: string
40
+ const props = withDefaults(defineProps<{
41
+ /** Internal route — renders a NuxtLink. */
42
+ to?: string
43
+ /** External or non-router destination — renders an <a>. A section labelled
44
+ "External" whose rows cannot leave the app is not much of a section. */
45
+ href?: string
46
+ /** Only with `href`. Any target that leaves this context also gets `rel="noopener
47
+ * noreferrer"`.
48
+ */
49
+ target?: string
50
+ /** Tabler icon name. Every nav row carries one. */
23
51
  icon?: string
24
- }>()
52
+ /** Optional pill on the right of the row, e.g. a role. */
53
+ badge?: string
54
+ /** Tone of the badge; see TspNavBadge. */
55
+ badgeTone?: 'primary' | 'neutral'
56
+ }>(), {
57
+ to: undefined,
58
+ href: undefined,
59
+ target: undefined,
60
+ icon: undefined,
61
+ badge: undefined,
62
+ badgeTone: 'neutral',
63
+ })
64
+
65
+ // resolveComponent must run in setup, not in a template expression: there it
66
+ // returns the NAME and Vue renders a literal <NuxtLink> element instead of an
67
+ // anchor. lint, typecheck and the build all stay green while the whole nav is
68
+ // broken — only the e2e caught it. Same shape as TspFooterRow, deliberately.
69
+ const tag = computed(() => (props.href ? 'a' : resolveComponent('NuxtLink')))
70
+
71
+ const linkProps = computed(() => (props.href
72
+ ? {
73
+ href: props.href,
74
+ target: props.target,
75
+ // Any target that leaves this browsing context, not just _blank: a
76
+ // NAMED target (`target="docs"`) also hands the opened document a live
77
+ // window.opener, and the browser's implicit noopener covers _blank only.
78
+ rel: props.target && props.target !== '_self' ? 'noopener noreferrer' : undefined,
79
+ }
80
+ : { to: props.to }))
25
81
  </script>
@@ -0,0 +1,56 @@
1
+ <!--
2
+ A nav row's label: truncated to the sidebar, with the full text revealed on
3
+ hover after a short pause.
4
+
5
+ NOT a tooltip bubble — it is the label itself, in the row's own surface,
6
+ allowed to run past the sidebar edge. A 224px sidebar will always be too
7
+ narrow for some project names, and truncating with no way to read the rest
8
+ makes those rows useless.
9
+
10
+ The reveal only exists when the text ACTUALLY overflows, measured rather than
11
+ guessed: rendering it for every row would put an invisible overlay on labels
12
+ that do not need one.
13
+
14
+ It is aria-hidden. CSS truncation does not remove text, so a screen reader
15
+ already reads the full label from the element above — the reveal is purely
16
+ visual, and announcing it would read every long row twice.
17
+
18
+ Appears after 500ms, disappears instantly: `delay-500` applies only under
19
+ group-hover, so the delay is on the way in and not on the way out.
20
+ -->
21
+ <template>
22
+ <span
23
+ class="relative min-w-0 flex-1"
24
+ @mouseenter="measure"
25
+ >
26
+ <span
27
+ ref="labelEl"
28
+ data-testid="tsp-nav-label"
29
+ class="block truncate"
30
+ >
31
+ <slot />
32
+ </span>
33
+ <span
34
+ v-if="overflowing"
35
+ data-testid="tsp-nav-label-full"
36
+ aria-hidden="true"
37
+ class="pointer-events-none absolute left-0 top-1/2 z-30 w-max max-w-64 -translate-y-1/2 rounded-md border border-default bg-elevated px-2 py-1 whitespace-nowrap opacity-0 shadow-lg transition-opacity duration-150 delay-0 group-hover:opacity-100 group-hover:delay-500"
38
+ >
39
+ <slot />
40
+ </span>
41
+ </span>
42
+ </template>
43
+
44
+ <script setup lang="ts">
45
+ const labelEl = ref<HTMLElement | null>(null)
46
+ const overflowing = ref(false)
47
+
48
+ // Measured, not assumed. Re-measured on hover as well as on mount, because the
49
+ // sidebar's width and the row's content can both change after first render.
50
+ const measure = () => {
51
+ const el = labelEl.value
52
+ if (el) overflowing.value = el.scrollWidth > el.clientWidth
53
+ }
54
+
55
+ onMounted(measure)
56
+ </script>
@@ -0,0 +1,33 @@
1
+ <!--
2
+ A break in the nav: a rule, optionally followed by a small label.
3
+
4
+ <TspNavSection label="External" /> rule + label
5
+ <TspNavSection /> bare divider
6
+
7
+ The label deliberately shares the sidebar footer's `User` treatment — 10px,
8
+ uppercase, muted — so a section header reads the same wherever it appears
9
+ (spec 04).
10
+ -->
11
+ <template>
12
+ <div
13
+ data-testid="tsp-nav-section"
14
+ :data-bare="!label"
15
+ class="mt-3 shrink-0"
16
+ >
17
+ <hr class="border-default">
18
+ <p
19
+ v-if="label"
20
+ data-testid="tsp-nav-section-label"
21
+ class="px-3 mt-3 mb-1 text-[10px] uppercase tracking-wide text-muted"
22
+ >
23
+ {{ label }}
24
+ </p>
25
+ </div>
26
+ </template>
27
+
28
+ <script setup lang="ts">
29
+ defineProps<{
30
+ /** Omit for a bare divider. */
31
+ label?: string
32
+ }>()
33
+ </script>