Compare commits

..

No commits in common. "abbfcaf3ef8c7ae15b1454e1e5f69cfdb0545417" and "0054f3ab805d8c1146f36f164b19a75ec19cc86d" have entirely different histories.

19 changed files with 2 additions and 1256 deletions

View file

@ -1,8 +0,0 @@
{
"mcpServers": {
"shadcn": {
"command": "npx",
"args": ["shadcn-vue@latest", "mcp"]
}
}
}

View file

@ -1,3 +0,0 @@
allowBuilds:
vue-demi: false
verifyDepsBeforeRun: false

View file

@ -1,33 +0,0 @@
<script setup lang="ts">
import SlideShell from './SlideShell.vue'
export interface Bullet {
/** Optional bold lead-in, rendered in white before the rest of the text. */
lead?: string
text: string
}
defineProps<{
kicker?: string
title: string
bullets: Bullet[]
footnote?: string
}>()
</script>
<template>
<SlideShell :kicker="kicker" :title="title">
<ul class="space-y-5">
<li v-for="(b, i) in bullets" :key="i" class="flex gap-4">
<span class="mt-2 h-2 w-2 flex-none rounded-full bg-violet-500" />
<span>
<span v-if="b.lead" class="mr-1.5 font-semibold text-white">{{ b.lead }}</span>
<span>{{ b.text }}</span>
</span>
</li>
</ul>
<p v-if="footnote" class="mt-10 border-l-2 border-violet-500/50 pl-4 text-base italic text-zinc-400">
{{ footnote }}
</p>
</SlideShell>
</template>

View file

@ -1,170 +0,0 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { PenLine, Hash, KeyRound, ArrowRight } from '@lucide/vue'
import SlideShell from './SlideShell.vue'
// Each field of a NIP-01 event: the key, a sample value, and what it means.
// `derived` fields aren't typed by you they're computed when the event is signed.
interface Field {
key: string
value: string
kind: 'string' | 'number' | 'array'
comment: string
derived?: boolean
}
const fields: Field[] = [
{ key: 'pubkey', value: '"3bf0c6…2a"', kind: 'string', comment: 'who wrote it — your public key' },
{ key: 'created_at', value: '1721600000', kind: 'number', comment: 'when — unix seconds' },
{ key: 'kind', value: '1', kind: 'number', comment: '1 = short text note' },
{ key: 'tags', value: '[["e","…"],["p","…"]]', kind: 'array', comment: 'references: reply-to, mentions' },
{ key: 'content', value: '"gm nostr ⚡"', kind: 'string', comment: 'the message itself' },
{ key: 'id', value: '"0a1b2c…9f"', kind: 'string', comment: 'sha256 of the fields above', derived: true },
{ key: 'sig', value: '"d4e5…c7"', kind: 'string', comment: 'Schnorr sig over id — proof its you', derived: true },
]
const valueClass = { string: 'text-emerald-300', number: 'text-amber-300', array: 'text-zinc-300' }
const steps = [
{ icon: PenLine, label: 'Fill the fields', sub: 'pubkey · kind · tags · content · time' },
{ icon: Hash, label: 'Hash them → id', sub: 'sha256 of the serialized event' },
{ icon: KeyRound, label: 'Sign id → sig', sub: 'Schnorr signature with your nsec' },
]
// Staged reveal: typed fields first, then `id` is hashed in, then `sig` is signed in.
// stage 0 = fields only · 1 = id computed · 2 = sig computed.
const stage = ref(0)
const timers: ReturnType<typeof setTimeout>[] = []
function isRevealed(f: Field): boolean {
if (!f.derived) return true
if (f.key === 'id') return stage.value >= 1
return stage.value >= 2 // sig
}
function clearTimers() {
timers.forEach(clearTimeout)
timers.length = 0
}
// Click a step to jump to (and replay) that state; stops the auto-play so
// manual control wins.
function selectStage(i: number) {
clearTimers()
stage.value = i
}
onMounted(() => {
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches
if (reduce) {
stage.value = 2
return
}
timers.push(setTimeout(() => (stage.value = 1), 900))
timers.push(setTimeout(() => (stage.value = 2), 1800))
})
onBeforeUnmount(clearTimers)
</script>
<template>
<SlideShell kicker="NIP-01 · the atom of Nostr" title="Anatomy of an event">
<!-- The event, as JSON, hugged by big nostr-purple braces -->
<div class="flex items-center justify-center gap-3 sm:gap-6">
<span class="select-none text-[9rem] font-thin leading-none text-violet-500/80 sm:text-[11rem]">{</span>
<div class="grid grid-cols-[auto_auto_1fr] items-center gap-x-5 gap-y-1.5 font-mono text-base sm:text-lg">
<template v-for="(f, i) in fields" :key="f.key">
<span
class="justify-self-end"
:class="f.derived ? 'font-semibold text-violet-300' : 'text-violet-300'"
>
"{{ f.key }}"<span class="text-zinc-500">:</span>
</span>
<span class="whitespace-nowrap" :class="valueClass[f.kind]">
<template v-if="isRevealed(f)">
<span :class="{ 'reveal-flash': f.derived }">{{ f.value }}</span
><span v-if="i < fields.length - 1" class="text-zinc-500">,</span>
</template>
<span v-else class="italic text-zinc-600">{{
f.key === 'id' ? 'hashing…' : 'signing…'
}}</span>
</span>
<span class="whitespace-nowrap text-sm text-zinc-600">
<span class="text-zinc-500">// </span>{{ f.comment
}}<span
v-if="f.derived && isRevealed(f)"
class="reveal-flash ml-1 rounded bg-violet-500/15 px-1 text-xs text-violet-300"
>computed</span
>
</span>
</template>
</div>
<span class="select-none text-[9rem] font-thin leading-none text-violet-500/80 sm:text-[11rem]">}</span>
</div>
<!-- How it's built & signed -->
<div class="mt-8 flex flex-col items-stretch gap-3 sm:flex-row sm:items-center sm:justify-center">
<template v-for="(s, i) in steps" :key="s.label">
<button
type="button"
:aria-current="i === stage ? 'step' : undefined"
class="flex items-center gap-3 rounded-xl border px-4 py-3 text-left transition-colors duration-300 hover:border-violet-500/70 focus:outline-none focus-visible:ring-2 focus-visible:ring-violet-400"
:class="
i === stage
? 'border-violet-500 bg-violet-950/30 ring-1 ring-violet-500/40'
: 'border-zinc-700 bg-zinc-900/60 hover:bg-zinc-900'
"
@click="selectStage(i)"
>
<component
:is="s.icon"
class="h-6 w-6 flex-none transition-colors duration-300"
:class="i === stage ? 'text-violet-300' : 'text-violet-400/60'"
/>
<div>
<p class="font-semibold text-white">{{ s.label }}</p>
<p class="text-xs text-zinc-400">{{ s.sub }}</p>
</div>
</button>
<ArrowRight
v-if="i < steps.length - 1"
class="mx-auto h-5 w-5 flex-none rotate-90 text-zinc-500 sm:rotate-0"
/>
</template>
</div>
<p class="mt-3 text-center text-xs text-zinc-600">Click a step to walk through each state.</p>
<p class="mt-6 text-center text-sm text-zinc-500">
Anyone recomputes the hash and checks <code class="text-zinc-300">sig</code> against your
<code class="text-zinc-300">pubkey</code> tamper with one character and it fails. No server vouches for you; the math does.
</p>
</SlideShell>
</template>
<style scoped>
.reveal-flash {
display: inline-block;
border-radius: 3px;
animation: reveal 0.6s ease-out;
}
@keyframes reveal {
0% {
opacity: 0;
filter: blur(5px);
box-shadow: 0 0 0 4px rgba(139, 92, 246, 0.35);
background: rgba(139, 92, 246, 0.35);
}
100% {
opacity: 1;
filter: blur(0);
box-shadow: 0 0 0 0 transparent;
background: transparent;
}
}
@media (prefers-reduced-motion: reduce) {
.reveal-flash {
animation: none;
}
}
</style>

View file

@ -1,40 +0,0 @@
<script setup lang="ts">
import { Globe, KeyRound } from '@lucide/vue'
import SlideShell from './SlideShell.vue'
</script>
<template>
<SlideShell kicker="Your identity" title="No account — a key pair">
<div class="grid gap-6 sm:grid-cols-2">
<!-- Public key -->
<div class="rounded-xl border border-emerald-600/40 bg-emerald-950/20 p-6">
<div class="mb-3 flex items-center gap-3">
<Globe class="h-7 w-7 text-emerald-400" />
<span class="text-xl font-bold text-white">npub</span>
</div>
<p class="text-sm font-semibold uppercase tracking-wider text-emerald-400">Public key</p>
<p class="mt-2 text-zinc-300">Your username. Share it freely its how people find and follow you.</p>
<p class="mt-4 rounded-md bg-black/40 px-3 py-2 font-mono text-xs text-emerald-300/80">
npub1q9xshare&nbsp;me
</p>
</div>
<!-- Private key -->
<div class="rounded-xl border border-red-600/50 bg-red-950/20 p-6">
<div class="mb-3 flex items-center gap-3">
<KeyRound class="h-7 w-7 text-red-400" />
<span class="text-xl font-bold text-white">nsec</span>
</div>
<p class="text-sm font-semibold uppercase tracking-wider text-red-400">Private key</p>
<p class="mt-2 text-zinc-300">Your password. Whoever has it <em>is</em> you. Cant be reset. Never share it.</p>
<p class="mt-4 rounded-md bg-black/40 px-3 py-2 font-mono text-xs text-red-300/70">
nsec1
</p>
</div>
</div>
<p class="mt-8 text-zinc-300">
No email, no phone, no signup. Your keys work across <span class="font-semibold text-white">every</span> Nostr
app your follows and posts come with you.
</p>
</SlideShell>
</template>

View file

@ -1,45 +0,0 @@
<script setup lang="ts">
import { PenLine, ArrowRight, Server, Users } from '@lucide/vue'
import SlideShell from './SlideShell.vue'
</script>
<template>
<SlideShell kicker="The key idea" title="The outbox model (a.k.a. gossip)">
<!-- Flow: you write your outbox relays followers read -->
<div class="flex flex-col items-stretch gap-4 sm:flex-row sm:items-center sm:justify-between">
<div class="flex flex-1 flex-col items-center gap-2 rounded-xl border border-zinc-700 bg-zinc-900/60 p-5 text-center">
<PenLine class="h-8 w-8 text-violet-400" />
<p class="font-semibold text-white">You post</p>
<p class="text-sm text-zinc-400">A signed note</p>
</div>
<ArrowRight class="mx-auto h-6 w-6 rotate-90 text-zinc-500 sm:rotate-0" />
<div class="flex flex-1 flex-col items-center gap-2 rounded-xl border border-violet-600/50 bg-violet-950/20 p-5 text-center">
<Server class="h-8 w-8 text-violet-300" />
<p class="font-semibold text-white">Your <span class="text-violet-300">write / outbox</span> relays</p>
<p class="text-sm text-zinc-400">Where your content lives</p>
</div>
<ArrowRight class="mx-auto h-6 w-6 rotate-90 text-zinc-500 sm:rotate-0" />
<div class="flex flex-1 flex-col items-center gap-2 rounded-xl border border-zinc-700 bg-zinc-900/60 p-5 text-center">
<Users class="h-8 w-8 text-violet-400" />
<p class="font-semibold text-white">Followers read</p>
<p class="text-sm text-zinc-400">Their app looks there</p>
</div>
</div>
<div class="mt-8 space-y-4">
<p>
Your app <span class="font-semibold text-white">announces</span> which relays you write to.
Other peoples apps read that list and go fetch your notes <span class="text-white">from the right place</span>
no central server required.
</p>
<p class="rounded-lg border-l-2 border-violet-500 bg-zinc-900/50 py-3 pl-4 pr-3 text-zinc-300">
If you dont know where your posts are, you might as well just stay on centralized Twitter.
<span class="text-zinc-500"> Vitor Pamplona, Amethyst</span>
</p>
</div>
</SlideShell>
</template>

View file

@ -1,138 +0,0 @@
<script setup lang="ts">
import SlideShell from './SlideShell.vue'
// HTTP: three users funnel into ONE server.
const users = [40, 95, 150]
// Nostr: you broadcast out to MANY relays (a little mesh).
const you = { x: 52, y: 95 }
const relays = [
{ x: 200, y: 32 },
{ x: 250, y: 62 },
{ x: 262, y: 112 },
{ x: 232, y: 158 },
{ x: 182, y: 128 },
]
// Faint relay-to-relay links to imply a gossiping network.
const mesh = [
[0, 1],
[1, 2],
[2, 3],
[3, 4],
[4, 0],
[1, 4],
]
</script>
<template>
<SlideShell kicker="Mental model" title="Nostr isnt HTTP">
<div class="grid gap-6 sm:grid-cols-2">
<!-- HTTP: many users one server -->
<div class="rounded-xl border border-zinc-700 bg-zinc-900/60 p-6">
<p class="mb-2 text-sm font-semibold uppercase tracking-widest text-zinc-400">
The web (HTTP)
</p>
<svg viewBox="0 0 300 190" class="mx-auto w-full max-w-[300px]">
<!-- connections -->
<g stroke="#52525b" stroke-width="2">
<line v-for="y in users" :key="'l' + y" x1="54" :y1="y" x2="206" y2="95" />
</g>
<!-- users -->
<g fill="#a1a1aa">
<template v-for="y in users" :key="'u' + y">
<circle cx="42" :cy="y - 7" r="6" />
<path :d="`M33 ${y + 9} a9 9 0 0 1 18 0`" />
</template>
</g>
<!-- one server -->
<rect x="206" y="66" width="66" height="58" rx="8" fill="#27272a" stroke="#a1a1aa" stroke-width="2" />
<g stroke="#a1a1aa" stroke-width="2">
<line x1="218" y1="84" x2="260" y2="84" />
<line x1="218" y1="95" x2="260" y2="95" />
<line x1="218" y1="106" x2="260" y2="106" />
</g>
<circle cx="252" cy="118" r="2.5" fill="#ef4444" />
<text x="239" y="150" text-anchor="middle" fill="#71717a" font-size="12">single point</text>
</svg>
<p class="mt-2 text-center text-sm text-zinc-400">
One server holds your account <em>and</em> your data. It bans you? Youre gone.
</p>
</div>
<!-- Nostr: you fan out to many relays -->
<div class="rounded-xl border border-violet-600/50 bg-violet-950/20 p-6">
<p class="mb-2 text-sm font-semibold uppercase tracking-widest text-violet-400">Nostr</p>
<svg viewBox="0 0 300 190" class="mx-auto w-full max-w-[300px]">
<!-- faint relay mesh -->
<g stroke="#5b21b6" stroke-width="1.5" opacity="0.6">
<line
v-for="([a, b], i) in mesh"
:key="'m' + i"
:x1="relays[a].x"
:y1="relays[a].y"
:x2="relays[b].x"
:y2="relays[b].y"
/>
</g>
<!-- broadcast lines (animated) -->
<g stroke="#8b5cf6" stroke-width="2" class="flow">
<line
v-for="(r, i) in relays"
:key="'b' + i"
:x1="you.x + 14"
:y1="you.y"
:x2="r.x"
:y2="r.y"
/>
</g>
<!-- relays -->
<g>
<circle
v-for="(r, i) in relays"
:key="'r' + i"
:cx="r.x"
:cy="r.y"
r="11"
fill="#2e1065"
stroke="#a78bfa"
stroke-width="2"
/>
</g>
<!-- you (signed) -->
<g fill="#8b5cf6">
<circle :cx="you.x" :cy="you.y - 8" r="8" />
<path :d="`M${you.x - 12} ${you.y + 10} a12 12 0 0 1 24 0`" />
</g>
<text :x="you.x" :y="you.y + 34" text-anchor="middle" fill="#a78bfa" font-size="12">you</text>
<text x="235" y="184" text-anchor="middle" fill="#a78bfa" font-size="12">many relays</text>
</svg>
<p class="mt-2 text-center text-sm text-zinc-300">
You sign notes and broadcast to relays you choose. One drops you? The rest still have it.
</p>
</div>
</div>
<p class="mt-6 text-sm text-zinc-500">
Relays actually talk over <code class="text-zinc-300">wss://</code> (WebSockets) same plumbing
as the web. What flips is
<span class="text-zinc-300">who owns your identity and where your data lives.</span>
</p>
</SlideShell>
</template>
<style scoped>
.flow line {
stroke-dasharray: 5 6;
animation: flow 0.9s linear infinite;
}
@keyframes flow {
to {
stroke-dashoffset: -11;
}
}
@media (prefers-reduced-motion: reduce) {
.flow line {
animation: none;
}
}
</style>

View file

@ -1,34 +0,0 @@
<script setup lang="ts">
import SlideShell from './SlideShell.vue'
const rows = [
{ label: 'Identity', http: 'An account the server owns', nostr: 'A keypair you own' },
{ label: 'Data lives', http: 'On one companys server', nostr: 'Copied across many relays' },
{ label: 'Model', http: 'Request → response', nostr: 'Publish signed events (pub/sub)' },
{ label: 'Trust', http: 'Trust the host not to lie', nostr: 'Signatures — math, not trust' },
{ label: 'If it bans you', http: 'Youre gone', nostr: 'Move relays, keep your identity' },
]
</script>
<template>
<SlideShell kicker="Mental model" title="HTTP vs Nostr, point by point">
<div class="overflow-hidden rounded-xl border border-zinc-700">
<table class="w-full text-left text-lg">
<thead class="bg-zinc-800/60 text-sm uppercase tracking-wider text-zinc-400">
<tr>
<th class="px-5 py-3 font-medium"></th>
<th class="px-5 py-3 font-medium">HTTP</th>
<th class="px-5 py-3 font-medium text-violet-300">Nostr</th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-800">
<tr v-for="row in rows" :key="row.label">
<td class="px-5 py-4 font-semibold text-white">{{ row.label }}</td>
<td class="px-5 py-4 text-zinc-400">{{ row.http }}</td>
<td class="px-5 py-4 text-zinc-200">{{ row.nostr }}</td>
</tr>
</tbody>
</table>
</div>
</SlideShell>
</template>

View file

@ -1,70 +0,0 @@
<script setup lang="ts">
import { Home, Bell, Mail } from '@lucide/vue'
import SlideShell from './SlideShell.vue'
const rows = [
{
icon: Home,
role: 'Outbox',
writes: 'You',
reads: 'Everyone',
empty: 'Followers never get your posts',
},
{
icon: Bell,
role: 'Inbox',
writes: 'Everyone',
reads: 'You',
empty: 'You miss replies & mentions',
},
{
icon: Mail,
role: 'DM inbox',
writes: 'Anyone (encrypted)',
reads: 'Only you',
empty: 'You miss DMs',
},
]
</script>
<template>
<SlideShell kicker="Amethyst — why the sets differ" title="One relay cant do every job">
<div class="overflow-hidden rounded-xl border border-zinc-700">
<table class="w-full text-left text-base">
<thead class="bg-zinc-800/60 text-sm uppercase tracking-wider text-zinc-400">
<tr>
<th class="px-4 py-3 font-medium">Role</th>
<th class="px-4 py-3 font-medium">Who writes</th>
<th class="px-4 py-3 font-medium">Who reads</th>
<th class="px-4 py-3 font-medium text-red-300">If you leave it empty</th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-800">
<tr v-for="row in rows" :key="row.role">
<td class="px-4 py-3 font-semibold text-white">
<span class="flex items-center gap-2">
<component :is="row.icon" class="h-5 w-5 text-violet-400" />
{{ row.role }}
</span>
</td>
<td class="px-4 py-3 text-zinc-300">{{ row.writes }}</td>
<td class="px-4 py-3 text-zinc-300">{{ row.reads }}</td>
<td class="px-4 py-3 text-red-300/90">{{ row.empty }}</td>
</tr>
</tbody>
</table>
</div>
<p class="mt-6">
The access rules are <span class="font-semibold text-white">opposite</span>, so no single relay
fits every list thats the whole reason for separate sets.
</p>
<div class="mt-4 rounded-lg border-l-2 border-violet-500 bg-zinc-900/50 py-3 pl-4 pr-3 text-zinc-300">
<span class="font-semibold text-white">Reuse vs. keep separate:</span>
an <em>open</em> relay (e.g. <code class="text-zinc-200">nos.lol</code>) is fine in several lists at once.
A <em>paid / restricted</em> relay (e.g. <code class="text-zinc-200">nostr.wine</code>) must stay in its lane
great for DMs, wrong for your public inbox.
</div>
</SlideShell>
</template>

View file

@ -1,75 +0,0 @@
<script setup lang="ts">
import { Home, Bell, Lock, Mail, Search, HardDrive } from '@lucide/vue'
import SlideShell from './SlideShell.vue'
const categories = [
{
icon: Home,
name: 'Public Home / Outbox',
count: '~3',
what: 'Where your posts, likes & replies live. Followers read from here.',
tip: 'One big public relay + one personal + one fast/country relay.',
},
{
icon: Bell,
name: 'Public Inbox',
count: '~3',
what: 'Where replies, mentions & zaps arrive — your notifications.',
tip: 'Keep separate from outbox. Avoid nostr.wine variants here.',
},
{
icon: Mail,
name: 'DM Inbox',
count: '~3',
what: 'Encrypted direct messages. Write-open, read-restricted to you.',
tip: 'inbox.nostr.wine or auth.nostr1.com + a personal backup.',
},
{
icon: Lock,
name: 'Private Home',
count: '12',
what: 'Drafts, bookmarks, lists & app settings — nobody else reads these.',
tip: 'A local relay (Citrine) or an authed personal relay.',
},
{
icon: Search,
name: 'Search',
count: '23',
what: 'Powers tagging & search. Must support NIP-50.',
tip: 'relay.nostr.band, nostr.wine, relay.noswhere.com.',
},
{
icon: HardDrive,
name: 'Local',
count: '1',
what: 'On-device cache so the app is fast & works offline.',
tip: 'Citrine on Android → ws://localhost:4869.',
},
]
</script>
<template>
<SlideShell kicker="Amethyst — the confusing part" title="Relays come in categories">
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div
v-for="cat in categories"
:key="cat.name"
class="rounded-xl border border-zinc-700 bg-zinc-900/60 p-5"
>
<div class="mb-2 flex items-center justify-between">
<component :is="cat.icon" class="h-6 w-6 text-violet-400" />
<span class="rounded-full bg-violet-600/20 px-2 py-0.5 text-xs font-semibold text-violet-300">
{{ cat.count }}
</span>
</div>
<p class="text-lg font-semibold text-white">{{ cat.name }}</p>
<p class="mt-1 text-sm leading-snug text-zinc-400">{{ cat.what }}</p>
<p class="mt-3 text-xs leading-snug text-violet-300/90"> {{ cat.tip }}</p>
</div>
</div>
<p class="mt-6 text-sm text-zinc-500">
Dont overthink it: set <span class="text-zinc-300">outbox</span>, <span class="text-zinc-300">inbox</span> and
<span class="text-zinc-300">DM</span> with ~3 solid relays each. Amethyst suggests the rest from who you follow.
</p>
</SlideShell>
</template>

View file

@ -1,43 +0,0 @@
<script setup lang="ts">
import { ExternalLink } from '@lucide/vue'
import SlideShell from './SlideShell.vue'
defineProps<{
kicker?: string
title: string
takeaways: string[]
links: { label: string; url: string }[]
}>()
</script>
<template>
<SlideShell :kicker="kicker" :title="title">
<div class="grid gap-8 sm:grid-cols-2">
<div>
<p class="mb-3 text-sm font-semibold uppercase tracking-wider text-violet-400">Remember</p>
<ul class="space-y-3">
<li v-for="(t, i) in takeaways" :key="i" class="flex gap-3">
<span class="mt-2 h-2 w-2 flex-none rounded-full bg-violet-500" />
<span>{{ t }}</span>
</li>
</ul>
</div>
<div>
<p class="mb-3 text-sm font-semibold uppercase tracking-wider text-violet-400">Go deeper</p>
<ul class="space-y-3">
<li v-for="link in links" :key="link.url">
<a
:href="link.url"
target="_blank"
rel="noopener"
class="inline-flex items-center gap-2 text-zinc-200 underline decoration-violet-500/50 underline-offset-4 hover:text-violet-300"
>
<ExternalLink class="h-4 w-4 flex-none" />
{{ link.label }}
</a>
</li>
</ul>
</div>
</div>
</SlideShell>
</template>

View file

@ -1,31 +0,0 @@
<script setup lang="ts">
/**
* Common frame for every content slide: a small violet "kicker" (eyebrow),
* a big title, and a default slot for the body. Vertically centered, capped
* width so lines stay readable on a projector.
*/
defineProps<{
kicker?: string
title?: string
}>()
</script>
<template>
<div class="mx-auto flex h-full w-full max-w-5xl flex-col justify-center px-10 py-16">
<p
v-if="kicker"
class="mb-3 text-sm font-semibold uppercase tracking-[0.2em] text-violet-400"
>
{{ kicker }}
</p>
<h2
v-if="title"
class="mb-8 text-4xl font-bold leading-tight text-white sm:text-5xl"
>
{{ title }}
</h2>
<div class="text-lg leading-relaxed text-zinc-300 sm:text-xl">
<slot />
</div>
</div>
</template>

View file

@ -1,32 +0,0 @@
<script setup lang="ts">
/** Opening / hero slide. */
defineProps<{
title: string
subtitle?: string
tagline?: string
}>()
</script>
<template>
<div
class="relative mx-auto flex h-full w-full max-w-5xl flex-col items-center justify-center px-10 text-center"
>
<div
class="mb-8 flex h-20 w-20 items-center justify-center rounded-2xl bg-violet-600/20 ring-1 ring-violet-500/40"
>
<span class="text-4xl">🟣</span>
</div>
<h1 class="text-5xl font-extrabold tracking-tight text-white sm:text-7xl">
{{ title }}
</h1>
<p v-if="subtitle" class="mt-6 max-w-2xl text-xl text-zinc-300 sm:text-2xl">
{{ subtitle }}
</p>
<p
v-if="tagline"
class="mt-10 text-sm font-medium uppercase tracking-[0.25em] text-violet-400"
>
{{ tagline }}
</p>
</div>
</template>

View file

@ -1,72 +0,0 @@
<script setup lang="ts">
import SlideShell from './SlideShell.vue'
type Role = 'server' | 'recipient' | 'sender'
interface Line {
key: string
value: string
kind: 'string' | 'number'
comment: string
role?: Role
tag?: boolean // an entry in the event's `tags` array (vs. a top-level field)
}
const lines: Line[] = [
{ key: 'kind', value: '9735', kind: 'number', comment: 'a zap receipt' },
{ key: 'pubkey', value: '"9be0…7c"', kind: 'string', comment: 'the zapper services key — it signs, not you', role: 'server' },
{ key: 'p', value: '"3bf0c6…2a"', kind: 'string', comment: 'who was zapped (recipient)', role: 'recipient', tag: true },
{ key: 'e', value: '"0a1b2c…9f"', kind: 'string', comment: 'which note (optional)', tag: true },
{ key: 'P', value: '"82f4…d1"', kind: 'string', comment: 'who zapped (optional)', role: 'sender', tag: true },
{ key: 'bolt11', value: '"lnbc210n1…"', kind: 'string', comment: 'the invoice that was paid', tag: true },
{ key: 'description', value: '"{…9734…}"', kind: 'string', comment: 'your signed request: sender + amount + comment', role: 'sender', tag: true },
{ key: 'sig', value: '"e7c9…"', kind: 'string', comment: 'signed by the service key', role: 'server' },
]
const valueClass = { string: 'text-emerald-300', number: 'text-amber-300' }
const roleBadge: Record<Role, { label: string; class: string }> = {
server: { label: 'server', class: 'bg-violet-500/15 text-violet-300' },
recipient: { label: 'zapped', class: 'bg-sky-500/15 text-sky-300' },
sender: { label: 'zapper', class: 'bg-rose-500/15 text-rose-300' },
}
</script>
<template>
<SlideShell kicker="NIP-57 · kind 9735" title="Anatomy of a zap receipt">
<p class="mb-4 text-zinc-400">The event the provider publishes when your zap is paid:</p>
<div class="flex items-center justify-center gap-3 sm:gap-6">
<span class="select-none text-[8rem] font-thin leading-none text-violet-500/80 sm:text-[10rem]">{</span>
<div class="grid grid-cols-[auto_auto_1fr] items-center gap-x-5 gap-y-1.5 font-mono text-base sm:text-lg">
<template v-for="l in lines" :key="l.key">
<span class="justify-self-end whitespace-nowrap" :class="l.tag ? 'text-violet-300/90' : 'font-semibold text-violet-300'">
<span v-if="l.tag" class="text-zinc-600">tag </span>"{{ l.key }}"<span class="text-zinc-500">:</span>
</span>
<span class="whitespace-nowrap" :class="valueClass[l.kind]">{{ l.value }}</span>
<span class="whitespace-nowrap text-sm text-zinc-600">
<span class="text-zinc-500">// </span>{{ l.comment
}}<span
v-if="l.role"
class="ml-1 rounded px-1 text-xs font-semibold"
:class="roleBadge[l.role].class"
>{{ roleBadge[l.role].label }}</span
>
</span>
</template>
</div>
<span class="select-none text-[8rem] font-thin leading-none text-violet-500/80 sm:text-[10rem]">}</span>
</div>
<div class="mt-6 flex flex-wrap justify-center gap-x-6 gap-y-1 text-sm">
<span><span class="mr-1 inline-block h-2 w-2 rounded-full bg-violet-400" />service signs it</span>
<span><span class="mr-1 inline-block h-2 w-2 rounded-full bg-sky-400" /><code>p</code> = who was zapped</span>
<span><span class="mr-1 inline-block h-2 w-2 rounded-full bg-rose-400" /><code>description</code> = who zapped (your signed 9734)</span>
</div>
<p class="mt-4 text-center text-sm text-zinc-500">
Clients trust it only if <code class="text-zinc-300">pubkey</code> matches the
<code class="text-zinc-300">nostrPubkey</code> the recipients provider advertised thats what stops fake zaps.
</p>
</SlideShell>
</template>

View file

@ -1,59 +0,0 @@
<script setup lang="ts">
import { Wallet, BadgeCheck, ArrowRight, Zap } from '@lucide/vue'
import SlideShell from './SlideShell.vue'
</script>
<template>
<SlideShell kicker="Lightning + a receipt" title="How a zap works ⚡">
<div class="flex flex-col items-stretch gap-4 lg:flex-row lg:items-stretch">
<!-- Step 1: plain Lightning payment (not Nostr) -->
<div class="flex-1 rounded-xl border border-amber-500/40 bg-amber-950/10 p-6">
<div class="mb-3 flex items-center gap-3">
<span class="flex h-8 w-8 flex-none items-center justify-center rounded-full bg-amber-500/20 text-sm font-bold text-amber-300">1</span>
<Wallet class="h-6 w-6 text-amber-400" />
<span class="text-sm font-semibold uppercase tracking-wider text-amber-400">Plain Lightning · not Nostr</span>
</div>
<p class="text-white">You pay a Bitcoin <span class="font-semibold">Lightning invoice</span>.</p>
<p class="mt-2 text-base text-zinc-400">
Their profile lists a Lightning address. Your app fetches an invoice and pays it instant,
cheap, and on its own <em>invisible</em>. This step is 100% Lightning; Nostr isnt involved yet.
</p>
</div>
<ArrowRight class="mx-auto h-6 w-6 rotate-90 flex-none self-center text-zinc-500 lg:rotate-0" />
<!-- Step 2: the signed receipt (the Nostr part) -->
<div class="flex-1 rounded-xl border border-violet-600/50 bg-violet-950/20 p-6">
<div class="mb-3 flex items-center gap-3">
<span class="flex h-8 w-8 flex-none items-center justify-center rounded-full bg-violet-500/20 text-sm font-bold text-violet-300">2</span>
<BadgeCheck class="h-6 w-6 text-violet-400" />
<span class="text-sm font-semibold uppercase tracking-wider text-violet-400">The receipt · the Nostr part</span>
</div>
<p class="text-white">Their Lightning provider signs a <span class="font-semibold">zap receipt</span>.</p>
<p class="mt-2 text-base text-zinc-400">
The provider has its <span class="text-zinc-200">own Nostr key</span>. When the invoice clears it
signs a <code class="text-zinc-300">kind 9735</code> event tagged with
<span class="text-zinc-200">who was zapped</span> and embedding your signed request (which says who
zapped, how much, and any comment) then posts it to the relays you named.
</p>
</div>
</div>
<!-- Takeaway -->
<div class="mt-6 flex items-start gap-3 rounded-lg border-l-2 border-violet-500 bg-zinc-900/50 py-3 pl-4 pr-4">
<Zap class="mt-0.5 h-5 w-5 flex-none text-violet-400" />
<p class="text-zinc-300">
Same sats either way the <span class="font-semibold text-white">public, signed receipt</span> is what turns a
private Lightning tip into a visible <em>zap</em>.
</p>
</div>
<p class="mt-4 text-sm text-zinc-500">
Under the hood (NIP-57): your signed
<code class="text-zinc-300">zap request (kind 9734)</code> rides along with the LNURL-pay invoice; the
provider advertises <code class="text-zinc-300">allowsNostr</code> + its
<code class="text-zinc-300">nostrPubkey</code>, and clients only trust a
<code class="text-zinc-300">9735</code> receipt signed by that key. (Next: its anatomy )
</p>
</SlideShell>
</template>

View file

@ -1,235 +0,0 @@
import type { Slide } from './types'
import TitleSlide from '@/components/slides/TitleSlide.vue'
import BulletsSlide from '@/components/slides/BulletsSlide.vue'
import KeysSlide from '@/components/slides/KeysSlide.vue'
import EventAnatomySlide from '@/components/slides/EventAnatomySlide.vue'
import ProtocolCompareSlide from '@/components/slides/ProtocolCompareSlide.vue'
import ProtocolTableSlide from '@/components/slides/ProtocolTableSlide.vue'
import OutboxSlide from '@/components/slides/OutboxSlide.vue'
import RelayCategoriesSlide from '@/components/slides/RelayCategoriesSlide.vue'
import RelayAccessSlide from '@/components/slides/RelayAccessSlide.vue'
import ZapSlide from '@/components/slides/ZapSlide.vue'
import ZapReceiptSlide from '@/components/slides/ZapReceiptSlide.vue'
import ResourcesSlide from '@/components/slides/ResourcesSlide.vue'
/**
* Ordered deck. Each entry maps an id (used for the URL hash) to a layout
* component + its props. Text slides reuse BulletsSlide; the three hard
* concepts (protocol model, outbox, relay categories) get bespoke components.
*/
export const slides: Slide[] = [
{
id: 'intro',
label: 'Welcome',
component: TitleSlide,
props: {
title: 'Getting on Nostr',
subtitle: 'A decentralized social protocol you actually own — in about 10 minutes.',
tagline: 'Notes and Other Stuff Transmitted by Relays',
},
},
{
id: 'what',
label: 'What is Nostr?',
component: BulletsSlide,
props: {
kicker: 'The 30-second version',
title: 'What is Nostr?',
bullets: [
{ lead: 'A protocol, not an app.', text: 'Like email — many apps, one open network.' },
{ lead: 'No signup.', text: 'No email, no phone number, no company approving you.' },
{ lead: 'You own your identity.', text: 'A key pair you carry between apps.' },
{ lead: 'Censorship-resistant.', text: 'No single server can erase you.' },
],
},
},
{
id: 'vs-http',
label: 'Nostr vs HTTP',
component: ProtocolCompareSlide,
},
{
id: 'vs-http-table',
label: 'HTTP vs Nostr table',
component: ProtocolTableSlide,
},
{
id: 'keys',
label: 'Your keys',
component: KeysSlide,
},
{
id: 'guard-nsec',
label: 'Guard your nsec',
component: BulletsSlide,
props: {
kicker: 'Rule #1',
title: 'Protect your nsec',
bullets: [
{ lead: 'It cant be reset.', text: 'Lose it and the account is gone forever.' },
{ lead: 'Its total control.', text: 'Anyone with it can post as you and read your DMs.' },
{ lead: 'Never paste it into websites.', text: 'Legit apps never ask you to.' },
{ lead: 'Use a signer app.', text: 'On Android, Amber holds the key and approves actions for you.' },
],
footnote: 'Back it up offline — write it on paper or store it in a password manager.',
},
},
{
id: 'signing',
label: 'Signing',
component: BulletsSlide,
props: {
kicker: 'How you log in',
title: 'Every action is signed',
bullets: [
{ lead: 'No password server.', text: 'You prove who you are with a cryptographic signature.' },
{ lead: 'Each post, like & follow', text: 'is signed by your key before it goes out.' },
{ lead: 'Anyone can verify it', text: 'came from your npub — math, not trust.' },
{ lead: 'A signer (Amber) does this', text: 'so your nsec never touches individual apps.' },
],
},
},
{
id: 'event',
label: 'Anatomy of an event',
component: EventAnatomySlide,
},
{
id: 'amethyst',
label: 'Get Amethyst',
component: BulletsSlide,
props: {
kicker: 'Hands-on · Android',
title: 'Get started with Amethyst',
bullets: [
{ lead: '1. Install Amethyst', text: 'from the Play Store, Zap.store, or F-Droid (Obtainium).' },
{ lead: '2. Install Amber', text: 'the companion signer — keeps your nsec out of every app.' },
{ lead: '3. Create your key', text: 'Amber generates your npub / nsec pair.' },
{ lead: '4. Back up the nsec', text: 'before you do anything else.' },
],
footnote: 'iOS or web instead? Primal, Damus (iOS) and Nos work the same way — concepts carry over.',
},
},
{
id: 'profile',
label: 'Your profile',
component: BulletsSlide,
props: {
kicker: 'Make it yours',
title: 'Set up your profile',
bullets: [
{ lead: 'Display name & picture', text: 'so people recognize you.' },
{ lead: 'A short bio', text: 'tell folks what youre into.' },
{ lead: 'NIP-05 (optional)', text: 'a name@domain handle that verifies you — like a checkmark you control.' },
],
},
},
{
id: 'follow',
label: 'Find people',
component: BulletsSlide,
props: {
kicker: 'Fill your feed',
title: 'Find people to follow',
bullets: [
{ lead: 'Search a known npub', text: 'paste a friends public key.' },
{ lead: 'Follow packs', text: 'Amethyst suggests curated starter lists.' },
{ lead: 'nostr.directory & #nostr', text: 'find people whove linked their accounts.' },
{ lead: 'Follow ~20 people', text: 'a fuller feed makes everything click.' },
],
},
},
{
id: 'relays-what',
label: 'What are relays?',
component: BulletsSlide,
props: {
kicker: 'The infrastructure',
title: 'What is a relay?',
bullets: [
{ lead: 'A dumb server', text: 'that stores notes and rebroadcasts them to whoever asks.' },
{ lead: 'Anyone can run one', text: 'there are thousands; most are free.' },
{ lead: 'You connect to several', text: 'for redundancy — if one goes down, your notes live on the others.' },
{ lead: 'Relays dont know each other', text: 'your app is what ties them together.' },
],
footnote: 'Feed feels slow or empty? Its almost always your relay choice — not the app.',
},
},
{
id: 'outbox',
label: 'Outbox model',
component: OutboxSlide,
},
{
id: 'relay-categories',
label: 'Relay categories',
component: RelayCategoriesSlide,
},
{
id: 'relay-access',
label: 'Why the sets differ',
component: RelayAccessSlide,
},
{
id: 'paid-relays',
label: 'Paid vs free',
component: BulletsSlide,
props: {
kicker: 'Choosing relays',
title: 'Free, paid & where to look',
bullets: [
{ lead: 'Most relays are free', text: 'great to start — just add a few big, reliable ones.' },
{ lead: 'Open relays double up', text: 'a free, openly-readable/writable relay (nos.lol) can sit in several lists at once.' },
{ lead: 'Paid relays stay in their lane', text: 'nostr.wine & co. restrict access — keep them category-specific (e.g. DMs), never your public inbox.' },
{ lead: 'Browse & health-check', text: 'nostr.watch shows which relays are up, fast, free vs. paid, and reputable.' },
],
},
},
{
id: 'first-actions',
label: 'First actions',
component: BulletsSlide,
props: {
kicker: 'Youre on',
title: 'Post your first note — and zaps',
bullets: [
{ lead: 'Just start typing', text: 'hit post; it goes to your outbox relays and out to the world.' },
{ lead: 'Reply, like, repost', text: 'same as any social app — but portable.' },
{ lead: 'Zaps ⚡', text: 'send real Bitcoin tips over Lightning by tapping the zap icon.' },
{ lead: 'Add a wallet later', text: 'connect a Lightning wallet to send and receive zaps.' },
],
},
},
{
id: 'zap',
label: 'How a zap works',
component: ZapSlide,
},
{
id: 'zap-receipt',
label: 'Zap receipt anatomy',
component: ZapReceiptSlide,
},
{
id: 'recap',
label: 'Recap',
component: ResourcesSlide,
props: {
kicker: 'You made it',
title: 'Recap & resources',
takeaways: [
'Your keys are your identity — guard the nsec.',
'You publish to relays you choose; followers read from them (outbox).',
'Set ~3 outbox, ~3 inbox, ~3 DM relays and youre sorted.',
'No server owns you — move apps and relays freely.',
],
links: [
{ label: 'nostr.how — get started', url: 'https://nostr.how/en/get-started' },
{ label: 'Vitor Pamplona — Relay Setup 101', url: 'https://vitor.npub.pro/post/relay-setup/' },
{ label: 'nostr.watch — browse relays', url: 'https://nostr.watch' },
{ label: 'Amethyst on GitHub', url: 'https://github.com/vitorpamplona/amethyst' },
],
},
},
]

View file

@ -1,16 +0,0 @@
import type { Component } from 'vue'
/**
* A slide is a layout component plus the props that feed it.
* The deck renders `<component :is="component" v-bind="props" />`, so any
* component under `src/components/slides/` can be dropped in here generic
* layouts for text-heavy slides, bespoke components for the diagram slides.
*/
export interface Slide {
/** Stable id used for the URL hash (e.g. #keys) and nav. */
id: string
/** Short label for the overview / progress tooltip. */
label: string
component: Component
props?: Record<string, unknown>
}

View file

@ -3,8 +3,8 @@ import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
const routes: RouteRecordRaw[] = [ const routes: RouteRecordRaw[] = [
{ {
path: '/', path: '/',
name: 'presentation', name: 'home',
component: () => import('@/views/PresentationView.vue'), component: () => import('@/views/HomeView.vue'),
}, },
] ]

View file

@ -1,150 +0,0 @@
<script setup lang="ts">
import { computed, onMounted, onBeforeUnmount, ref, watch } from 'vue'
import { ChevronLeft, ChevronRight } from '@lucide/vue'
import { slides } from '@/features/slides/slides'
const count = slides.length
/** URL hash -> slide index, so any slide is deep-linkable (e.g. #outbox). */
function indexFromHash(): number {
const id = window.location.hash.replace(/^#\/?/, '')
if (!id) return 0
const byId = slides.findIndex((s) => s.id === id)
if (byId !== -1) return byId
const asNum = Number(id)
return Number.isFinite(asNum) ? Math.max(0, Math.min(count - 1, asNum - 1)) : 0
}
// Initialize from the hash before first paint so deep-links don't fade in from slide 1.
const current = ref(indexFromHash())
const activeSlide = computed(() => slides[current.value])
const progress = computed(() => ((current.value + 1) / count) * 100)
/** Track direction so the transition slides the right way. */
const dir = ref<'fwd' | 'back'>('fwd')
function go(index: number) {
const clamped = Math.max(0, Math.min(count - 1, index))
dir.value = clamped >= current.value ? 'fwd' : 'back'
current.value = clamped
}
function next() {
go(current.value + 1)
}
function prev() {
go(current.value - 1)
}
function onKey(e: KeyboardEvent) {
switch (e.key) {
case 'ArrowRight':
case 'PageDown':
case ' ':
e.preventDefault()
next()
break
case 'ArrowLeft':
case 'PageUp':
e.preventDefault()
prev()
break
case 'Home':
e.preventDefault()
go(0)
break
case 'End':
e.preventDefault()
go(count - 1)
break
}
}
function onHashChange() {
const idx = indexFromHash()
if (idx !== current.value) go(idx)
}
watch(current, (i) => {
const id = slides[i].id
if (window.location.hash.replace(/^#\/?/, '') !== id) {
window.history.replaceState(null, '', `#${id}`)
}
})
onMounted(() => {
window.addEventListener('keydown', onKey)
window.addEventListener('hashchange', onHashChange)
})
onBeforeUnmount(() => {
window.removeEventListener('keydown', onKey)
window.removeEventListener('hashchange', onHashChange)
})
</script>
<template>
<div class="dark relative h-screen w-screen overflow-hidden bg-zinc-950 text-white">
<!-- Slide viewport -->
<Transition :name="dir === 'fwd' ? 'slide-fwd' : 'slide-back'" mode="out-in">
<div :key="activeSlide.id" class="absolute inset-0 flex items-stretch">
<component :is="activeSlide.component" v-bind="activeSlide.props" />
</div>
</Transition>
<!-- Prev / next click zones (mobile + mouse) -->
<button
class="group absolute left-0 top-0 flex h-full w-16 items-center justify-center text-zinc-600 transition hover:bg-white/5 hover:text-white disabled:pointer-events-none disabled:opacity-0"
:disabled="current === 0"
aria-label="Previous slide"
@click="prev"
>
<ChevronLeft class="h-8 w-8" />
</button>
<button
class="group absolute right-0 top-0 flex h-full w-16 items-center justify-center text-zinc-600 transition hover:bg-white/5 hover:text-white disabled:pointer-events-none disabled:opacity-0"
:disabled="current === count - 1"
aria-label="Next slide"
@click="next"
>
<ChevronRight class="h-8 w-8" />
</button>
<!-- Bottom bar: counter + progress -->
<div class="absolute inset-x-0 bottom-0">
<div class="flex items-center justify-between px-6 pb-3 text-xs text-zinc-500">
<span class="font-medium tracking-wide">Intro to Nostr</span>
<span class="tabular-nums">{{ current + 1 }} / {{ count }}</span>
</div>
<div class="h-1 w-full bg-zinc-800">
<div class="h-full bg-violet-500 transition-all duration-300" :style="{ width: progress + '%' }" />
</div>
</div>
</div>
</template>
<style scoped>
.slide-fwd-enter-active,
.slide-fwd-leave-active,
.slide-back-enter-active,
.slide-back-leave-active {
transition:
opacity 0.25s ease,
transform 0.25s ease;
}
.slide-fwd-enter-from {
opacity: 0;
transform: translateX(3%);
}
.slide-fwd-leave-to {
opacity: 0;
transform: translateX(-3%);
}
.slide-back-enter-from {
opacity: 0;
transform: translateX(-3%);
}
.slide-back-leave-to {
opacity: 0;
transform: translateX(3%);
}
</style>