Add inline glossary tooltips across the deck

Self-contained GlossaryTerm (dotted-underline, hover + tap, with an
optional "learn more" link) and GlossedText that wraps glossary hits in
any string. Wired into every slide subtitle via SlideFrame, plus the
entity detail, ecosystem and tech card bodies where the jargon lives.

Deliberately not portaled — the deck stage uses transforms + overflow,
so a portaled popover would escape the themed .dark tree and lose its
tokens; the card floats inline just below the term instead.

TechSlide also surfaces the self-hosting line here (reviewer found it
the clearest idea but it was buried on the building slide). Registers
the Server icon for it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Patrick Mulligan 2026-08-12 20:23:48 +02:00
commit 1ee4d64cb1
7 changed files with 138 additions and 8 deletions

View file

@ -2,6 +2,7 @@
import SlideFrame from './SlideFrame.vue' import SlideFrame from './SlideFrame.vue'
import Icon from './Icon.vue' import Icon from './Icon.vue'
import PlaceholderBadge from './PlaceholderBadge.vue' import PlaceholderBadge from './PlaceholderBadge.vue'
import GlossedText from './GlossedText.vue'
import { accentText, accentRing, accentDot, type Entity } from '../data' import { accentText, accentRing, accentDot, type Entity } from '../data'
const props = defineProps<{ entity: Entity }>() const props = defineProps<{ entity: Entity }>()
@ -21,7 +22,7 @@ const a = props.entity.accent
<span v-if="entity.example" class="text-sm text-fg-muted">e.g. {{ entity.example }}</span> <span v-if="entity.example" class="text-sm text-fg-muted">e.g. {{ entity.example }}</span>
<PlaceholderBadge label="name" /> <PlaceholderBadge label="name" />
</div> </div>
<p class="mt-4 max-w-2xl text-lg text-fg-muted">{{ entity.blurb }}</p> <p class="mt-4 max-w-2xl text-lg text-fg-muted"><GlossedText :text="entity.blurb" /></p>
<div class="mt-8 grid gap-6 lg:grid-cols-3"> <div class="mt-8 grid gap-6 lg:grid-cols-3">
<!-- unique traits --> <!-- unique traits -->
@ -34,7 +35,7 @@ const a = props.entity.accent
<span class="mt-0.5 grid size-5 shrink-0 place-items-center rounded-full" :class="accentRing[a]"> <span class="mt-0.5 grid size-5 shrink-0 place-items-center rounded-full" :class="accentRing[a]">
<Icon name="Check" class="size-3" :class="accentText[a]" /> <Icon name="Check" class="size-3" :class="accentText[a]" />
</span> </span>
{{ t }} <GlossedText :text="t" />
</li> </li>
</ul> </ul>
</div> </div>
@ -49,7 +50,7 @@ const a = props.entity.accent
class="flex items-center gap-2 rounded-lg bg-surface px-3 py-2 text-sm text-fg" class="flex items-center gap-2 rounded-lg bg-surface px-3 py-2 text-sm text-fg"
> >
<span class="size-1.5 rounded-full" :class="accentDot[a]" /> <span class="size-1.5 rounded-full" :class="accentDot[a]" />
{{ s }} <GlossedText :text="s" />
</div> </div>
</div> </div>
</div> </div>
@ -69,7 +70,7 @@ const a = props.entity.accent
</div> </div>
<div class="flex items-center gap-2 rounded-2xl border border-line bg-surface px-4 py-3 text-xs text-fg-muted"> <div class="flex items-center gap-2 rounded-2xl border border-line bg-surface px-4 py-3 text-xs text-fg-muted">
<Icon name="Layers" class="size-4 text-amber" /> <Icon name="Layers" class="size-4 text-amber" />
Runs on Lab 484 same membership, kiosk &amp; app as every node. <GlossedText text="Runs on Lab 484 — same membership, kiosk & app as every node." />
</div> </div>
</div> </div>
</div> </div>

View file

@ -0,0 +1,96 @@
<script setup lang="ts">
/**
* An inline glossary term: dotted-underline trigger that reveals a one-line
* definition (and an optional "learn more" link) on hover or tap.
*
* Deliberately dependency-free the deck stage uses transforms + overflow, so
* a portaled popover would escape the themed `.dark` tree and lose its tokens.
* We render the card inline instead and let it float just below the term.
*/
import { ref, onBeforeUnmount } from 'vue'
import { ExternalLink } from '@lucide/vue'
import type { GlossaryEntry } from '../data'
defineProps<{ entry: GlossaryEntry }>()
const open = ref(false)
const root = ref<HTMLElement | null>(null)
let hoverTimer: ReturnType<typeof setTimeout> | undefined
function show() {
clearTimeout(hoverTimer)
open.value = true
}
function hideSoon() {
// small grace period so the reader can travel from term to card
hoverTimer = setTimeout(() => (open.value = false), 120)
}
function toggle() {
open.value = !open.value
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') open.value = false
}
// close when tapping elsewhere (mobile has no hover-out)
function onDocPointer(e: PointerEvent) {
if (open.value && root.value && !root.value.contains(e.target as Node)) open.value = false
}
if (typeof document !== 'undefined') document.addEventListener('pointerdown', onDocPointer)
onBeforeUnmount(() => {
clearTimeout(hoverTimer)
if (typeof document !== 'undefined') document.removeEventListener('pointerdown', onDocPointer)
})
</script>
<template>
<span ref="root" class="relative inline-block" @mouseenter="show" @mouseleave="hideSoon">
<button
type="button"
class="cursor-help font-medium text-fg underline decoration-amber-400/70 decoration-dotted underline-offset-4 transition hover:decoration-amber-400"
:aria-expanded="open"
@click="toggle"
@keydown="onKey"
>
<slot>{{ entry.term }}</slot>
</button>
<Transition name="gloss">
<span
v-if="open"
class="absolute left-1/2 top-full z-30 mt-2 block w-64 -translate-x-1/2 cursor-auto rounded-xl border border-line-2 bg-surface-2 p-3 text-left text-sm font-normal normal-case leading-relaxed text-fg-muted shadow-xl"
@mouseenter="show"
@mouseleave="hideSoon"
>
<span class="mb-1 block font-display text-xs font-semibold uppercase tracking-wider text-amber">
{{ entry.term }}
</span>
{{ entry.short }}
<a
v-if="entry.more"
:href="entry.more.href"
target="_blank"
rel="noopener noreferrer"
class="mt-2 inline-flex items-center gap-1 text-xs font-medium text-indigo transition hover:text-indigo-300"
>
{{ entry.more.label }}
<ExternalLink class="size-3" />
</a>
</span>
</Transition>
</span>
</template>
<style scoped>
.gloss-enter-active,
.gloss-leave-active {
transition:
opacity 0.15s ease,
transform 0.15s ease;
}
.gloss-enter-from,
.gloss-leave-to {
opacity: 0;
transform: translate(-50%, -4px);
}
</style>

View file

@ -0,0 +1,19 @@
<script setup lang="ts">
/**
* Renders a string with any glossary terms turned into inline tooltips.
* Drop-in for plain interpolation: `<GlossedText :text="subtitle" />`.
*/
import GlossaryTerm from './GlossaryTerm.vue'
import { glossedParts } from '../data'
defineProps<{ text: string }>()
</script>
<template>
<span
><template v-for="p in glossedParts(text)" :key="p.id"
><GlossaryTerm v-if="p.entry" :entry="p.entry">{{ p.text }}</GlossaryTerm
><template v-else>{{ p.text }}</template></template
></span
>
</template>

View file

@ -32,6 +32,7 @@ import {
ChevronRight, ChevronRight,
Images, Images,
Expand, Expand,
Server,
type LucideProps, type LucideProps,
} from '@lucide/vue' } from '@lucide/vue'
import type { FunctionalComponent } from 'vue' import type { FunctionalComponent } from 'vue'
@ -68,6 +69,7 @@ const registry: Record<string, FunctionalComponent<LucideProps>> = {
ChevronRight, ChevronRight,
Images, Images,
Expand, Expand,
Server,
} }
const props = defineProps<{ name: string }>() const props = defineProps<{ name: string }>()

View file

@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
/** Shared slide chrome: consistent padding, kicker, title, subtitle + body slot. */ /** Shared slide chrome: consistent padding, kicker, title, subtitle + body slot. */
import { brandCaseParts } from '../data' import { brandCaseParts } from '../data'
import GlossedText from './GlossedText.vue'
defineProps<{ defineProps<{
kicker?: string kicker?: string
@ -32,7 +33,7 @@ defineProps<{
class="mt-4 max-w-2xl text-base text-fg-muted sm:text-lg" class="mt-4 max-w-2xl text-base text-fg-muted sm:text-lg"
:class="center ? 'mx-auto' : ''" :class="center ? 'mx-auto' : ''"
> >
{{ subtitle }} <GlossedText :text="subtitle" />
</p> </p>
<div :class="[title || kicker ? 'mt-10' : '', 'w-full']"> <div :class="[title || kicker ? 'mt-10' : '', 'w-full']">
<slot /> <slot />

View file

@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import SlideFrame from '../components/SlideFrame.vue' import SlideFrame from '../components/SlideFrame.vue'
import Icon from '../components/Icon.vue' import Icon from '../components/Icon.vue'
import GlossedText from '../components/GlossedText.vue'
import { userEcosystem, surfaceEntities as entities } from '../data' import { userEcosystem, surfaceEntities as entities } from '../data'
</script> </script>
@ -20,7 +21,7 @@ import { userEcosystem, surfaceEntities as entities } from '../data'
<Icon :name="item.icon" class="size-5" /> <Icon :name="item.icon" class="size-5" />
</span> </span>
<h3 class="mt-4 font-display text-lg font-semibold text-fg">{{ item.title }}</h3> <h3 class="mt-4 font-display text-lg font-semibold text-fg">{{ item.title }}</h3>
<p class="mt-1.5 text-sm leading-relaxed text-fg-muted">{{ item.body }}</p> <p class="mt-1.5 text-sm leading-relaxed text-fg-muted"><GlossedText :text="item.body" /></p>
</div> </div>
</div> </div>

View file

@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import SlideFrame from '../components/SlideFrame.vue' import SlideFrame from '../components/SlideFrame.vue'
import Icon from '../components/Icon.vue' import Icon from '../components/Icon.vue'
import GlossedText from '../components/GlossedText.vue'
import { tech } from '../data' import { tech } from '../data'
</script> </script>
@ -8,7 +9,7 @@ import { tech } from '../data'
<SlideFrame <SlideFrame
kicker="Lab 484 · the shared backbone" kicker="Lab 484 · the shared backbone"
title="Open hardware, sovereign software" title="Open hardware, sovereign software"
subtitle="Lab 484 is the stack under every entity — owned by the network, documented to be rebuilt anywhere. Build it once; every place plugs in." subtitle="Lab 484 is the stack under every entity — the shared hardware + software every location runs on, owned by the network and documented to be rebuilt anywhere. Build the stack once; every place plugs in."
> >
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div <div
@ -20,8 +21,17 @@ import { tech } from '../data'
<Icon :name="t.icon" class="size-5" /> <Icon :name="t.icon" class="size-5" />
</span> </span>
<h3 class="mt-4 font-display text-lg font-semibold text-fg">{{ t.title }}</h3> <h3 class="mt-4 font-display text-lg font-semibold text-fg">{{ t.title }}</h3>
<p class="mt-1.5 text-sm leading-relaxed text-fg-muted">{{ t.body }}</p> <p class="mt-1.5 text-sm leading-relaxed text-fg-muted"><GlossedText :text="t.body" /></p>
</div> </div>
</div> </div>
<!-- self-hosting: surfaced here (reviewer found this the clearest idea, buried on the building slide) -->
<div class="mt-5 flex items-start gap-3 rounded-2xl border border-pine-500/20 bg-pine-500/5 p-5 text-sm text-fg">
<Icon name="Server" class="mt-0.5 size-5 shrink-0 text-pine" />
<span>
Every node <span class="font-semibold text-pine">self-hosts</span> the stack runs on-site,
on the networks own hardware, not in someone elses cloud.
</span>
</div>
</SlideFrame> </SlideFrame>
</template> </template>