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 }} diff --git a/e2e/visual-qa.spec.ts b/e2e/visual-qa.spec.ts index b2a43574..a8789b74 100644 --- a/e2e/visual-qa.spec.ts +++ b/e2e/visual-qa.spec.ts @@ -43,13 +43,16 @@ test.describe('Homepage — visual QA across viewports', () => { }); test('navigation is accessible', async ({ page }) => { - if (vp.width >= 768) { - // Desktop nav: visible inline links (md: breakpoint = 768px) - const desktopNav = page.locator('nav.hidden.md\\:flex'); + if (vp.width >= 1024) { + // Desktop nav: visible inline links (lg: breakpoint = 1024px — + // with Stream+Pulse the full nav overflows below that, so + // tablet uses the drawer; see +layout.svelte nav comment) + const desktopNav = page.locator('nav.hidden.lg\\:flex'); await expect(desktopNav).toBeVisible(); await expect(desktopNav.getByRole('link', { name: 'Blog' })).toBeVisible(); + await expect(desktopNav.getByRole('link', { name: 'Stream' })).toBeVisible(); } else { - // Mobile: hamburger button present + // Mobile + tablet: hamburger button present const hamburger = page.getByRole('button', { name: 'Open navigation' }); await expect(hamburger).toBeVisible(); } 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/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/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/+layout.svelte b/src/routes/+layout.svelte index 451e7107..5673d5ab 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; } @@ -121,7 +124,10 @@ -