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:
parent
2fac844863
commit
1ee4d64cb1
7 changed files with 138 additions and 8 deletions
|
|
@ -2,6 +2,7 @@
|
|||
import SlideFrame from './SlideFrame.vue'
|
||||
import Icon from './Icon.vue'
|
||||
import PlaceholderBadge from './PlaceholderBadge.vue'
|
||||
import GlossedText from './GlossedText.vue'
|
||||
import { accentText, accentRing, accentDot, type Entity } from '../data'
|
||||
|
||||
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>
|
||||
<PlaceholderBadge label="name" />
|
||||
</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">
|
||||
<!-- 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]">
|
||||
<Icon name="Check" class="size-3" :class="accentText[a]" />
|
||||
</span>
|
||||
{{ t }}
|
||||
<GlossedText :text="t" />
|
||||
</li>
|
||||
</ul>
|
||||
</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"
|
||||
>
|
||||
<span class="size-1.5 rounded-full" :class="accentDot[a]" />
|
||||
{{ s }}
|
||||
<GlossedText :text="s" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -69,7 +70,7 @@ const a = props.entity.accent
|
|||
</div>
|
||||
<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" />
|
||||
Runs on Lab 484 — same membership, kiosk & app as every node.
|
||||
<GlossedText text="Runs on Lab 484 — same membership, kiosk & app as every node." />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
96
src/deck/components/GlossaryTerm.vue
Normal file
96
src/deck/components/GlossaryTerm.vue
Normal 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>
|
||||
19
src/deck/components/GlossedText.vue
Normal file
19
src/deck/components/GlossedText.vue
Normal 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>
|
||||
|
|
@ -32,6 +32,7 @@ import {
|
|||
ChevronRight,
|
||||
Images,
|
||||
Expand,
|
||||
Server,
|
||||
type LucideProps,
|
||||
} from '@lucide/vue'
|
||||
import type { FunctionalComponent } from 'vue'
|
||||
|
|
@ -68,6 +69,7 @@ const registry: Record<string, FunctionalComponent<LucideProps>> = {
|
|||
ChevronRight,
|
||||
Images,
|
||||
Expand,
|
||||
Server,
|
||||
}
|
||||
|
||||
const props = defineProps<{ name: string }>()
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
/** Shared slide chrome: consistent padding, kicker, title, subtitle + body slot. */
|
||||
import { brandCaseParts } from '../data'
|
||||
import GlossedText from './GlossedText.vue'
|
||||
|
||||
defineProps<{
|
||||
kicker?: string
|
||||
|
|
@ -32,7 +33,7 @@ defineProps<{
|
|||
class="mt-4 max-w-2xl text-base text-fg-muted sm:text-lg"
|
||||
:class="center ? 'mx-auto' : ''"
|
||||
>
|
||||
{{ subtitle }}
|
||||
<GlossedText :text="subtitle" />
|
||||
</p>
|
||||
<div :class="[title || kicker ? 'mt-10' : '', 'w-full']">
|
||||
<slot />
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import SlideFrame from '../components/SlideFrame.vue'
|
||||
import Icon from '../components/Icon.vue'
|
||||
import GlossedText from '../components/GlossedText.vue'
|
||||
import { userEcosystem, surfaceEntities as entities } from '../data'
|
||||
</script>
|
||||
|
||||
|
|
@ -20,7 +21,7 @@ import { userEcosystem, surfaceEntities as entities } from '../data'
|
|||
<Icon :name="item.icon" class="size-5" />
|
||||
</span>
|
||||
<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>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import SlideFrame from '../components/SlideFrame.vue'
|
||||
import Icon from '../components/Icon.vue'
|
||||
import GlossedText from '../components/GlossedText.vue'
|
||||
import { tech } from '../data'
|
||||
</script>
|
||||
|
||||
|
|
@ -8,7 +9,7 @@ import { tech } from '../data'
|
|||
<SlideFrame
|
||||
kicker="Lab 484 · the shared backbone"
|
||||
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
|
||||
|
|
@ -20,8 +21,17 @@ import { tech } from '../data'
|
|||
<Icon :name="t.icon" class="size-5" />
|
||||
</span>
|
||||
<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>
|
||||
|
||||
<!-- 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 network’s own hardware, not in someone else’s cloud.
|
||||
</span>
|
||||
</div>
|
||||
</SlideFrame>
|
||||
</template>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue