Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/tooltip-viewport-clamp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"nostream-website": patch
---

Info-tip bubbles no longer clip at the viewport edge: the tooltip is centered on its (i) toggle, so near an edge it overflowed. On open (hover, tap or keyboard) the bubble now shifts horizontally to stay inside the viewport, and re-clamps on resize.

Tooltip behavior is delegated at the document level, so every tip is covered even when its per-element listeners would have silently failed to attach.

Site polish from an external review: the community participate cards have their own section heading, the hero headline is real DOM text with a pause control on the film, the console screenshots are labeled as real first-run console data, the honest-advertising copy matches what its warning shows, the nav's active-path comparison strips the deploy base, the get-started phase list explains the skipped numbers, and single-column grids no longer let nowrap content push the page wider than a phone viewport. The hero film is re-rendered at 1080p for the current 25-NIP list.
9 changes: 9 additions & 0 deletions .github/workflows/deploy-website-preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,15 @@ jobs:
run: |
pnpm run build
node scripts/apply-base.mjs
# The assets manifest is keyed by file path: the worker serves dist/
# at the root of its URL, but the preview is requested under
# /preview/<n>/... — so the built tree must also EXIST at that path
# or every asset request falls through to the SPA-fallback
# index.html (text/html for .css/.mp4 requests — the broken preview).
# Copy the build into dist/preview/<n>/ (excluding the copy itself).
n="${{ github.event.pull_request.number }}"
mkdir -p "dist/preview/$n"
find dist -mindepth 1 -maxdepth 1 ! -name preview -exec cp -r {} "dist/preview/$n/" \;

- name: Mark the preview comment as rendering
if: steps.guard.outputs.skip == 'false' && github.event.action != 'closed'
Expand Down
Binary file modified website/site/public/media/hero-low.mp4
Binary file not shown.
Binary file modified website/site/public/media/hero-poster.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 4 additions & 2 deletions website/site/public/media/hero.meta.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
{
"nipCount": 24,
"generatedAt": "2026-10-15T07:03:58.735Z"
"nipCount": 25,
"generatedAt": "2026-10-16T21:15:11.228Z",
"sourceVideo": "output/nostream-hero.mp4",
"sourceLowVideo": "output/nostream-hero-low.mp4"
}
Binary file modified website/site/public/media/hero.mp4
Binary file not shown.
Binary file modified website/site/public/media/planet.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
66 changes: 57 additions & 9 deletions website/site/src/components/InfoTip.astro
Original file line number Diff line number Diff line change
Expand Up @@ -108,25 +108,73 @@ const id = `tip-${Astro.props['detail'] ? '' : ''}${Math.random().toString(36).s
</style>

<script>
// Close any open tooltip when clicking elsewhere or pressing Escape —
// the keyboard/tap dismiss path for the native <details> open state.
// All behavior is DELEGATED at the document level: per-element listeners
// proved fragile (one tip ended up unhooked in a real-browser trace, with
// no error and no way to see it from markup). Delegation covers every
// current and future .info-tip regardless of node replacement.
const closeAll = (except?: Element | null) => {
document.querySelectorAll('details.info-tip[open]').forEach((d) => {
if (d !== except) d.removeAttribute('open')
})
}
// Keep aria-expanded truthful and clamp the bubble inside the viewport.
// The bubble is centered on its (i) toggle by the stylesheet's
// translateX(-50%); near a viewport edge the centered box overflows and
// clips (CSS max-width bounds the width, not the position).
const clamp = (details: Element) => {
const bubble = details.querySelector<HTMLElement>('.tip-bubble')
if (!bubble) return
// Clear only any PREVIOUS inline shift; the stylesheet's own
// translateX(-50%) stays applied, so the measured rect IS the rendered
// (centered) box — no half-width arithmetic.
bubble.style.transform = ''
const pad = 12
const rect = bubble.getBoundingClientRect()
const overshootLeft = pad - rect.left
const overshootRight = rect.right - (window.innerWidth - pad)
if (overshootLeft > 0) {
bubble.style.transform = `translateX(-50%) translateX(${overshootLeft}px)`
} else if (overshootRight > 0) {
bubble.style.transform = `translateX(-50%) translateX(${-overshootRight}px)`
}
}
// toggle does not bubble; capture at the document sees every details.
document.addEventListener(
'toggle',
(e) => {
const d = (e.target as Element | null)?.closest?.('details.info-tip')
if (!d) return
d.querySelector('summary')?.setAttribute('aria-expanded', d.hasAttribute('open') ? 'true' : 'false')
if (d.hasAttribute('open')) {
// Measure after layout so getBoundingClientRect sees the real rect.
requestAnimationFrame(() => clamp(d))
} else if (d.matches(':hover')) {
// Tap-close while the pointer stays over the tooltip: :hover keeps
// the bubble visible, so re-clamp instead of clearing the shift.
clamp(d)
} else {
const bubble = d.querySelector<HTMLElement>('.tip-bubble')
if (bubble) bubble.style.transform = ''
}
Comment thread
chappie-daemon marked this conversation as resolved.
},
true,
)
// Hover path: the bubble opens via CSS without a toggle event. pointerenter
// does not bubble either — use pointerover delegation on the toggle/summary.
document.addEventListener('pointerover', (e) => {
const d = (e.target as Element | null)?.closest?.('details.info-tip')
if (d) clamp(d)
})
document.addEventListener('click', (e) => {
const tip = (e.target as Element | null)?.closest?.('details.info-tip')
closeAll(tip)
})
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeAll(null)
})
// Keep aria-expanded truthful for screen readers: the toggle event fires on
// the <details> element (not the summary), including programmatic closes.
document.querySelectorAll('details.info-tip').forEach((d) => {
d.addEventListener('toggle', () => {
d.querySelector('summary')?.setAttribute('aria-expanded', d.hasAttribute('open') ? 'true' : 'false')
})
})
// Re-clamp when the viewport moves under a visible bubble (rotation,
// resize) — hover-opened bubbles have no [open], so match both.
window.addEventListener('resize', () => {
document.querySelectorAll('details.info-tip[open], details.info-tip:hover').forEach(clamp)
}, { passive: true })
</script>
29 changes: 26 additions & 3 deletions website/site/src/components/Nav.astro
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@
import Logo from './Logo.astro'
import { NAV, REPO, VERSION } from '../data/site'

const current = Astro.url.pathname.replace(/\/$/, '') || '/'
// Astro.url.pathname carries the configured base (preview deploys), so strip
// it before comparing against the base-relative NAV hrefs.
const base = import.meta.env.BASE_URL.replace(/\/$/, '')
const current = base && (Astro.url.pathname.replace(/\/$/, '') || '/').startsWith(base)
? (Astro.url.pathname.replace(/\/$/, '') || '/').slice(base.length) || '/'
: Astro.url.pathname.replace(/\/$/, '') || '/'
const isActive = (href: string) => current === href || current.startsWith(`${href}/`)
---

Expand All @@ -22,7 +27,7 @@ const isActive = (href: string) => current === href || current.startsWith(`${hre

<div class="actions">
<a class="version mono" href="/changelog">v{VERSION}</a>
<a class="btn btn-primary get-started" href="/get-started">Get started</a>
<a class:list={['btn btn-primary get-started', { self: isActive('/get-started') }]} href={isActive('/get-started') ? '#build' : '/get-started'}>Get started</a>
<a class="gh" href={REPO} target="_blank" rel="noopener noreferrer">
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"
><path
Expand All @@ -39,7 +44,7 @@ const isActive = (href: string) => current === href || current.startsWith(`${hre
</div>

<nav class="drawer" aria-label="Mobile" data-drawer>
<a class="drawer-cta" href="/get-started">Get started</a>
<a class="drawer-cta" href={isActive('/get-started') ? '#build' : '/get-started'}>Get started</a>
Comment thread
chappie-daemon marked this conversation as resolved.
{NAV.map((item) => <a href={item.href}>{item.label}</a>)}
<a href={REPO} target="_blank" rel="noopener noreferrer">GitHub</a>
</nav>
Expand All @@ -58,6 +63,16 @@ const isActive = (href: string) => current === href || current.startsWith(`${hre
menu.setAttribute('aria-expanded', String(open))
menu.setAttribute('aria-label', open ? 'Close menu' : 'Open menu')
})

// Same-page hash links from the mobile drawer (the Get-started CTA on
// /get-started) scroll under the still-open drawer — close it first.
document.querySelectorAll<HTMLAnchorElement>('.drawer a[href^="#"]').forEach((a) => {
a.addEventListener('click', () => {
nav?.classList.remove('open')
menu?.setAttribute('aria-expanded', 'false')
menu?.setAttribute('aria-label', 'Open menu')
})
})
</script>

<style>
Expand Down Expand Up @@ -131,6 +146,14 @@ const isActive = (href: string) => current === href || current.startsWith(`${hre
font-size: 13px;
}

/* On /get-started the CTA scrolls to the build block instead of linking
to the page you are already on — keep it visible but quiet. */
.get-started.self {
border: 1px solid var(--panel-border);
background: none;
color: var(--fg-2);
}

.gh {
display: inline-flex;
align-items: center;
Expand Down
1 change: 1 addition & 0 deletions website/site/src/components/home/Audience.astro
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ const audiences = [
.rail {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 18px;
}

Expand Down
17 changes: 14 additions & 3 deletions website/site/src/components/home/Console.astro
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ const shots = [
{
name: 'mismatches',
title: 'Honest advertising',
body: 'Catches when your relay promises clients something it does not actually do.',
tip: 'NIP-11 mismatch warnings: flags when what your relay advertises disagrees with what probes measure.',
body: "Flags the gap between what your relay advertises and what it actually does — in either direction.",
tip: 'NIP-11 mismatch warnings: compares what your relay publishes in supported_nips with what probes measure, and flags any disagreement.',
tag: 'NIP-11',
},
{
Expand Down Expand Up @@ -68,6 +68,10 @@ const capabilities = [
<ul class="caps">
{capabilities.map((c) => <li>{c}</li>)}
</ul>
<p class="demo-note">
Screenshots from a real console — this one ran on a laptop catching its first events, rejections and failed
alert deliveries included.
</p>
</div>
</div>

Expand Down Expand Up @@ -139,9 +143,16 @@ const capabilities = [
font-size: 12px;
}

.demo-note {
margin: 16px 0 0;
color: var(--fg-2);
font-size: 13px;
}

.gallery {
display: grid;
gap: 28px 20px;
grid-template-columns: minmax(0, 1fr);
gap: 20px 16px;
}

@media (min-width: 900px) {
Expand Down
105 changes: 104 additions & 1 deletion website/site/src/components/home/Hero.astro
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,13 @@ import { VERSION } from '../../data/site'
---

<section class="stage" data-spark-host data-spark-mode="stream">
<h1 class="sr-only">nostream — a production-ready Nostr relay. Your relay, your rules.</h1>
<h1 class="hero-h1">
<span class="g1">Nostr runs on relays.</span>
<span class="g2">nostream is the one you own.</span>
</h1>
<p class="hero-sub sr-only">
A production-ready Nostr relay you deploy and control — self-hosted and open source.
</p>
<canvas
class="spark-canvas"
data-spark-canvas
Expand All @@ -19,6 +25,7 @@ import { VERSION } from '../../data/site'
poster={`${import.meta.env.BASE_URL}media/hero-poster.webp`.replace(/\/\//, '/')}
title={`/hero · nostream ${VERSION}`}
/>
<button class="film-toggle mono" type="button" data-film-toggle aria-pressed="false" aria-label="Pause the hero film">Pause</button>
Comment thread
chappie-daemon marked this conversation as resolved.
</section>

<style>
Expand All @@ -36,6 +43,53 @@ import { VERSION } from '../../data/site'
background: var(--bg);
}

/* The headline used to live only inside the rendered film — invisible to
search and screen readers. It is real text now, echoing the film's
second scene; the film keeps telling the full story beside it. The
film frame's own chrome starts lower, so the headline sits in the
clear above it. */
.hero-h1 {
position: absolute;
z-index: 3;
left: var(--pad-x);
top: clamp(12px, 2.2vh, 26px);
max-width: min(560px, calc(100% - 2 * var(--pad-x)));
margin: 0;
font-size: clamp(24px, 3vw, 38px);
line-height: 1.12;
letter-spacing: -0.02em;
pointer-events: none;
}

.hero-h1 span {
display: block;
}

.hero-h1 .g2 {
color: var(--pink);
}

.film-toggle {
position: absolute;
z-index: 4;
left: var(--pad-x);
bottom: clamp(18px, 3.5vh, 44px);
padding: 8px 14px;
border: 1px solid var(--panel-border);
border-radius: 7px;
background: rgba(14, 14, 17, 0.8);
color: var(--fg-2);
font-size: 12px;
letter-spacing: 0.08em;
cursor: pointer;
transition: border-color 0.2s ease, color 0.2s ease;
}

.film-toggle:hover {
border-color: var(--wire);
color: var(--fg);
}

.spark-canvas {
position: absolute;
inset: 0;
Expand Down Expand Up @@ -74,6 +128,20 @@ import { VERSION } from '../../data/site'
padding: var(--pad-y) var(--pad-x);
}

/* Headline becomes normal flow above the film; the pause control stays
pinned to the stage's bottom-left. */
.hero-h1 {
position: static;
max-width: none;
margin-bottom: 18px;
pointer-events: auto;
}

.film-toggle {
position: static;
margin-top: 14px;
}

.stage :global(.hero-film) {
position: relative;
top: auto;
Expand Down Expand Up @@ -107,3 +175,38 @@ import { VERSION } from '../../data/site'
border: 0;
}
</style>

<script>
// The Base layout plays the film only while visible; this adds the manual
// pause. Base's IntersectionObserver re-plays on scroll, so a deliberately
// paused film is kept paused by intercepting the play event. The label is
// driven by actual playback state — under prefers-reduced-motion the film
// starts paused, so the button must offer Play, not Pause.
let userPaused = false
const button = document.querySelector<HTMLButtonElement>('[data-film-toggle]')
const video = document.querySelector<HTMLVideoElement>('.hero-film video')
const sync = () => {
if (!button || !video) return
const paused = video.paused
button.textContent = paused ? 'Play' : 'Pause'
button.setAttribute('aria-pressed', String(paused))
button.setAttribute('aria-label', paused ? 'Play the hero film' : 'Pause the hero film')
}
video?.addEventListener('play', sync)
video?.addEventListener('pause', () => {
// A user pause must survive Base's observer re-play; a scroll-off pause
// (userPaused false) just follows the video's own state.
sync()
})
video?.addEventListener('play', () => {
if (userPaused) video.pause()
})
button?.addEventListener('click', () => {
if (!video) return
userPaused = !video.paused
if (video.paused) void video.play()
else video.pause()
sync()
})
sync()
</script>
4 changes: 4 additions & 0 deletions website/site/src/components/home/Pillars.astro
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,10 @@ const pillars = [
.bento {
display: grid;
gap: 16px;
/* Grid tracks default to min-width:auto, so the nowrap .cmd in the I/O
cell forces the track (and the page) wider than the viewport on
mobile. Floor the track at 0 and let the cmd scroll inside itself. */
grid-template-columns: minmax(0, 1fr);
}

@media (min-width: 900px) {
Expand Down
Loading
Loading