@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.
- package/CHANGELOG.md +114 -0
- package/README.md +147 -32
- package/assets/css/theme.css +86 -6
- package/components/TspBanner.vue +97 -0
- package/components/TspBrandTile.vue +46 -0
- package/components/TspCodeBlock.vue +68 -0
- package/components/TspContainer.vue +10 -2
- package/components/TspCopyButton.vue +40 -0
- package/components/TspCopyField.vue +102 -0
- package/components/TspFooterRow.vue +56 -0
- package/components/TspLanguageToggle.vue +75 -0
- package/components/TspNavBadge.vue +36 -0
- package/components/TspNavGroup.vue +58 -0
- package/components/TspNavItem.vue +68 -12
- package/components/TspNavLabel.vue +56 -0
- package/components/TspNavSection.vue +33 -0
- package/components/TspNavSubItem.vue +35 -0
- package/components/TspPageTabs.vue +46 -0
- package/components/TspReleaseNotes.vue +143 -0
- package/components/TspSectionCard.vue +52 -0
- package/components/TspSidebar.vue +96 -3
- package/components/TspSidebarFooter.vue +332 -37
- package/components/TspSiteFooter.vue +47 -16
- package/components/TspSiteHeader.vue +18 -2
- package/components/TspThemeToggle.vue +24 -5
- package/components/TspToolOf.vue +42 -0
- package/components/TspTopBar.vue +69 -0
- package/components/TspUserCard.vue +127 -0
- package/components/TspVersionBadge.vue +49 -0
- package/components/TspWordmark.vue +36 -4
- package/composables/useTspCopy.ts +72 -0
- package/composables/useTspNavDrawer.ts +14 -0
- package/composables/useTspReveal.ts +18 -0
- package/docs/api.md +394 -0
- package/docs/design-system.md +297 -0
- package/nuxt.config.ts +11 -0
- package/package.json +24 -11
- package/page-meta.d.ts +11 -0
- 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
|
|
3
|
-
|
|
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
|
|
7
|
-
|
|
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
|
+
}
|