@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
@@ -1,60 +1,355 @@
1
1
  <!--
2
- The sidebar bottom block, in canonical order:
3
- User label → #user → #logout → divider → theme toggle → GitHub + version.
2
+ The sidebar bottom block, in the order spec 03 fixes:
4
3
 
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.
4
+ User label user settings logout
5
+ ───────────
6
+ [theme ……… DE|EN] → [github · support · tip jar ……… version] → “A tool of…”
7
+
8
+ The divider separates ACCOUNT from PREFERENCES: above it is who you are, your
9
+ settings and leaving; below it is how the app looks and speaks, plus the meta
10
+ links. All four rows share one left edge and one 32px height. Before 0.3.0 the
11
+ language toggle was positioned by each app (`class="mt-1 ml-3"`) and the theme
12
+ toggle row was text-xs/px-1 against TspFooterRow's text-sm/px-3 — spec 01
13
+ shipped that mismatch knowingly and left it open; this closes it.
14
+
15
+ PROPS, NOT SLOTS, for the rows every app has. A slot invites each app to
16
+ re-style its own copy, which is the drift TspFooterRow was created to stop.
17
+ `#user` survives only for apps that need more than a name.
18
+
19
+ OPEN SOURCE IS DERIVED FROM `githubLink`. Fil's framing: "I just define in a
20
+ project if it's open source, and then there will be a GitHub link or not." So
21
+ the link is the consequence of the fact, and a separate `variant` prop would
22
+ be a second source of truth that can disagree with it.
23
+
24
+ githubLink set → theme-toggle row, then a GitHub row whose version badge
25
+ links to the release.
26
+ githubLink unset → one row: theme toggle with a PLAIN-TEXT version badge.
27
+ `versionLink` is then a no-op, silently (a warning would
28
+ scold someone for passing both and toggling githubLink
29
+ per environment, which is reasonable).
30
+
31
+ The language row renders only when `locales` is passed — that is what makes it
32
+ absent for the services that are not bilingual.
7
33
  -->
8
34
  <template>
9
35
  <div>
10
36
  <p
11
- v-if="$slots.user"
12
- class="px-1 mb-1 text-[10px] uppercase tracking-wide text-dimmed"
37
+ v-if="user || $slots.user"
38
+ data-testid="tsp-user-label"
39
+ class="px-3 mb-1 text-[10px] uppercase tracking-wide text-muted"
13
40
  >
14
- User
41
+ {{ userLabel }}
15
42
  </p>
16
- <slot name="user" />
17
- <slot name="logout" />
18
43
 
19
- <TspThemeToggle class="w-full mt-2 pt-2.5 pb-1.5 border-t border-default" />
44
+ <slot name="user">
45
+ <!-- A LINK when `userTo` is given — the user page is a real destination,
46
+ and the row then goes amber on it like any other active row. Without
47
+ it the row is plain text. The icon inherits the row colour either
48
+ way: the original complaint was an amber icon on a row that was
49
+ neither selected nor clickable. -->
50
+ <TspFooterRow
51
+ v-if="user && userTo"
52
+ data-footer-row="user"
53
+ data-testid="tsp-user"
54
+ :to="userTo"
55
+ :icon="userIcon"
56
+ >
57
+ <span class="truncate">{{ user }}</span>
58
+ </TspFooterRow>
59
+ <p
60
+ v-else-if="user"
61
+ data-footer-row="user"
62
+ data-testid="tsp-user"
63
+ :class="[ROW, 'text-muted']"
64
+ >
65
+ <UIcon
66
+ v-if="userIcon"
67
+ :name="userIcon"
68
+ class="text-lg shrink-0"
69
+ />
70
+ <span class="truncate">{{ user }}</span>
71
+ </p>
72
+ </slot>
20
73
 
21
- <div
22
- v-if="githubLink || version"
23
- class="flex items-center gap-1.5 px-1 pt-1.5 text-xs text-muted"
74
+ <TspFooterRow
75
+ v-if="settingsTo"
76
+ data-footer-row="settings"
77
+ :to="settingsTo"
78
+ icon="i-tabler-settings"
24
79
  >
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"
80
+ {{ settingsLabel }}
81
+ </TspFooterRow>
82
+
83
+ <!-- The slot is kept as an escape hatch, exactly like #user. Removing it
84
+ was a SILENT break: Vue discards content for a slot that no longer
85
+ exists, so every consumer still passing #logout would have lost its
86
+ logout control with no error, no warning and no build failure. -->
87
+ <slot name="logout">
88
+ <TspFooterRow
89
+ v-if="logoutTo || logoutHref"
90
+ data-footer-row="logout"
91
+ :to="logoutTo"
92
+ :href="logoutHref"
93
+ icon="i-tabler-logout"
31
94
  >
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"
95
+ {{ logoutLabel }}
96
+ </TspFooterRow>
97
+ </slot>
98
+
99
+ <div class="mt-2 pt-2 border-t border-default">
100
+ <!-- Language sits BELOW the divider with the theme toggle: both are
101
+ display preferences, while everything above the line is the account
102
+ (who you are, your settings, leaving). -->
103
+ <!-- Preferences row: theme on the left with its label, language on the
104
+ right. The old "Sprache" text is gone — a DE/EN segmented control
105
+ says what it is, so the label was carrying nothing, and the space it
106
+ freed is what lets the theme toggle keep ITS label instead of being
107
+ an unexplained sun. The row itself has no padding: the toggle brings
108
+ its own px-3, so the icon still lands on the 12px column. -->
109
+ <div
110
+ data-footer-row="preferences"
111
+ class="flex items-center h-8"
41
112
  >
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"
113
+ <TspThemeToggle
114
+ :label-to-light="labelToLight"
115
+ :label-to-dark="labelToDark"
116
+ :aria-label-to-light="ariaLabelToLight"
117
+ :aria-label-to-dark="ariaLabelToDark"
118
+ />
119
+ <TspLanguageToggle
120
+ v-if="locales?.length"
121
+ class="ml-auto mr-3"
122
+ :model-value="modelValue"
123
+ :locales="locales"
124
+ :group-label="languageGroupLabel"
125
+ :labels="languageLabels"
126
+ @update:model-value="emit('update:modelValue', $event)"
127
+ />
128
+ </div>
129
+
130
+ <div
131
+ data-testid="tsp-meta-row"
132
+ class="flex items-center gap-1 h-8 px-2 text-muted"
47
133
  >
48
- {{ version }}
49
- </span>
134
+ <a
135
+ v-if="githubLink"
136
+ data-testid="tsp-github-link"
137
+ :href="githubLink"
138
+ :aria-label="githubLabel"
139
+ :title="githubLabel"
140
+ target="_blank"
141
+ rel="noopener noreferrer"
142
+ :class="ICON"
143
+ >
144
+ <UIcon
145
+ name="i-tabler-brand-github"
146
+ class="text-lg block"
147
+ />
148
+ </a>
149
+ <a
150
+ v-if="supportEmail"
151
+ data-testid="tsp-support-link"
152
+ :href="supportHref"
153
+ :aria-label="supportLabel"
154
+ :title="supportLabel"
155
+ :class="ICON"
156
+ >
157
+ <UIcon
158
+ name="i-tabler-mail"
159
+ class="text-lg block"
160
+ />
161
+ </a>
162
+ <a
163
+ v-if="tipJar"
164
+ data-testid="tsp-tip-jar-link"
165
+ :href="tipJarLink"
166
+ :aria-label="tipJarLabel"
167
+ :title="tipJarLabel"
168
+ target="_blank"
169
+ rel="noopener noreferrer"
170
+ :class="ICON"
171
+ >
172
+ <UIcon
173
+ name="i-tabler-coin"
174
+ class="text-lg block"
175
+ />
176
+ </a>
177
+
178
+ <TspVersionBadge
179
+ v-if="version"
180
+ class="ml-auto"
181
+ :version="version"
182
+ :href="githubLink ? versionLink : undefined"
183
+ :unread="versionUnread"
184
+ />
185
+ </div>
186
+
187
+ <TspToolOf
188
+ v-if="toolOf"
189
+ class="px-3 pt-2 pb-0.5"
190
+ :label="toolOfLabel"
191
+ :name="toolOfName"
192
+ :link="toolOfLink"
193
+ />
50
194
  </div>
51
195
  </div>
52
196
  </template>
53
197
 
54
198
  <script setup lang="ts">
55
- defineProps<{
199
+ // One row shape for every row in this block: 32px tall, 12px left edge, 14px
200
+ // text. Fixed height rather than vertical padding, because the language row
201
+ // holds a control and would otherwise come out taller than its neighbours.
202
+ const ROW = 'flex items-center gap-2.5 h-8 px-3 rounded-md text-sm font-normal'
203
+ // Icon control in the meta row. p-1 on top of the row's px-2 puts the glyph on
204
+ // the same 12px column as the icons in the rows above.
205
+ const ICON = 'flex items-center p-1 rounded-md hover:text-highlighted hover:bg-default'
206
+
207
+ const props = withDefaults(defineProps<{
208
+ /** The username. Plain text, no icon. Use #user only if you need more. */
209
+ user?: string
210
+ /** Accessible name for the user row. */
211
+ userLabel?: string
212
+ /** Icon for the user row. Pass `undefined` for a bare name. */
213
+ userIcon?: string
214
+ /** Route to the user page. Given, the row becomes a link and can go active. */
215
+ userTo?: string
216
+
217
+ /** Route to the settings page. Omit to drop the row. */
218
+
219
+ settingsTo?: string
220
+ /** Visible text of the settings row. */
221
+ settingsLabel?: string
222
+
223
+ /** Internal logout route. Use this or `logoutHref`, not both. */
224
+
225
+ logoutTo?: string
226
+ /** External logout URL — the usual case with a hosted identity provider. */
227
+ logoutHref?: string
228
+ /** Visible text of the logout row. */
229
+ logoutLabel?: string
230
+
231
+ /** Active locale (v-model). The language toggle renders only with `locales`. */
232
+ modelValue?: string
233
+ /** Locale codes, e.g. `['de', 'en']`. The language row appears only with this — not every
234
+ * service is translated.
235
+ */
236
+ locales?: string[]
237
+ /** Accessible name for the locale group. There is no visible label. */
238
+ languageGroupLabel?: string
239
+ /** Accessible names per code, e.g. `{ de: 'Deutsch' }`. The button itself shows the code. */
240
+ languageLabels?: Record<string, string>
241
+
242
+ /** Presence of this is what makes the project open source. */
56
243
  githubLink?: string
244
+ /** Accessible name for the GitHub icon. */
245
+ githubLabel?: string
246
+ /** Support address. One platform mailbox, so it has a default. */
247
+ supportEmail?: string
248
+ /** Accessible name for the support icon. */
249
+ supportLabel?: string
250
+ /** Subject line. Defaults to one naming this app, so support can tell which. */
251
+ supportSubject?: string
252
+ /** First line of the body. Defaults to app + version, for the same reason. */
253
+ supportBody?: string
254
+ /** This app's name, used in the support subject/body and nothing else. */
255
+ appName?: string
256
+ /** The tip jar icon. Same destination for every service, so only a boolean. */
257
+ tipJar?: boolean
258
+ /** Accessible name for the tip-jar icon. */
259
+ tipJarLabel?: string
260
+ /** Where it goes. One destination for the whole platform, so services do not set this. */
261
+ tipJarLink?: string
262
+ /** Version string for the badge, e.g. `v0.3.0`. Omit to drop the badge. */
57
263
  version?: string
264
+ /** Release link for the badge. Ignored without `githubLink`. */
58
265
  versionLink?: string
59
- }>()
266
+ /** Mark the version badge as having unseen release notes. */
267
+ versionUnread?: boolean
268
+
269
+ /** The “A tool of tsp.tools” line. Off by default; only some services carry it. */
270
+ toolOf?: boolean
271
+ /** Leading words of the attribution. Left undefined so TspToolOf owns the default. */
272
+ toolOfLabel?: string
273
+ /** The platform name in it. Same reason. */
274
+ toolOfName?: string
275
+ /** Where that name points. Same reason. */
276
+ toolOfLink?: string
277
+
278
+ /** Theme-toggle text while dark — it names the DESTINATION. */
279
+
280
+ labelToLight?: string
281
+ /** Theme-toggle text while light. */
282
+ labelToDark?: string
283
+ /** Its accessible name while dark. */
284
+ ariaLabelToLight?: string
285
+ /** Its accessible name while light. */
286
+ ariaLabelToDark?: string
287
+ }>(), {
288
+ user: undefined,
289
+ userLabel: 'User',
290
+ userIcon: 'i-tabler-user-circle',
291
+ userTo: undefined,
292
+ settingsTo: undefined,
293
+ settingsLabel: 'Settings',
294
+ logoutTo: undefined,
295
+ logoutHref: undefined,
296
+ logoutLabel: 'Logout',
297
+ modelValue: undefined,
298
+ locales: undefined,
299
+ languageGroupLabel: 'Language',
300
+ languageLabels: undefined,
301
+ githubLink: undefined,
302
+ githubLabel: 'GitHub',
303
+ // The platform mailbox, not a per-service one — so it is a default rather
304
+ // than something eight apps each retype.
305
+ supportEmail: 'support@tsp.tools',
306
+ supportLabel: 'Support',
307
+ supportSubject: undefined,
308
+ supportBody: undefined,
309
+ appName: undefined,
310
+ // Opt-in, not on by default: switching it on would put a money link into
311
+ // every consuming app on upgrade, which is not something a version bump
312
+ // should decide.
313
+ tipJar: false,
314
+ tipJarLabel: 'Tip jar',
315
+ tipJarLink: 'https://thespielplatz.com/tip-jar',
316
+ version: undefined,
317
+ versionLink: undefined,
318
+ versionUnread: false,
319
+ toolOf: false,
320
+ // Left undefined so TspToolOf's own defaults apply — the same pattern as the
321
+ // theme-toggle labels below. Restating them here would be a second source of
322
+ // truth for one string set, which is what this component's header warns
323
+ // against for githubLink.
324
+ toolOfLabel: undefined,
325
+ toolOfName: undefined,
326
+ toolOfLink: undefined,
327
+ labelToLight: undefined,
328
+ labelToDark: undefined,
329
+ ariaLabelToLight: undefined,
330
+ ariaLabelToDark: undefined,
331
+ })
332
+
333
+ const emit = defineEmits<{ 'update:modelValue': [code: string] }>()
334
+
335
+ // A support mail should arrive already saying which service and which version
336
+ // it came from — otherwise that is the first round-trip of every support
337
+ // thread. Subject and body are overridable, but never empty.
338
+ // A bare mailbox only. The address used to be interpolated raw while the
339
+ // subject and body were encoded — so a value carrying `?`, `&` or a comma could
340
+ // add recipients or headers to the mail the user is about to send. It is a
341
+ // prop, and a prop is exactly what a service wires to config.
342
+ const MAILBOX = /^[^\s@,?&]+@[^\s@,?&]+\.[^\s@,?&]+$/
343
+
344
+ const supportHref = computed(() => {
345
+ if (!props.supportEmail || !MAILBOX.test(props.supportEmail)) return undefined
346
+ const app = props.appName ?? 'tsp.tools'
347
+ const subject = props.supportSubject ?? `${app} — Support`
348
+ const body = props.supportBody
349
+ ?? `\n\n—\n${app}${props.version ? ` ${props.version}` : ''}`
350
+ // encodeURIComponent, not URLSearchParams: the latter encodes spaces as "+",
351
+ // which mail clients show literally in the subject line.
352
+ const query = `subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`
353
+ return `mailto:${props.supportEmail}?${query}`
354
+ })
60
355
  </script>
@@ -1,40 +1,71 @@
1
1
  <!--
2
- Marketing/public site footer (ADR 008, finding 2). Frame only: top border,
3
- site-width column, brand block over a wrapping link row. The app supplies the
4
- content; the legal routes and their labels stay with the app.
2
+ Marketing/public site footer (ADR 008, finding 2). Frame only: a top border
3
+ and one centred row of links, with the platform attribution under it.
5
4
 
6
- #brand — logo + wordmark (+ tagline)
7
- #links — inline links, muted
8
- #actions pushed to the right on ≥sm, e.g. a language toggle
5
+ #brand — optional mark/wordmark above the row
6
+ #links — inline links, muted. Give them icons; the row is compact and an
7
+ icon carries a short label better than a word alone at this size.
8
+ #actions — trailing items on the same row, e.g. <TspVersionBadge>
9
+
10
+ COMPACT AND CENTRED. It used to be a tall left-aligned block with the brand
11
+ stacked over a link row — a lot of vertical space at the end of a page that
12
+ has already said everything. One centred row reads as a footer rather than as
13
+ a final section, and matches what stash runs in production.
9
14
 
10
15
  The link row renders only when #links or #actions is filled.
16
+
17
+ `tool-of` adds the “A tool of tsp.tools” line below it — the same component
18
+ the sidebar footer uses, so the two attributions cannot drift.
11
19
  -->
12
20
  <template>
13
- <footer class="border-t border-muted pt-9 pb-12">
21
+ <footer class="border-t border-muted pt-6 pb-8">
14
22
  <TspContainer
15
23
  width="site"
16
24
  :padded="false"
17
25
  >
18
26
  <div
19
27
  v-if="$slots.brand"
20
- class="flex items-center gap-3"
28
+ class="flex items-center justify-center gap-3 mb-5"
21
29
  >
22
30
  <slot name="brand" />
23
31
  </div>
24
32
 
25
33
  <div
26
34
  v-if="$slots.links || $slots.actions"
27
- class="mt-6 flex flex-wrap items-center gap-x-6 gap-y-2.5 text-sm text-muted"
35
+ class="flex flex-wrap items-center justify-center gap-x-6 gap-y-3 text-sm text-muted"
28
36
  >
29
37
  <slot name="links" />
30
-
31
- <div
32
- v-if="$slots.actions"
33
- class="sm:ml-auto"
34
- >
35
- <slot name="actions" />
36
- </div>
38
+ <slot name="actions" />
37
39
  </div>
40
+
41
+ <TspToolOf
42
+ v-if="toolOf"
43
+ class="mt-4 text-center"
44
+ :label="toolOfLabel"
45
+ :name="toolOfName"
46
+ :link="toolOfLink"
47
+ />
38
48
  </TspContainer>
39
49
  </footer>
40
50
  </template>
51
+
52
+ <script setup lang="ts">
53
+ withDefaults(defineProps<{
54
+ /** The “A tool of tsp.tools” line. Off by default; only some services carry it. */
55
+ toolOf?: boolean
56
+ /** Leading words of the attribution. Left undefined so TspToolOf owns the default. */
57
+ toolOfLabel?: string
58
+ /** The platform name in it. Same reason. */
59
+ toolOfName?: string
60
+ /** Where that name points. Same reason. */
61
+ toolOfLink?: string
62
+ }>(), {
63
+ toolOf: false,
64
+ // Left undefined so TspToolOf's own defaults apply. Restating them here would
65
+ // make this a second source of truth for one string set, which is how the two
66
+ // footers' attributions would quietly drift apart.
67
+ toolOfLabel: undefined,
68
+ toolOfName: undefined,
69
+ toolOfLink: undefined,
70
+ })
71
+ </script>
@@ -10,13 +10,25 @@
10
10
  #actions — right-hand cluster, e.g. <TspThemeToggle> + a CTA
11
11
 
12
12
  Pass `:sticky="false"` for a header that scrolls away.
13
+
14
+ `bare` drops the bar entirely — no border, no background, not sticky — leaving
15
+ just the controls floating over the page. That is what a LANDING page wants:
16
+ the brand is already the hero, so repeating it in a header bar says the same
17
+ thing twice and puts a line across the top of an otherwise open page. A
18
+ logged-out *content* page still wants the real bar, because there the brand is
19
+ the only way back.
13
20
  -->
14
21
  <template>
15
- <header :class="['border-b border-muted bg-default/90 backdrop-blur', sticky ? 'sticky top-0 z-50' : '']">
22
+ <header
23
+ :class="[
24
+ bare ? '' : 'border-b border-muted bg-default/90 backdrop-blur',
25
+ sticky && !bare ? 'sticky top-0 z-50' : '',
26
+ ]"
27
+ >
16
28
  <TspContainer
17
29
  width="site"
18
30
  :padded="false"
19
- class="flex h-18 items-center"
31
+ :class="['flex items-center', bare ? 'h-16' : 'h-18']"
20
32
  >
21
33
  <slot name="brand" />
22
34
 
@@ -39,8 +51,12 @@
39
51
 
40
52
  <script setup lang="ts">
41
53
  withDefaults(defineProps<{
54
+ /** Keep the bar at the top of the viewport. Ignored when `bare`. */
42
55
  sticky?: boolean
56
+ /** No bar: no border, no background, not sticky. For landing pages. */
57
+ bare?: boolean
43
58
  }>(), {
44
59
  sticky: true,
60
+ bare: false,
45
61
  })
46
62
  </script>
@@ -1,9 +1,17 @@
1
1
  <!--
2
- Light/dark toggle. Flips the remembered preference (useTspColorMode); the
2
+ Light/dark toggle. Shares the 32px row shape of everything else in the
3
+ sidebar footer (spec 03 — it used to be text-xs/px-1 against the rows'
4
+ text-sm/px-3, which is the mismatch spec 01 shipped knowingly).
5
+
6
+ Flips the remembered preference (useTspColorMode); the
3
7
  colour-mode plugin applies it. Default content is an icon + label; override via
4
8
  the default slot (receives `{ isDark, label }`). The button is unstyled-ish so it
5
9
  fits the sidebar footer or a top bar — pass classes to taste.
6
10
 
11
+ `icon-only` drops the label for tight places — the sidebar's meta row, and the
12
+ landing-page header, which wants just a sun/moon. The aria-label stays either
13
+ way: an icon-only control is only as good as its label.
14
+
7
15
  All four strings are props so a bilingual site can pass its own (ADR 008,
8
16
  finding 4). Naming is by *destination*: `…ToLight` is what the button shows and
9
17
  announces while dark, because pressing it switches to light.
@@ -18,14 +26,17 @@
18
26
  type="button"
19
27
  data-testid="tsp-theme-toggle"
20
28
  :aria-label="isDark ? ariaLabelToLight : ariaLabelToDark"
21
- class="flex items-center gap-2 px-1 py-1.5 rounded-md text-xs text-muted hover:text-highlighted hover:bg-default"
29
+ :class="iconOnly
30
+ ? 'flex items-center p-1 rounded-md text-muted hover:text-highlighted hover:bg-default'
31
+ : 'flex items-center gap-2.5 h-8 px-3 rounded-md text-sm text-muted hover:text-highlighted hover:bg-default'"
22
32
  @click="toggle"
23
33
  >
24
34
  <UIcon
25
35
  :name="isDark ? 'i-tabler-sun' : 'i-tabler-moon'"
26
- class="text-base"
36
+ :class="iconOnly ? 'text-lg block' : 'text-base'"
27
37
  />
28
38
  <slot
39
+ v-if="!iconOnly"
29
40
  :is-dark="isDark"
30
41
  :label="label"
31
42
  >{{ label }}</slot>
@@ -42,11 +53,19 @@ const props = withDefaults(defineProps<{
42
53
  ariaLabelToLight?: string
43
54
  /** aria-label while light. */
44
55
  ariaLabelToDark?: string
56
+ /** Drop the visible label and render a compact icon button. */
57
+ iconOnly?: boolean
45
58
  }>(), {
46
- labelToLight: 'Light mode',
47
- labelToDark: 'Dark mode',
59
+ // Just "Light" / "Dark": the word "mode" earns nothing next to a sun icon,
60
+ // and the label shares a 224px row with the language control — at "Light
61
+ // mode" the two sat flush with a pixel to spare, and a longer translation
62
+ // overflowed. The ARIA labels keep the full phrasing, where the extra words
63
+ // do work.
64
+ labelToLight: 'Light',
65
+ labelToDark: 'Dark',
48
66
  ariaLabelToLight: 'Switch to light mode',
49
67
  ariaLabelToDark: 'Switch to dark mode',
68
+ iconOnly: false,
50
69
  })
51
70
 
52
71
  const { pref, toggle } = useTspColorMode()
@@ -0,0 +1,42 @@
1
+ <!--
2
+ “A tool of tsp.tools” — the platform attribution line.
3
+
4
+ One component because it appears in BOTH footers: the sidebar's and the
5
+ landing page's (spec 03). Writing it twice is how the two drift apart.
6
+
7
+ Only the leading words translate (`label`, German "Ein Tool von"). The domain
8
+ never does, and the link is always ours — so an app never hand-rolls markup to
9
+ translate a sentence that contains a link, which is the thing this layer
10
+ exists to prevent. This works because DE and EN both put the domain last; a
11
+ language that needs it mid-sentence would need a different shape.
12
+
13
+ Not a flex row: it is a sentence and has to stay one inline flow.
14
+ -->
15
+ <template>
16
+ <p
17
+ data-testid="tsp-tool-of"
18
+ class="text-xs text-dimmed"
19
+ >
20
+ {{ label }}&nbsp;<a
21
+ :href="link"
22
+ target="_blank"
23
+ rel="noopener noreferrer"
24
+ class="underline underline-offset-2 hover:text-highlighted"
25
+ >{{ name }}</a>
26
+ </p>
27
+ </template>
28
+
29
+ <script setup lang="ts">
30
+ withDefaults(defineProps<{
31
+ /** The leading words. */
32
+ label?: string
33
+ /** The platform, not the service. A service never names itself here. */
34
+ name?: string
35
+ /** Where the name points. */
36
+ link?: string
37
+ }>(), {
38
+ label: 'A tool of',
39
+ name: 'tsp.tools',
40
+ link: 'https://tsp.tools',
41
+ })
42
+ </script>