@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,69 @@
1
+ <!--
2
+ Mobile top bar — only below `sm`, where the sidebar has become a drawer.
3
+
4
+ burger · mark + wordmark · page title
5
+
6
+ Sticky: on mobile this bar is the only way to reach the nav, so it stays put
7
+ for the same reason the sidebar does on desktop.
8
+
9
+ THE BURGER IS ON THE LEFT, where the drawer comes from. stash puts it right;
10
+ the platform's own mockup and the other tsp services put it left, and a
11
+ control that opens a left drawer belongs on the left. stash is the outlier.
12
+
13
+ The page title matters more here than anywhere else: on mobile the sidebar is
14
+ hidden, so without it nothing on screen says where you are.
15
+ -->
16
+ <template>
17
+ <header
18
+ data-testid="tsp-top-bar"
19
+ class="sm:hidden sticky top-0 z-30 flex items-center gap-3 h-14 px-4 border-b border-default bg-muted"
20
+ >
21
+ <button
22
+ type="button"
23
+ data-testid="tsp-burger"
24
+ :aria-label="menuLabel"
25
+ :aria-expanded="isOpen"
26
+ aria-controls="tsp-sidebar"
27
+ class="flex size-9 shrink-0 items-center justify-center rounded-md border border-default text-muted hover:text-highlighted"
28
+ @click="toggle"
29
+ >
30
+ <UIcon
31
+ name="i-tabler-menu-2"
32
+ class="text-xl"
33
+ />
34
+ </button>
35
+
36
+ <slot name="brand" />
37
+
38
+ <span
39
+ v-if="pageTitle"
40
+ data-testid="tsp-top-bar-title"
41
+ class="ml-auto truncate text-sm font-bold text-highlighted"
42
+ >{{ pageTitle }}</span>
43
+ </header>
44
+ </template>
45
+
46
+ <script setup lang="ts">
47
+ const props = withDefaults(defineProps<{
48
+ /** The current page's name. Nothing else on a mobile screen says where you are.
49
+ Omitted, it is derived from the route. */
50
+ title?: string
51
+ /** Accessible name for the burger button. */
52
+ menuLabel?: string
53
+ }>(), {
54
+ title: undefined,
55
+ menuLabel: 'Open menu',
56
+ })
57
+
58
+ const { isOpen, toggle } = useTspNavDrawer()
59
+ const route = useRoute()
60
+
61
+ // Falls back to the route when no title is passed: `meta.title` if the page set
62
+ // one, else the last path segment capitalised. Both demo apps had derived this
63
+ // identically in their own layouts, which is a sign it belongs to the component
64
+ // that renders it. An app with better titles passes `title` and this never runs.
65
+ const pageTitle = computed(() => props.title
66
+ ?? (route.meta.title as string | undefined)
67
+ ?? route.path.split('/').filter(Boolean).pop()?.replace(/^\w/, c => c.toUpperCase())
68
+ ?? undefined)
69
+ </script>
@@ -0,0 +1,127 @@
1
+ <!--
2
+ The account card on a service's user page — rebuilt from stash, which already
3
+ runs this layout, so the layer now owns it instead of each service copying it
4
+ (ADR 017: the shell lives here).
5
+
6
+ <TspUserCard
7
+ name="Fil"
8
+ :account-id="user.id"
9
+ manage-link="https://auth.tsp.tools/account"
10
+ />
11
+
12
+ The layer has no auth and no data: the name, the id and the manage URL are the
13
+ app's. What lives here is the arrangement — amber mark, name over the account
14
+ line, the labelled id with its reveal, and the manage link.
15
+
16
+ MASKING IS REAL, NOT COSMETIC. While hidden, the id is simply not rendered;
17
+ the dots are a placeholder string. Covering the value with CSS would leave it
18
+ in the page source, copyable and readable by anything walking the DOM, which
19
+ is not masking — it just looks like it.
20
+
21
+ Every visible string is a prop with an English default (ADR 008, finding 4).
22
+
23
+ IT DOES NOT CAP ITS OWN WIDTH. It used to carry `max-w-lg`, which left the
24
+ user page 512px wide inside a 1152px column while every other page ran the
25
+ full measure. Page width belongs to <TspContainer>; a component that caps its
26
+ own root overrides it from the inside, and the app cannot undo it without
27
+ fighting tailwind-merge. A service that wants it narrower wraps it.
28
+ -->
29
+ <template>
30
+ <div
31
+ data-testid="tsp-user-card"
32
+ class="rounded-xl bg-elevated border border-default p-6"
33
+ >
34
+ <div class="flex items-center gap-4">
35
+ <UIcon
36
+ name="i-tabler-user-circle"
37
+ class="text-4xl shrink-0 tsp-text-brand"
38
+ />
39
+ <div class="min-w-0">
40
+ <p
41
+ data-testid="tsp-user-card-name"
42
+ class="font-bold text-highlighted truncate"
43
+ >
44
+ {{ name }}
45
+ </p>
46
+ <p
47
+ data-testid="tsp-user-card-account"
48
+ class="text-xs text-muted truncate"
49
+ >
50
+ {{ accountLabel }}
51
+ </p>
52
+ </div>
53
+ </div>
54
+
55
+ <template v-if="accountId">
56
+ <p class="mt-6 text-xs font-bold text-toned">
57
+ {{ idLabel }}
58
+ </p>
59
+ <div class="mt-1 flex items-center gap-2">
60
+ <code
61
+ data-testid="tsp-user-card-id"
62
+ class="text-sm text-muted tracking-wider"
63
+ >{{ revealed ? accountId : mask }}</code>
64
+ <button
65
+ type="button"
66
+ data-testid="tsp-user-card-reveal"
67
+ :aria-pressed="revealed"
68
+ :aria-label="revealed ? hideLabel : revealLabel"
69
+ :title="revealed ? hideLabel : revealLabel"
70
+ class="rounded-md p-1 text-muted hover:text-highlighted hover:bg-default"
71
+ @click="toggle"
72
+ >
73
+ <UIcon
74
+ :name="revealed ? 'i-tabler-eye-off' : 'i-tabler-eye'"
75
+ class="text-base block"
76
+ />
77
+ </button>
78
+ </div>
79
+ </template>
80
+
81
+ <a
82
+ v-if="manageLink"
83
+ data-testid="tsp-user-card-manage"
84
+ :href="manageLink"
85
+ target="_blank"
86
+ rel="noopener noreferrer"
87
+ class="mt-6 inline-flex items-center gap-1.5 text-sm font-bold tsp-text-brand hover:underline"
88
+ >
89
+ {{ manageLabel }}
90
+ <UIcon
91
+ name="i-tabler-external-link"
92
+ class="text-base"
93
+ />
94
+ </a>
95
+ </div>
96
+ </template>
97
+
98
+ <script setup lang="ts">
99
+ withDefaults(defineProps<{
100
+ /** Display name. */
101
+ name: string
102
+ /** Which account this is — shown under the name. */
103
+ accountLabel?: string
104
+ /** The account id. Omit to hide the whole id block. */
105
+ accountId?: string
106
+ /** Label over the account id. */
107
+ idLabel?: string
108
+ /** Where “manage account” goes. Omit to hide the link. */
109
+ manageLink?: string
110
+ /** Text of the manage-account link. */
111
+ manageLabel?: string
112
+ /** Accessible name for the eye while the id is hidden. */
113
+ revealLabel?: string
114
+ /** Accessible name for it while the id is shown. */
115
+ hideLabel?: string
116
+ }>(), {
117
+ accountLabel: 'tsp.tools account',
118
+ accountId: undefined,
119
+ idLabel: 'tsp.tools ID',
120
+ manageLink: undefined,
121
+ manageLabel: 'Manage account in Logto',
122
+ revealLabel: 'Show ID',
123
+ hideLabel: 'Hide ID',
124
+ })
125
+
126
+ const { revealed, toggle, mask } = useTspReveal(12)
127
+ </script>
@@ -0,0 +1,49 @@
1
+ <!--
2
+ The version pill in the sidebar footer. A link when `href` is given, plain
3
+ text otherwise (spec 03) — a closed-source project has no release page to
4
+ point at, so its badge must not look clickable.
5
+
6
+ Carries a tag icon (spec 05 item 3), which is also what tells it apart from
7
+ the role pills now that both are outlined rounded-full shapes.
8
+
9
+ `unread` marks that there are release notes the user has not seen — the
10
+ tsp-release skill writes those notes, and the badge is where a user looks for
11
+ the version anyway, so it is the honest place to say "this changed". It goes
12
+ amber TEXT with an amber edge, not an amber fill: a filled pill in the sidebar
13
+ is what ADR 018 removed. Whether a release counts as unread is the app's
14
+ question, not the layer's.
15
+
16
+ Kept as its own component because it appears in two places: beside the theme
17
+ toggle when the project is closed source, and on the GitHub row when it is
18
+ open source.
19
+ -->
20
+ <template>
21
+ <component
22
+ :is="href ? 'a' : 'span'"
23
+ data-testid="tsp-version-badge"
24
+ v-bind="href ? { href, target: '_blank', rel: 'noopener noreferrer' } : {}"
25
+ :data-unread="unread ? 'true' : undefined"
26
+ class="inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs"
27
+ :class="[
28
+ unread ? 'tsp-text-brand border-current' : 'border-default text-muted',
29
+ href ? 'hover:text-highlighted hover:border-accented' : '',
30
+ ]"
31
+ >
32
+ <UIcon
33
+ name="i-tabler-tag"
34
+ class="text-xs shrink-0"
35
+ />
36
+ {{ version }}
37
+ </component>
38
+ </template>
39
+
40
+ <script setup lang="ts">
41
+ defineProps<{
42
+ /** The version string, shown as-is, e.g. `v0.3.0`. */
43
+ version: string
44
+ /** Release link. Omitted for closed-source projects, which renders a span. */
45
+ href?: string
46
+ /** There are release notes the user has not seen. */
47
+ unread?: boolean
48
+ }>()
49
+ </script>
@@ -1,9 +1,41 @@
1
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>.
2
+ Brand wordmark: the app's name in white, terminated by an amber dot.
3
+
4
+ <TspWordmark name="trips" /> → trips.
5
+ <TspWordmark name="gage" /> → gage.
6
+
7
+ THE COMPONENT OWNS THE DOT (spec 03). There is no slot: the one case that
8
+ argued for one was `tsp.tools`, and that turned out not to be a wordmark at
9
+ all — it is the domain of the whole tool set, not a tool's name. Keeping an
10
+ escape hatch for a case nobody has would only let the old mid-dot form
11
+ (`tsp` · amber `.` · `tools`) back in.
12
+
13
+ Before 0.3.0 the root carried `text-primary`, so the WHOLE mark rendered amber
14
+ and the amber-dot span this component's own docs prescribed was a no-op. The
15
+ documented format was never actually possible.
16
+
17
+ The dot uses `.tsp-text-brand`, not `text-primary`, so it steps down to
18
+ amber-600 on light surfaces where #fbad18 fails contrast — same treatment as
19
+ the active nav item (ADR 018).
4
20
  -->
5
21
  <template>
6
- <div class="tsp-wordmark font-bold text-xl tracking-tight text-primary">
7
- <slot />
22
+ <div
23
+ data-testid="tsp-wordmark"
24
+ class="tsp-wordmark font-bold text-xl tracking-tight"
25
+ >
26
+ <span
27
+ data-testid="tsp-wordmark-name"
28
+ class="text-highlighted"
29
+ >{{ name }}</span><span
30
+ data-testid="tsp-wordmark-dot"
31
+ class="tsp-text-brand"
32
+ >.</span>
8
33
  </div>
9
34
  </template>
35
+
36
+ <script setup lang="ts">
37
+ defineProps<{
38
+ /** The app name, lowercase. The amber dot is appended for you. */
39
+ name: string
40
+ }>()
41
+ </script>
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Copy text to the clipboard and confirm it for a moment.
3
+ *
4
+ * `navigator.clipboard` needs a SECURE CONTEXT. It is absent over plain HTTP on
5
+ * a LAN address, which is exactly how a service gets tested on a phone — so the
6
+ * fallback keeps the button working there instead of failing silently.
7
+ *
8
+ * `copied` returns to false on its own; a copy that gives no feedback gets
9
+ * pressed twice.
10
+ */
11
+ export const useTspCopy = (resetDelay = 1600) => {
12
+ const copied = ref(false)
13
+ let timer: ReturnType<typeof setTimeout> | undefined
14
+ let alive = true
15
+
16
+ const legacyCopy = (text: string) => {
17
+ const ta = document.createElement('textarea')
18
+ ta.value = text
19
+ // readonly stops a mobile keyboard appearing for the split second the
20
+ // textarea is focused; inert/aria-hidden keep it out of the tab order and
21
+ // off the accessibility tree while it briefly exists.
22
+ ta.setAttribute('readonly', '')
23
+ ta.setAttribute('aria-hidden', 'true')
24
+ ta.tabIndex = -1
25
+ ta.style.cssText = 'position:fixed;top:-9999px;opacity:0'
26
+ document.body.appendChild(ta)
27
+ try {
28
+ ta.select()
29
+ // execCommand returns FALSE without throwing when the copy is refused —
30
+ // which is precisely the plain-HTTP case this fallback exists for. The
31
+ // return value used to be discarded, so the tick appeared while the
32
+ // clipboard was untouched.
33
+ return document.execCommand('copy')
34
+ }
35
+ finally {
36
+ // `finally`, not the happy path: a throw here used to leave a textarea
37
+ // holding the real secret in the DOM for the rest of the session —
38
+ // CSS-covered and readable by anything walking it, which is exactly what
39
+ // this layer's masking rule says is not masking.
40
+ ta.value = ''
41
+ ta.remove()
42
+ }
43
+ }
44
+
45
+ const copy = async (text: string) => {
46
+ try {
47
+ if (navigator.clipboard?.writeText) {
48
+ await navigator.clipboard.writeText(text)
49
+ }
50
+ else if (!legacyCopy(text)) {
51
+ copied.value = false
52
+ return
53
+ }
54
+ // The await above can resolve after the component is gone; scheduling a
55
+ // timer then would outlive the cleanup that was supposed to clear it.
56
+ if (!alive) return
57
+ copied.value = true
58
+ clearTimeout(timer)
59
+ timer = setTimeout(() => { copied.value = false }, resetDelay)
60
+ }
61
+ catch {
62
+ copied.value = false
63
+ }
64
+ }
65
+
66
+ onUnmounted(() => {
67
+ alive = false
68
+ clearTimeout(timer)
69
+ })
70
+
71
+ return { copied, copy }
72
+ }
@@ -0,0 +1,14 @@
1
+ // Shared open/closed state for the mobile nav drawer, so TspTopBar's burger and
2
+ // TspSidebar do not have to be wired together by every app.
3
+ //
4
+ // useState, not a module-level ref: on the server a module-level ref is shared
5
+ // across requests, which leaks one visitor's open drawer into another's page.
6
+ export const useTspNavDrawer = () => {
7
+ const isOpen = useState('tsp-nav-drawer', () => false)
8
+
9
+ const open = () => { isOpen.value = true }
10
+ const close = () => { isOpen.value = false }
11
+ const toggle = () => { isOpen.value = !isOpen.value }
12
+
13
+ return { isOpen, open, close, toggle }
14
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Show/hide a secret, with the placeholder to render while it is hidden.
3
+ *
4
+ * MASKING IS REAL, NOT COSMETIC: callers render `mask` INSTEAD of the value,
5
+ * never the value under a CSS cover. A covered value is still in the page
6
+ * source, still copyable, and still readable by anything walking the DOM — that
7
+ * is not masking, it only looks like it.
8
+ *
9
+ * The mask is a FIXED length. Deriving it from the value would leak how long
10
+ * the secret is, which is the sort of detail masking exists to withhold.
11
+ */
12
+ export const useTspReveal = (maskLength = 24) => {
13
+ const revealed = ref(false)
14
+ const toggle = () => { revealed.value = !revealed.value }
15
+ const mask = '•'.repeat(maskLength)
16
+
17
+ return { revealed, toggle, mask }
18
+ }