From 3e72f109ce0f8c1b3b96b617077392b987e706d7 Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Sun, 5 Jul 2026 03:01:45 -0400 Subject: [PATCH 1/5] feat(blog): tiered reader UI consuming editorial_tier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface the editorial taxonomy tier (added to the Post type in #212) in the reader UI. Tiers are reader-weight / IA only, never an auth, privacy, or ActivityPub-delivery signal; an absent tier means "not classified yet". - TierBadge.svelte: accessible, aria-labelled badge — stronger accent for noteworthy, muted outline for less-noteworthy, renders nothing when the tier is undefined. - BlogCard: badge in the metadata row; noteworthy cards get a gentle ring accent (featured styling still wins); no layout shift when tier is absent. - BlogArticle header: badge threaded through the static and broker paths. - /blog index: additive reader-weight ordering (date-desc preserved globally, noteworthy floats up only within an equal date via a stable sort); optional view-only tier filter chips that never drop posts. - Tag two posts to demonstrate both tiers (RE writeup -> noteworthy, recipe -> less-noteworthy), per the taxonomy migration guidance. No RSS/JSON feed shape change; no broker Worker path change. --- src/lib/components/BlogArticle.svelte | 8 ++ src/lib/components/BlogCard.svelte | 7 +- src/lib/components/TierBadge.svelte | 38 ++++++++++ src/lib/components/TierBadge.test.ts | 74 +++++++++++++++++++ ...ridge-whitelists-to-recover-nvme-drives.md | 1 + src/posts/2026-05-29-gingersnap-cookies.md | 1 + src/routes/blog/+page.svelte | 50 ++++++++++++- 7 files changed, 175 insertions(+), 4 deletions(-) create mode 100644 src/lib/components/TierBadge.svelte create mode 100644 src/lib/components/TierBadge.test.ts diff --git a/src/lib/components/BlogArticle.svelte b/src/lib/components/BlogArticle.svelte index 5b77d4dc..f5cc3b65 100644 --- a/src/lib/components/BlogArticle.svelte +++ b/src/lib/components/BlogArticle.svelte @@ -1,6 +1,8 @@ + +{#if tier} + + {#if tier === 'noteworthy'} + + {/if} + {TIER_LABELS[tier]} + +{/if} diff --git a/src/lib/components/TierBadge.test.ts b/src/lib/components/TierBadge.test.ts new file mode 100644 index 00000000..2c9260aa --- /dev/null +++ b/src/lib/components/TierBadge.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest'; +import { render } from 'svelte/server'; +import type { Post } from '$lib/types'; +import { POST_EDITORIAL_TIERS } from '$lib/types'; +import TierBadge from './TierBadge.svelte'; +import BlogCard from './BlogCard.svelte'; + +describe('TierBadge', () => { + it('renders a labelled badge for the noteworthy tier', () => { + const { html } = render(TierBadge, { props: { tier: 'noteworthy' } }); + + expect(html).toContain('data-testid="tier-badge"'); + expect(html).toContain('data-tier="noteworthy"'); + expect(html).toContain('Noteworthy'); + expect(html).toContain('aria-label="Editorial tier: Noteworthy"'); + }); + + it('renders a labelled badge for the less-noteworthy tier', () => { + const { html } = render(TierBadge, { props: { tier: 'less-noteworthy' } }); + + expect(html).toContain('data-testid="tier-badge"'); + expect(html).toContain('data-tier="less-noteworthy"'); + expect(html).toContain('Less noteworthy'); + expect(html).toContain('aria-label="Editorial tier: Less noteworthy"'); + }); + + it('renders a badge with an aria-label for every declared tier', () => { + for (const tier of POST_EDITORIAL_TIERS) { + const { html } = render(TierBadge, { props: { tier } }); + expect(html).toContain('aria-label="Editorial tier:'); + expect(html).toContain(`data-tier="${tier}"`); + } + }); + + it('renders nothing when the tier is undefined', () => { + const { html } = render(TierBadge, { props: { tier: undefined } }); + + expect(html).not.toContain('tier-badge'); + expect(html).not.toContain('aria-label'); + expect(html.replace(//g, '').trim()).toBe(''); + }); +}); + +function makePost(overrides: Partial = {}): Post { + return { + title: 'A Test Post', + slug: 'a-test-post', + date: '2026-03-04', + description: 'A description for the card.', + tags: ['test'], + published: true, + ...overrides, + }; +} + +describe('BlogCard editorial tier surface', () => { + it('shows the tier badge when the post carries a tier', () => { + const { html } = render(BlogCard, { + props: { post: makePost({ editorial_tier: 'noteworthy' }) }, + }); + + expect(html).toContain('data-testid="tier-badge"'); + expect(html).toContain('aria-label="Editorial tier: Noteworthy"'); + }); + + it('omits the tier badge entirely when the post has no tier', () => { + const { html } = render(BlogCard, { + props: { post: makePost() }, + }); + + expect(html).toContain('A Test Post'); + expect(html).not.toContain('tier-badge'); + }); +}); diff --git a/src/posts/2026-03-04-xram-injection-bypassing-usb-bridge-whitelists-to-recover-nvme-drives.md b/src/posts/2026-03-04-xram-injection-bypassing-usb-bridge-whitelists-to-recover-nvme-drives.md index 84bddf77..1a50b185 100644 --- a/src/posts/2026-03-04-xram-injection-bypassing-usb-bridge-whitelists-to-recover-nvme-drives.md +++ b/src/posts/2026-03-04-xram-injection-bypassing-usb-bridge-whitelists-to-recover-nvme-drives.md @@ -6,6 +6,7 @@ tags: ["nvme", "asm2362", "xram", "scsi", "usb-bridge", "reverse-engineering", " published: true slug: "xram-injection-bypassing-usb-bridge-whitelists-to-recover-nvme-drives" category: "hardware" +editorial_tier: "noteworthy" feature_image: "/images/posts/IMG_2127.jpg" source_repo: "Jesssullivan/hiberpower-ntfs" source_path: "docs/blog/xram-injection-guide.md" diff --git a/src/posts/2026-05-29-gingersnap-cookies.md b/src/posts/2026-05-29-gingersnap-cookies.md index 97e3a07f..19e46d2c 100644 --- a/src/posts/2026-05-29-gingersnap-cookies.md +++ b/src/posts/2026-05-29-gingersnap-cookies.md @@ -6,6 +6,7 @@ tags: ["recipe", "baking", "baking-science", "cookies", "gingersnap", "allergen- category: "tutorial" published: true slug: "gingersnap-cookies" +editorial_tier: "less-noteworthy" excerpt: "A milk-free, soy-free gingersnap recipe card, transcribed from a photo with Apple Vision OCR, metric equivalents, a scaler, and notes on the baking soda reaction." feature_image: "/images/posts/gingersnap-cookies.webp" thumbnail_image: "/images/posts/gingersnap-cookies.webp" diff --git a/src/routes/blog/+page.svelte b/src/routes/blog/+page.svelte index dbe101e4..85972ca8 100644 --- a/src/routes/blog/+page.svelte +++ b/src/routes/blog/+page.svelte @@ -37,16 +37,44 @@ brokerState.status === 'ready' ? mergeBrokerPostsIntoStatic(data.posts, brokerState.posts) : data.posts, ); - let totalPages = $derived(Math.ceil(displayPosts.length / POSTS_PER_PAGE)); + // Reader-weight ordering (docs/blog-editorial-taxonomy-2026-07-03.md): keep the + // date-desc contract globally and only tie-break WITHIN an equal date so + // 'noteworthy' posts float gently up. Array.sort is stable (ES2019+), so untiered + // posts keep their existing relative order — the change is additive and reversible. + let orderedPosts = $derived( + [...displayPosts].sort((a, b) => { + const ad = new Date(a.date).getTime(); + const bd = new Date(b.date).getTime(); + if (ad !== bd) return bd - ad; + const aw = a.editorial_tier === 'noteworthy' ? 1 : 0; + const bw = b.editorial_tier === 'noteworthy' ? 1 : 0; + return bw - aw; + }), + ); + + // Optional reader filter — a view-only lens, never drops posts from the corpus. + // Defaults to 'all', so nothing is hidden unless the reader opts in. + let tierFilter = $state<'all' | 'noteworthy' | 'less-noteworthy'>('all'); + const TIER_CHIPS = [ + { value: 'all', label: 'All' }, + { value: 'noteworthy', label: 'Noteworthy' }, + { value: 'less-noteworthy', label: 'Less noteworthy' }, + ] as const; + let hasTieredPosts = $derived(displayPosts.some((p) => p.editorial_tier)); + let visiblePosts = $derived( + tierFilter === 'all' ? orderedPosts : orderedPosts.filter((p) => p.editorial_tier === tierFilter), + ); + + let totalPages = $derived(Math.ceil(visiblePosts.length / POSTS_PER_PAGE)); let pageParam = $derived(browser ? parseInt($page.url.searchParams.get('page') || '1') : 1); let currentPage = $derived(Math.max(0, Math.min(pageParam - 1, totalPages - 1))); - let paginatedPosts = $derived(displayPosts.slice(currentPage * POSTS_PER_PAGE, (currentPage + 1) * POSTS_PER_PAGE)); + let paginatedPosts = $derived(visiblePosts.slice(currentPage * POSTS_PER_PAGE, (currentPage + 1) * POSTS_PER_PAGE)); // Collect all unique tags let allTags = $derived([...new Set(displayPosts.flatMap((p) => p.tags))].sort()); // Recent 5 posts for sidebar - let recentPosts = $derived(displayPosts.slice(0, 5)); + let recentPosts = $derived(orderedPosts.slice(0, 5)); let brokerStatusLabel = $derived( brokerState.status === 'ready' ? `Updated ${formatTimestamp(brokerState.stream.generatedAt)}` @@ -147,6 +175,22 @@
+ {#if hasTieredPosts} +
+ Tier + {#each TIER_CHIPS as chip (chip.value)} + + {/each} +
+ {/if} + {#if paginatedPosts.length === 0}

No posts yet.

{:else} From 49e82deb1ccba8ab5c835610cea1de8279e11c04 Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Sun, 5 Jul 2026 04:49:46 -0400 Subject: [PATCH 2/5] feat(blog): add shadow blended /stream ingestion surface + pulse tiers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build the next increment of the "blog as live-streamed ingestion surface" vision on the SHADOW UI only (tss.tinyland.dev). Everything additive and reversible; prod apex stays FROZEN. - /stream route (prerender, noindex+nofollow while held): one blended, reverse-chronological, tier-weighted timeline merging published long-form posts (src/lib/posts) with public pulse items (src/lib/pulse/load). Pure, deterministic blendStream(): date desc primary; noteworthy-first within an equal date (mirrors the #217 comparator); stable id tie-break so ordering never depends on sort stability. Each item shows a kind indicator + TierBadge. - Pulse tier badges: render TierBadge on PulseNoteCard/PulseBirdCard when salience is present; renders nothing when absent (graceful). The hashed static snapshot fixture is untouched — display wires up when real hub data populates salience. - Nav: add Stream + Pulse entries; /pulse highlights on its subroutes. - Tests: vitest for blendStream (interleave by date, noteworthy-first within equal date, empty inputs, absent-tier, deterministic, total-order tie-break) and pulse-card salience gating (TierBadge only when salience is set). Tiers/salience are reader-weight/IA only — never auth, privacy, or AP delivery (docs/blog-editorial-taxonomy-2026-07-03.md). HELD-for-freeze: shadow-only. --- src/lib/components/pulse/PulseBirdCard.svelte | 9 +- src/lib/components/pulse/PulseCards.test.ts | 24 ++++ src/lib/components/pulse/PulseNoteCard.svelte | 9 +- src/lib/stream/blend.test.ts | 114 ++++++++++++++++++ src/lib/stream/blend.ts | 90 ++++++++++++++ src/routes/+layout.svelte | 3 + src/routes/stream/+page.svelte | 82 +++++++++++++ src/routes/stream/+page.ts | 25 ++++ 8 files changed, 352 insertions(+), 4 deletions(-) create mode 100644 src/lib/stream/blend.test.ts create mode 100644 src/lib/stream/blend.ts create mode 100644 src/routes/stream/+page.svelte create mode 100644 src/routes/stream/+page.ts diff --git a/src/lib/components/pulse/PulseBirdCard.svelte b/src/lib/components/pulse/PulseBirdCard.svelte index a455a119..7cd57c58 100644 --- a/src/lib/components/pulse/PulseBirdCard.svelte +++ b/src/lib/components/pulse/PulseBirdCard.svelte @@ -1,5 +1,6 @@
-
- 🐦 bird sighting +
+ + 🐦 bird sighting + + +
{#if sighting} diff --git a/src/lib/components/pulse/PulseCards.test.ts b/src/lib/components/pulse/PulseCards.test.ts index 5d5b8a4e..2578a2a6 100644 --- a/src/lib/components/pulse/PulseCards.test.ts +++ b/src/lib/components/pulse/PulseCards.test.ts @@ -78,6 +78,30 @@ describe('Pulse cards', () => { }); }); +describe('Pulse card editorial salience surface', () => { + it('shows a TierBadge on a note card only when salience is present', () => { + const withSalience = render(PulseNoteCard, { + props: { item: { ...noteItem, salience: 'noteworthy' } }, + }); + expect(withSalience.html).toContain('data-testid="tier-badge"'); + expect(withSalience.html).toContain('aria-label="Editorial tier: Noteworthy"'); + + const withoutSalience = render(PulseNoteCard, { props: { item: noteItem } }); + expect(withoutSalience.html).not.toContain('tier-badge'); + }); + + it('shows a TierBadge on a bird card only when salience is present', () => { + const withSalience = render(PulseBirdCard, { + props: { item: { ...birdItem, salience: 'less-noteworthy' } }, + }); + expect(withSalience.html).toContain('data-testid="tier-badge"'); + expect(withSalience.html).toContain('data-tier="less-noteworthy"'); + + const withoutSalience = render(PulseBirdCard, { props: { item: birdItem } }); + expect(withoutSalience.html).not.toContain('tier-badge'); + }); +}); + describe('Pulse lab policy result rendering', () => { it('renders allowed projection decisions', () => { const decision: PolicyDecision = { allowed: true, item: noteItem }; diff --git a/src/lib/components/pulse/PulseNoteCard.svelte b/src/lib/components/pulse/PulseNoteCard.svelte index c516fbc2..93f8c5ef 100644 --- a/src/lib/components/pulse/PulseNoteCard.svelte +++ b/src/lib/components/pulse/PulseNoteCard.svelte @@ -1,5 +1,6 @@
-
- 💬 note +
+ + 💬 note + + +

{item.content}

diff --git a/src/lib/stream/blend.test.ts b/src/lib/stream/blend.test.ts new file mode 100644 index 00000000..bbf4c1f6 --- /dev/null +++ b/src/lib/stream/blend.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'vitest'; +import type { Post } from '$lib/types'; +import type { PublicPulseItem } from '@blog/pulse-core/schema'; +import { blendStream, compareStreamItems, type StreamItem } from './blend'; + +function makePost(overrides: Partial = {}): Post { + return { + title: 'A Post', + slug: 'a-post', + date: '2026-03-04', + description: 'desc', + tags: [], + published: true, + ...overrides, + }; +} + +function makePulse(overrides: Partial = {}): PublicPulseItem { + return { + id: 'pulse_1', + kind: 'note', + occurredAt: '2026-03-04T12:00:00.000Z', + summary: 'a pulse', + content: 'a pulse body', + tags: [], + ...overrides, + } as PublicPulseItem; +} + +describe('blendStream', () => { + it('returns an empty timeline for empty inputs', () => { + expect(blendStream([], [])).toEqual([]); + }); + + it('flattens posts and pulses into the unified shape', () => { + const items = blendStream([makePost()], [makePulse()]); + const post = items.find((i) => i.kind === 'post') as StreamItem; + const pulse = items.find((i) => i.kind === 'pulse') as StreamItem; + + expect(post.url).toBe('/blog/a-post'); + expect(post.id).toBe('post:a-post'); + expect(pulse.url).toBe('/pulse'); + expect(pulse.id).toBe('pulse:pulse_1'); + expect(pulse.pulseKind).toBe('note'); + }); + + it('interleaves posts and pulses in reverse-chronological order', () => { + const posts = [ + makePost({ slug: 'old-post', date: '2026-01-01' }), + makePost({ slug: 'new-post', date: '2026-06-01' }), + ]; + const pulses = [ + makePulse({ id: 'mid-pulse', occurredAt: '2026-03-01T00:00:00.000Z' }), + makePulse({ id: 'newest-pulse', occurredAt: '2026-06-30T00:00:00.000Z' }), + ]; + + const order = blendStream(posts, pulses).map((i) => i.id); + expect(order).toEqual(['pulse:newest-pulse', 'post:new-post', 'pulse:mid-pulse', 'post:old-post']); + }); + + it('floats noteworthy first within an equal date (mirrors the #217 comparator)', () => { + // All share the same instant so only the tier weight can reorder them. + const at = '2026-04-01T00:00:00.000Z'; + const items = blendStream( + [ + makePost({ slug: 'plain', date: '2026-04-01' }), + makePost({ slug: 'featured', date: '2026-04-01', editorial_tier: 'noteworthy' }), + ], + [makePulse({ id: 'loud', occurredAt: at, salience: 'noteworthy' })], + ); + + // The two noteworthy entries come before the untiered post. + expect(items[items.length - 1].id).toBe('post:plain'); + const noteworthyIds = items.slice(0, 2).map((i) => i.id); + expect(noteworthyIds).toContain('post:featured'); + expect(noteworthyIds).toContain('pulse:loud'); + }); + + it('treats less-noteworthy and absent tiers as equal (weight 0)', () => { + const items = blendStream( + [ + makePost({ slug: 'zzz', date: '2026-04-01', editorial_tier: 'less-noteworthy' }), + makePost({ slug: 'aaa', date: '2026-04-01' }), + ], + [], + ); + // Neither outranks the other on tier, so the deterministic id tie-break wins. + expect(items.map((i) => i.id)).toEqual(['post:aaa', 'post:zzz']); + }); + + it('is deterministic regardless of input ordering', () => { + const posts = [ + makePost({ slug: 'a', date: '2026-05-01' }), + makePost({ slug: 'b', date: '2026-05-01', editorial_tier: 'noteworthy' }), + ]; + const pulses = [ + makePulse({ id: 'p1', occurredAt: '2026-05-01T00:00:00.000Z' }), + makePulse({ id: 'p2', occurredAt: '2026-05-02T00:00:00.000Z', salience: 'noteworthy' }), + ]; + + const forward = blendStream(posts, pulses).map((i) => i.id); + const reversedPosts = blendStream([...posts].reverse(), [...pulses].reverse()).map((i) => i.id); + expect(reversedPosts).toEqual(forward); + }); +}); + +describe('compareStreamItems', () => { + it('imposes a total order (antisymmetric on the tie-break)', () => { + const a: StreamItem = { kind: 'post', id: 'post:a', title: 'a', summary: '', date: '2026-01-01', url: '/blog/a' }; + const b: StreamItem = { kind: 'post', id: 'post:b', title: 'b', summary: '', date: '2026-01-01', url: '/blog/b' }; + expect(Math.sign(compareStreamItems(a, b))).toBe(-Math.sign(compareStreamItems(b, a))); + expect(compareStreamItems(a, a)).toBe(0); + }); +}); diff --git a/src/lib/stream/blend.ts b/src/lib/stream/blend.ts new file mode 100644 index 00000000..7d4058ba --- /dev/null +++ b/src/lib/stream/blend.ts @@ -0,0 +1,90 @@ +// Pure blend/merge for the shadow ingestion "stream": one reverse-chronological, +// tier-weighted timeline that interleaves long-form blog POSTS with public +// PULSE items. Kept framework-free so the merge+sort is unit-testable without a +// DOM, and so /stream/+page.ts can call it at prerender time. +// +// Tiers here are reader-weight / IA ONLY (docs/blog-editorial-taxonomy-2026-07-03.md): +// never authorization, privacy, or ActivityPub-delivery signals. `editorial_tier` +// (posts) and `salience` (pulses) are the same display union. + +import type { Post, PostEditorialTier } from '$lib/types'; +import type { PublicPulseItem } from '@blog/pulse-core/schema'; + +export type StreamItemKind = 'post' | 'pulse'; + +/** A single blended timeline entry — a post or a pulse flattened to one shape. */ +export interface StreamItem { + readonly kind: StreamItemKind; + /** Stable, namespaced id (`post:` / `pulse:`) — also the sort tie-break. */ + readonly id: string; + readonly title: string; + readonly summary: string; + /** ISO date (post `YYYY-MM-DD`) or ISO datetime (pulse `occurredAt`). */ + readonly date: string; + /** Reader-weight tier: post `editorial_tier` or pulse `salience`. Absent = untiered. */ + readonly tier?: PostEditorialTier; + readonly url: string; + /** Present only for pulses, so the reader can show the pulse sub-kind icon. */ + readonly pulseKind?: PublicPulseItem['kind']; +} + +export function postToStreamItem(post: Post): StreamItem { + return { + kind: 'post', + id: `post:${post.slug}`, + title: post.title, + summary: post.description ?? post.excerpt ?? post.body_excerpt ?? '', + date: post.date, + tier: post.editorial_tier, + url: `/blog/${post.slug}`, + }; +} + +export function pulseItemToStreamItem(item: PublicPulseItem): StreamItem { + const summary = item.kind === 'bird_sighting' ? (item.birdSighting?.placeLabel ?? '') : item.content; + return { + kind: 'pulse', + id: `pulse:${item.id}`, + title: item.summary, + summary, + date: item.occurredAt, + tier: item.salience, + url: '/pulse', + pulseKind: item.kind, + }; +} + +// `noteworthy` floats gently up WITHIN an equal date; untiered and +// less-noteworthy keep weight 0. Mirrors the #217 blog-index comparator so the +// two surfaces rank consistently. This never influences delivery or policy. +function noteworthyWeight(tier: PostEditorialTier | undefined): number { + return tier === 'noteworthy' ? 1 : 0; +} + +/** + * Total order over blended items: date desc primary; within an equal date, + * noteworthy first; then a stable id tie-break so the result is fully + * deterministic regardless of input order (never relies on sort stability). + */ +export function compareStreamItems(a: StreamItem, b: StreamItem): number { + const ad = new Date(a.date).getTime(); + const bd = new Date(b.date).getTime(); + if (ad !== bd) return bd - ad; + + const weightDiff = noteworthyWeight(b.tier) - noteworthyWeight(a.tier); + if (weightDiff !== 0) return weightDiff; + + if (a.id < b.id) return -1; + if (a.id > b.id) return 1; + return 0; +} + +/** + * Merge published posts + public pulses into one reverse-chronological, + * tier-weighted timeline. Pure and deterministic: same inputs (in any order) + * always yield the same ordering. Handles empty inputs and absent tiers. + */ +export function blendStream(posts: readonly Post[], pulseItems: readonly PublicPulseItem[]): StreamItem[] { + const items: StreamItem[] = [...posts.map(postToStreamItem), ...pulseItems.map(pulseItemToStreamItem)]; + return items.sort(compareStreamItems); +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 451e7107..9392084d 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -45,6 +45,8 @@ const navLinks = [ { href: '/blog', label: 'Blog' }, + { href: '/stream', label: 'Stream' }, + { href: '/pulse', label: 'Pulse' }, { href: '/photography', label: 'Photography' }, { href: '/music', label: 'Music' }, { href: '/making', label: 'Making' }, @@ -73,6 +75,7 @@ function isActive(href: string): boolean { const path = $page.url.pathname; if (href === '/blog') return path.startsWith('/blog'); + if (href === '/pulse') return path.startsWith('/pulse'); if (href === '/photography') return path.startsWith('/photography'); return path === href; } diff --git a/src/routes/stream/+page.svelte b/src/routes/stream/+page.svelte new file mode 100644 index 00000000..01b71231 --- /dev/null +++ b/src/routes/stream/+page.svelte @@ -0,0 +1,82 @@ + + + + Stream — transscendsurvival.org + + + + + +
+
+

Stream

+

+ One blended timeline: long-form posts and public + pulse items, interleaved in reverse chronological order + with a gentle reader-weight lift for noteworthy entries. +

+

{items.length} items

+
+ + {#if items.length === 0} +

Nothing in the stream yet.

+ {:else} +
    + {#each items as item (item.id)} +
  1. +
    +
    + + {kindLabel(item)} + + + +
    +

    + {item.title} +

    + {#if item.summary} +

    {item.summary}

    + {/if} +
    +
  2. + {/each} +
+ {/if} +
diff --git a/src/routes/stream/+page.ts b/src/routes/stream/+page.ts new file mode 100644 index 00000000..81345d81 --- /dev/null +++ b/src/routes/stream/+page.ts @@ -0,0 +1,25 @@ +import { getPosts } from '$lib/posts'; +import { loadPulseSnapshot } from '$lib/pulse/load'; +import { blendStream } from '$lib/stream/blend'; +import type { PublicPulseItem } from '@blog/pulse-core/schema'; +import type { PageLoad } from './$types'; + +export const prerender = true; + +// Shadow ingestion surface: blend published long-form posts with the public +// pulse snapshot into one reverse-chronological, tier-weighted timeline. The +// pulse snapshot is best-effort — if it is missing or fails validation the +// stream still prerenders from posts alone (graceful, never blocks the build). +export const load: PageLoad = async ({ fetch }) => { + const posts = await getPosts(); + + let pulseItems: PublicPulseItem[] = []; + try { + const snapshot = await loadPulseSnapshot(fetch); + pulseItems = snapshot.items; + } catch { + pulseItems = []; + } + + return { items: blendStream(posts, pulseItems) }; +}; From 7260fe6c1492105eae994dfa46297ea31959ffb6 Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Tue, 7 Jul 2026 02:57:43 -0400 Subject: [PATCH 3/5] ci(shadow): auto-deploy tss-shadow project on shadow-branch pushes Pushes to feat/blog-tiered-reader-ui now deploy the tss-shadow Pages project (production -> tss.tinyland.dev) so the shadow host tracks the branch. main -> transscendsurvival-org routing is byte-identical; PRs remain build-only. Lives on the held shadow branch only. --- .github/workflows/cloudflare-pages-shadow.yml | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/.github/workflows/cloudflare-pages-shadow.yml b/.github/workflows/cloudflare-pages-shadow.yml index 97dd8452..9d0d2104 100644 --- a/.github/workflows/cloudflare-pages-shadow.yml +++ b/.github/workflows/cloudflare-pages-shadow.yml @@ -20,7 +20,10 @@ on: - "src/**" - "static/**" push: - branches: [main] + # feat/blog-tiered-reader-ui: pushes to the held shadow branch auto-deploy + # the tss-shadow Pages project (tss.tinyland.dev) so the shadow host never + # goes stale. Prod (main -> transscendsurvival-org) is untouched. + branches: [main, feat/blog-tiered-reader-ui] paths: - ".github/workflows/cloudflare-pages-shadow.yml" - "package.json" @@ -98,16 +101,28 @@ jobs: echo "::notice title=Cloudflare shadow deploy skipped::${message}" fi + # Route: main -> transscendsurvival-org production (prod apex, unchanged); + # any other pushed branch -> the tss-shadow project's production + # deployment, which serves tss.tinyland.dev (the shadow host). + project="${CLOUDFLARE_PAGES_PROJECT_NAME}" + pages_branch="${deploy_branch}" + if [ "${deploy_branch}" != "main" ]; then + project="tss-shadow" + pages_branch="main" + fi + { echo "can_deploy=${can_deploy}" echo "branch=${deploy_branch}" + echo "project=${project}" + echo "pages_branch=${pages_branch}" } >> "$GITHUB_OUTPUT" { echo "## Cloudflare Pages Shadow" echo "" - echo "- Project: \`${CLOUDFLARE_PAGES_PROJECT_NAME}\`" - echo "- Branch: \`${deploy_branch}\`" + echo "- Project: \`${project}\`" + echo "- Branch: \`${deploy_branch}\` -> pages branch \`${pages_branch}\`" echo "- Deploy enabled: \`${can_deploy}\`" } >> "$GITHUB_STEP_SUMMARY" @@ -118,5 +133,5 @@ jobs: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} wranglerVersion: "4.95.0" - command: pages deploy build --project-name=${{ env.CLOUDFLARE_PAGES_PROJECT_NAME }} --branch=${{ steps.cloudflare.outputs.branch }} --commit-dirty=true + command: pages deploy build --project-name=${{ steps.cloudflare.outputs.project }} --branch=${{ steps.cloudflare.outputs.pages_branch }} --commit-dirty=true gitHubToken: ${{ secrets.GITHUB_TOKEN }} From fb45f2f9eb9233072b1dc00a5882396c529084b1 Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Tue, 7 Jul 2026 04:59:31 -0400 Subject: [PATCH 4/5] =?UTF-8?q?fix(nav):=20desktop=20nav=20at=20lg:=20not?= =?UTF-8?q?=20md:=20=E2=80=94=20Stream+Pulse=20overflow=20768px?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With Stream+Pulse added the full desktop nav measures ~830px, which overflows exactly at the md (768px) breakpoint (e2e prose-overflow caught it: 3 slug pages). Nav now appears at lg:, drawer covers tablet. Verified 9/9 (768/1024/375 x slug/stream/index) via vite preview. --- src/routes/+layout.svelte | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 9392084d..5673d5ab 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -124,7 +124,10 @@ -