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
1,049 changes: 1,049 additions & 0 deletions docs/explorations/0196_[x]_IMAGE_RICH_INTERACTIVE_CHANGELOG_PAGE.md

Large diffs are not rendered by default.

21 changes: 17 additions & 4 deletions site/scripts/validate-changelog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,24 @@ function checkRequired(e: ChangelogEntry): void {
if (!e.summary) err(id, 'missing summary')
if (!e.highlights?.length) err(id, 'must have at least one highlight')
if (!e.tags?.length) err(id, 'must have at least one tag')
if (e.hero) {
const ok = e.hero.src.startsWith('/') || e.hero.src.startsWith('https://')
if (!ok) err(id, `hero.src must be an absolute path or https URL (${e.hero.src})`)
if (!e.hero.alt) err(id, 'hero is missing alt text')
if (e.hero) checkImageSrc(id, 'hero', e.hero.src, e.hero.alt)
for (const [i, img] of (e.images ?? []).entries()) {
checkImageSrc(id, `images[${i}]`, img.src, img.alt)
}
if (e.video) {
checkImageSrc(id, 'video.poster', e.video.poster, e.video.alt)
if (!e.video.src.startsWith('/') && !e.video.src.startsWith('https://')) {
err(id, `video.src must be an absolute path or https URL (${e.video.src})`)
}
}
if (e.author && !e.author.login) err(id, 'author is missing a login')
}

function checkImageSrc(id: string, field: string, src: string, alt: string): void {
if (!src.startsWith('/') && !src.startsWith('https://')) {
err(id, `${field}.src must be an absolute path or https URL (${src})`)
}
if (!alt) err(id, `${field} is missing alt text`)
}

function checkUniqueIds(): void {
Expand Down
239 changes: 239 additions & 0 deletions site/src/components/changelog/Gallery.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
---
/**
* Changelog media gallery (exploration 0196). Zero runtime dependencies:
* - before/after surfaces → a hand-rolled, keyboard-accessible CSS slider
* - new/curated surfaces → a CSS scroll-snap thumbnail strip → <dialog> lightbox
* - interaction flows → a click-to-play <video> with a poster frame
* The interactive behaviour (lightbox + slider drag) is wired by the island
* script in changelog/index.astro, which operates on the markup rendered here.
*/
interface Thumb {
src: string
alt: string
caption?: string
}
interface Comparison {
before: string
after: string
alt: string
}
interface Video {
mp4: string
poster: string
alt: string
}
interface Props {
entryId: string
comparisons?: Comparison[]
thumbs?: Thumb[]
video?: Video
galleryLink?: { prUrl: string; count: number; pr: number }
}
const { entryId, comparisons = [], thumbs = [], video, galleryLink } = Astro.props
const hasMedia = comparisons.length > 0 || thumbs.length > 0 || Boolean(video)
---

{
hasMedia && (
<div class="cl-gallery mb-4 mt-1">
{comparisons.map((c) => (
<figure
class="cl-cmp"
tabindex="0"
aria-label={`Before and after: ${c.alt}. Use left and right arrow keys to compare.`}
>
<img class="cl-cmp-after" src={c.after} alt={`After — ${c.alt}`} loading="lazy" />
<div class="cl-cmp-before-wrap" aria-hidden="true">
<img class="cl-cmp-before" src={c.before} alt="" loading="lazy" />
</div>
<span class="cl-cmp-divider" aria-hidden="true" />
<input
class="cl-cmp-range"
type="range"
min="0"
max="100"
value="50"
aria-label={`Reveal before/after for ${c.alt}`}
/>
<figcaption class="cl-cmp-cap">{c.alt} — before / after</figcaption>
</figure>
))}

{thumbs.length > 0 && (
<ul class="cl-strip" role="list" aria-label="Screenshots for this release">
{thumbs.map((t) => (
<li>
<button
type="button"
class="cl-thumb"
data-full={t.src}
data-alt={t.alt}
data-group={entryId}
aria-label={`View ${t.alt}`}
>
<img src={t.src} alt={t.alt} loading="lazy" />
{t.caption && <span class="cl-thumb-cap">{t.caption}</span>}
</button>
</li>
))}
</ul>
)}

{video && (
<figure class="cl-video-wrap">
<video
class="cl-video"
poster={video.poster}
controls
muted
loop
playsinline
preload="none"
aria-label={video.alt}
>
<source src={video.mp4} type="video/mp4" />
</video>
<figcaption class="cl-cmp-cap">{video.alt}</figcaption>
</figure>
)}

{galleryLink && (
<a
class="cl-gallery-link"
href={galleryLink.prUrl}
target="_blank"
rel="noopener noreferrer"
>
View all {galleryLink.count} screenshot{galleryLink.count === 1 ? '' : 's'} on PR #
{galleryLink.pr} →
</a>
)}
</div>
)
}

<style>
.cl-gallery > * + * {
margin-top: 0.75rem;
}

/* Before/after comparison — the "after" image defines the box; "before" is
overlaid and clipped to --cl-pos (set by the island script in index.astro). */
.cl-cmp {
position: relative;
display: block;
margin: 0;
border: 1px solid var(--lp-border);
border-radius: 0.5rem;
overflow: hidden;
--cl-pos: 50%;
}
.cl-cmp:focus-visible {
outline: 2px solid #6366f1;
outline-offset: 2px;
}
.cl-cmp-after {
display: block;
width: 100%;
height: auto;
}
.cl-cmp-before-wrap {
position: absolute;
inset: 0;
clip-path: inset(0 calc(100% - var(--cl-pos)) 0 0);
}
.cl-cmp-before {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.cl-cmp-divider {
position: absolute;
top: 0;
bottom: 0;
left: var(--cl-pos);
width: 2px;
margin-left: -1px;
background: #6366f1;
pointer-events: none;
}
.cl-cmp-range {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
margin: 0;
opacity: 0;
cursor: ew-resize;
}
.cl-cmp-cap {
padding: 0.35rem 0.5rem;
font-size: 0.7rem;
color: #9ca3af;
}

/* Thumbnail strip → lightbox */
.cl-strip {
display: flex;
gap: 0.5rem;
overflow-x: auto;
scroll-snap-type: x mandatory;
list-style: none;
margin: 0;
padding: 0 0 0.35rem 0;
scrollbar-width: thin;
}
.cl-strip li {
flex: 0 0 auto;
scroll-snap-align: start;
}
.cl-thumb {
position: relative;
display: block;
padding: 0;
border: 1px solid var(--lp-border);
border-radius: 0.5rem;
overflow: hidden;
cursor: pointer;
background: none;
}
.cl-thumb:hover {
border-color: #6366f1;
}
.cl-thumb img {
display: block;
width: 220px;
height: 138px;
object-fit: cover;
}
.cl-thumb-cap {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 0.15rem 0.4rem;
background: rgba(0, 0, 0, 0.55);
color: #fff;
font-size: 0.65rem;
text-align: left;
}

.cl-video-wrap {
margin: 0;
}
.cl-video {
width: 100%;
border: 1px solid var(--lp-border);
border-radius: 0.5rem;
}

.cl-gallery-link {
display: inline-block;
font-size: 0.8rem;
color: #6366f1;
}
.cl-gallery-link:hover {
text-decoration: underline;
}
</style>
41 changes: 41 additions & 0 deletions site/src/data/changelog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,16 @@ export interface ChangelogEntry {
tags: ChangelogTag[]
/** Optional hero image (absolute site path or https URL). */
hero?: { src: string; alt: string }
/**
* Curated gallery images (absolute path or https URL). Shown in addition to
* any auto-pulled diff-manifest gallery (exploration 0196); use for entries
* whose PR predates the durable visual capture, or to hand-pick screenshots.
*/
images?: { src: string; alt: string; caption?: string }[]
/** Autoplay-muted, click-to-play video clip (replaces a GIF). */
video?: { src: string; poster: string; alt: string }
/** Primary author, surfaced as a GitHub avatar + link. */
author?: { login: string; name?: string }
/** Originating pull request number, when applicable. */
pr?: number
}
Expand All @@ -69,8 +79,36 @@ export const entries: ChangelogEntry[] = [
],
tags: ['app', 'platform', 'ci'],
hero: { src: '/images/workbench-dark.png', alt: 'The xNet workbench' },
images: [
{
src: 'https://xnet.fyi/visuals/pr/147/routes/home.png',
alt: 'The xNet home view',
caption: 'Home — captured by CI'
},
{
src: '/images/workbench-light.png',
alt: 'The xNet workbench in light mode',
caption: 'Workbench (light)'
}
],
author: { login: 'crs48' },
pr: 147
},
{
id: '2026-06-16',
date: 'June 2026',
title: 'xNet Cloud — managed hub hosting',
summary:
'Don’t want to run your own hub? xNet Cloud hosts one for you. A new onboarding flow takes you from signup to a provisioned hub, then lets you claim it from the app.',
highlights: [
'Marketing and pricing pages for managed hub hosting',
'Signup → provision → claim flow',
'Connect the app to your hosted hub from Settings'
],
tags: ['platform', 'app'],
author: { login: 'crs48' },
pr: 140
},
{
id: '2026-06-15',
date: 'June 2026',
Expand All @@ -83,6 +121,7 @@ export const entries: ChangelogEntry[] = [
'Foundations for an AI → Lab → Plugin assembly line'
],
tags: ['plugins', 'ai', 'editor', 'platform'],
author: { login: 'crs48' },
pr: 144
},
{
Expand All @@ -97,6 +136,7 @@ export const entries: ChangelogEntry[] = [
'A searchable marketplace index with install-consent prompts'
],
tags: ['plugins', 'platform'],
author: { login: 'crs48' },
pr: 142
},
{
Expand Down Expand Up @@ -125,6 +165,7 @@ export const entries: ChangelogEntry[] = [
'Tier preference persists between sessions'
],
tags: ['ai', 'app'],
author: { login: 'crs48' },
pr: 137
},
{
Expand Down
15 changes: 14 additions & 1 deletion site/src/lib/changelog-feed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,20 @@ export function buildJsonFeed(entries: ChangelogEntry[]): object {
date: entry.date,
summary: entry.summary,
highlights: entry.highlights,
...(entry.pr ? { pr: entry.pr } : {})
...(entry.pr ? { pr: entry.pr } : {}),
...(entry.author ? { author: entry.author } : {}),
...(entry.images?.length
? { images: entry.images.map((img) => ({ ...img, src: absoluteImage(img.src) })) }
: {}),
...(entry.video
? {
video: {
...entry.video,
src: absoluteImage(entry.video.src),
poster: absoluteImage(entry.video.poster)
}
}
: {})
}
}))
}
Expand Down
Loading
Loading