From acbb385685bc375dd8772a5591fda72523f712fc Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Wed, 15 Jul 2026 12:22:07 -0400 Subject: [PATCH 01/16] =?UTF-8?q?docs(design):=20custody=20commit=20?= =?UTF-8?q?=E2=80=94=20recovered=202026-07-03=20reader=20IA=20study=20+=20?= =?UTF-8?q?2026-07-15=20design=20of=20record=20(TIN-2903)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Study recovered via transcript replay (sha256 84b299401d9c1daf…). Design-of-record mock is subordinate to TIN-2903's fidelity contract. --- ...ry-ledger-design-of-record-2026-07-15.html | 366 +++++ docs/design/reader-ia-study-2026-07-03.html | 1236 +++++++++++++++++ 2 files changed, 1602 insertions(+) create mode 100644 docs/design/observatory-ledger-design-of-record-2026-07-15.html create mode 100644 docs/design/reader-ia-study-2026-07-03.html diff --git a/docs/design/observatory-ledger-design-of-record-2026-07-15.html b/docs/design/observatory-ledger-design-of-record-2026-07-15.html new file mode 100644 index 0000000..e9c4684 --- /dev/null +++ b/docs/design/observatory-ledger-design-of-record-2026-07-15.html @@ -0,0 +1,366 @@ + + +
+
+

“/” Observatory Ledger — round-2 hybrid study

+ 2026-07-15 · TIN-2413 D6 round 2 · base = round-1 Broadsheet × 07-03 Observatory Ledger · register v3 · round 1 · recovered study +
+ +
+

Masthead motion spec (evolves R4)

+

Two states crossfading: A — the real Great Blue Heron header (the existing site identity, asset static/images/header.webp from origin/main, embedded here) with the Trans Scend Survival title; B — the constellation as a night star-field statement. Dwell 14s, crossfade 1.4s. Pauses on hover/focus; explicit pause/play control (WCAG 2.2.2); under prefers-reduced-motion the oscillation never starts — heron state static, the toggle becomes a discrete no-animation switch. The masthead constellation is decorative (aria-hidden nodes); the accessible deterministic interaction (R6) is the disclosure section at the page bottom. Node model already carries kind: post | pulse — a future git kind slots in without redesign (the eventual git-activity-as-pulse “statement”), claimed nowhere in beta copy. Current hero’s scroll-fade behavior is retained.

+
+ +
+

Reductive nav call (current 7 links + wordmark + GitHub → proposed 5 + wordmark + GitHub)

+ + + + + + + + +
EntryTodayProposedWhy
wordmark→ /blogkeep/“/” is now the reader home (D4); wordmark goes home.
Blog→ /blogretireRedundant: the reader IS the front page. /blog remains the paginated archive, linked from register 02 (“full archive →”) and the footer.
Photography · Music · Making · About · CVprimarykeepReal destinations, unchanged.
Signal Boostsprimarydemote → footerGFTB convention: demoted-from-primary items live in footer nav groups.
GitHubexternal linkkeepUnchanged, external.
Orphans (/aag, /consultancy, /pulse*, /THIRD-PARTY-LICENSES…)unlinkedfooter meta / leave/pulse stays out of top-level nav per R5 (not a competing destination); licenses already footer; /aag + /consultancy = operator call, flagged.
+

Mobile drawer keeps the color-mode + theme pickers unchanged.

+
+ +
+

Sidebar reductive call

+ + + + + +
SurfaceTodayProposed
“/” (reader)ProfileSidebar absent on /blog* alreadystays absent — masthead + footer carry identity; the night rail owns the right column.
Section routes (photography/music/making/about/cv)ProfileSidebar, desktop-only, heavy (avatar, equation, bio, badges, TagCloud, co-occurrence SVG, 5 GitHub-activity widgets)slim — keep avatar/name/tagline/equation/bio/badges + TagCloud; reduce GitHub-activity widgets 5 → 2 (stats + streak); full widget set relocates to /about.
Post pagesBlogSidebarunchanged (out of scope this cycle).
+
+ +

▼ The mock — proposed “/” top to bottom

+ + +
+
mock · proposed “/” · fixture data shaped like the live 140-post / 6-pulse snapshot
+ + + +
+
+ + +
+ +
+ Great Blue Heron + +
+

Trans Scend Survival

+

Trans: Latin prefix implying “across” or “beyond” · Scend: originally nautical, the rising motion through a wave · Survival: continuing to live, in spite of.

+

Jess Sullivan — a naturalist-technologist’s log, in one system and two registers

+
+
+ + +
+ +
+
+

01 · Noteworthy 3 entries

+ +
+ + +
+
+

02 · Less-noteworthy 8 of 41 · rest in archive

+ + + + + + + + + +
06-23Less-noteworthy The tiny SSH trick for letting a USB KVM talk to a locked MacmacOS
06-14Less-noteworthy mdsvex components inside posts without brace-escape whack-a-molesveltekit
06-02Printing paperless: email-to-print on a Rocky 10 lab boxprinting
05-21Less-noteworthy Tag graphs as build artifactsmeta
05-09Field notes: soil probes, cheap ADCs, and lying datasheetsdollhouse-farm
04-28QGIS to OpenSCAD and back: parametric beds from parcel shapefilesgis
04-17Less-noteworthy A defensible fish shell promptshell
04-03Reverse-engineering the Canon G-series service protocolhardware
+ +
+ + +
+ +
+

Constellation · grouped by current category & tag metadata

+

The accessible, deterministic view behind the masthead statement — same nodes, honest label, keyboard-native disclosures. Not an embedding, not t-SNE.

+
chapel / performance · 12 entries
  1. Real-time Chapel on a scavenged Dell 7810
  2. NUMA myths, measured
+
homelab / networking · 18 entries
  1. 802.1X for the backyard
  2. NVMe scratch pools for bazel
+
gis / fieldwork · 9 entries
  1. QGIS to OpenSCAD and back
+
pulse · live · 6 items
  1. Northern cardinal, feeder post
  2. Great blue heron, retention pond
+
+ + +
+ +
+

Footer accuracy notes

+

Kept verbatim (current truths): the public-domain dedication, RSS/JSON feeds, licenses link, and the built-with-SvelteKit-&-Skeleton + source line. No copyright/org line is added — the blog has none today and needs none.

+

Adopted from greatfallstoolbus.org: the built from {short-sha} · GitHub-verified provenance line (rendered only when a real build sha is baked in — never on local/static builds), footer nav groups as the home for demoted-from-primary items, and the NOTICE/credits.ts discipline for asset provenance (the heron header currently has no recorded provenance — flagged, must be recorded before ship, not fabricated).

+

Deliberately NOT imported: tinyland.dev's brand statement, address block, or copyleft line — different site, different truths. And zero federation language anywhere on the page (R2/R11: no "federated" claims until TIN-2416's proof packet exists).

+
+ +

Shared invariants unchanged from round 1: badges only where data provides tier · pulse producer order preserved · constellation honestly labeled · Planetarium deferred · type/density hierarchy, no cards · promotion gated on TIN-2891 + R8 + operator review.

+
+ + diff --git a/docs/design/reader-ia-study-2026-07-03.html b/docs/design/reader-ia-study-2026-07-03.html new file mode 100644 index 0000000..a6f51f4 --- /dev/null +++ b/docs/design/reader-ia-study-2026-07-03.html @@ -0,0 +1,1236 @@ + + +
+
+ + +
+

tinyland.dev · design study · /posts + /pulse

+

A naturalist-technologist's log, in one system and two registers

+

+ The feed has three strata of signal, and they split into two registers. + Printed & permanent — the blog: noteworthy (deep reverse-engineering and + architecture write-ups, promoted to the top) and less-noteworthy (everyday recipes, + release notes, field observations). Live & observedpulse: Cayuga-basin + bird sightings, trail runs, and small life updates, fetched from the broker. Same sample content below, + rendered three ways — so you compare layout and motion, not palette, and pick one. +

+ +
+ Printed & permanent — serif on parchment + live & observed — mono on ink-teal +
+ +
+
+ +

Noteworthy

Deep / reverse-engineering / architecture. Hand-curated to the top.

cardinal · printed
+
+
+ +

Less-noteworthy

Recipes, release notes, casual posts. Published, not promoted.

heron · printed
+
+
+ +

Pulse

Notes, bird sightings, runs. Small, frequent, broker-fed. Motion lives here.

goldfinch · live
+
+
+ +
+
+ + + +
+ motion on · respects reduced-motion +
+
+ + +
+
+ Observatory Ledger + SSR-friendly · recommended beta default + A printed field-guide page beside a live night rail — motion only where it's genuinely live. +
+ +
+
+
+ Noteworthy + +
+
+ +

Less-noteworthy — chronological

+
+
+ + +
+ + + +
+ + +
+
+ Ticker + River + animated · attention-forward + Auto-advancing noteworthy carousel, a live pulse marquee, then a plain river of everyday posts. +
+ +

Noteworthy — carousel

+ + +
+
Pulse
+
+
+ +

Less-noteworthy — river

+
+
+ + +
+
+ Planetarium + wow · heavier + A living constellation. Time = radial distance; plumage-hue + brightness encode the tier. +
+ +
+ +
+
+
Noteworthy — bright cardinal stars
+
Less-noteworthy — dim heron stars
+
Pulse — goldfinch comets
+
+
+ +
hover a star for its title · click to zoom
tag lines = shared topics · center = now, edge = older
+
+
+
+ +

+ Static prototype · all data is sample content · pulse "liveness" is simulated (no broker attached). + Self-contained single file — system serif + mono, inline SVG/unicode only, no external requests. + Reduced-motion: carousel & ticker freeze, the pulse rail pre-fills without animation, the planetarium renders a static star map. +

+
+
+ + From e7ed2c2db4de1a71e62c8321050df6b6b7c3b1da Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Wed, 15 Jul 2026 12:24:15 -0400 Subject: [PATCH 02/16] =?UTF-8?q?feat(reader):=20harvest=20#217=20donor=20?= =?UTF-8?q?primitives=20verbatim=20(TierBadge,=20blend,=20pulse=20cards)?= =?UTF-8?q?=20=E2=80=94=20TIN-2903?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Harvested byte-for-byte from origin/feat/blog-tiered-reader-ui: - src/lib/components/TierBadge.svelte - src/lib/components/TierBadge.test.ts - src/lib/stream/blend.ts - src/lib/stream/blend.test.ts - src/lib/components/pulse/PulseNoteCard.svelte (adds TierBadge integration) - src/lib/components/pulse/PulseBirdCard.svelte (adds TierBadge integration) - src/lib/components/pulse/PulseCards.test.ts (co-located tier-badge tests) --- src/lib/components/TierBadge.svelte | 38 ++++++ src/lib/components/TierBadge.test.ts | 74 ++++++++++++ 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 ++++++++++++++ 7 files changed, 354 insertions(+), 4 deletions(-) create mode 100644 src/lib/components/TierBadge.svelte create mode 100644 src/lib/components/TierBadge.test.ts create mode 100644 src/lib/stream/blend.test.ts create mode 100644 src/lib/stream/blend.ts diff --git a/src/lib/components/TierBadge.svelte b/src/lib/components/TierBadge.svelte new file mode 100644 index 0000000..72e5262 --- /dev/null +++ b/src/lib/components/TierBadge.svelte @@ -0,0 +1,38 @@ + + +{#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 0000000..2c9260a --- /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 a455a11..7cd57c5 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 5d5b8a4..2578a2a 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 c516fbc..93f8c5e 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 0000000..bbf4c1f --- /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 0000000..7d4058b --- /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); +} From b47be05167a853607e6b197408cfb432dc4d9242 Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Wed, 15 Jul 2026 13:21:47 -0400 Subject: [PATCH 03/16] feat(reader): pure tier-partition + constellation-group model (TIN-2903) --- src/lib/reader/ledger.test.ts | 141 +++++++++++++++++++++++++++++++ src/lib/reader/ledger.ts | 152 ++++++++++++++++++++++++++++++++++ 2 files changed, 293 insertions(+) create mode 100644 src/lib/reader/ledger.test.ts create mode 100644 src/lib/reader/ledger.ts diff --git a/src/lib/reader/ledger.test.ts b/src/lib/reader/ledger.test.ts new file mode 100644 index 0000000..7a032cb --- /dev/null +++ b/src/lib/reader/ledger.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from 'vitest'; +import type { Post } from '$lib/types'; +import type { PublicPulseItem } from '@blog/pulse-core/schema'; +import { buildConstellationGroups, partitionLedger } from './ledger'; + +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('partitionLedger', () => { + it('partitions strictly on the explicit editorial_tier', () => { + const { noteworthy, lessNoteworthy, unclassified } = partitionLedger([ + makePost({ slug: 'n', editorial_tier: 'noteworthy' }), + makePost({ slug: 'l', editorial_tier: 'less-noteworthy' }), + makePost({ slug: 'u' }), + ]); + expect(noteworthy.map((p) => p.slug)).toEqual(['n']); + expect(lessNoteworthy.map((p) => p.slug)).toEqual(['l']); + expect(unclassified.map((p) => p.slug)).toEqual(['u']); + }); + + it('never infers a tier from featured/category/tags/length/recency', () => { + // Every signal that could tempt inference is present, but no tier is set. + const { noteworthy, lessNoteworthy, unclassified } = partitionLedger([ + makePost({ + slug: 'loud-but-untiered', + featured: true, + category: 'hardware', + tags: ['a', 'b', 'c'], + date: '2030-01-01', + description: 'x'.repeat(5000), + }), + ]); + expect(noteworthy).toHaveLength(0); + expect(lessNoteworthy).toHaveLength(0); + expect(unclassified.map((p) => p.slug)).toEqual(['loud-but-untiered']); + }); + + it('is deterministic under input reordering (date-desc, slug tie-break)', () => { + const posts = [ + makePost({ slug: 'b', date: '2026-05-01', editorial_tier: 'noteworthy' }), + makePost({ slug: 'a', date: '2026-05-01', editorial_tier: 'noteworthy' }), + makePost({ slug: 'c', date: '2026-06-01', editorial_tier: 'noteworthy' }), + ]; + const forward = partitionLedger(posts).noteworthy.map((p) => p.slug); + const reversed = partitionLedger([...posts].reverse()).noteworthy.map((p) => p.slug); + // c is newest; a before b on the slug tie-break within the shared 05-01. + expect(forward).toEqual(['c', 'a', 'b']); + expect(reversed).toEqual(forward); + }); + + it('does not silently cap: N in -> N out across the three buckets', () => { + const posts = Array.from({ length: 137 }, (_, i) => + makePost({ + slug: `p${i}`, + date: '2026-01-01', + editorial_tier: i % 3 === 0 ? 'noteworthy' : i % 3 === 1 ? 'less-noteworthy' : undefined, + }), + ); + const { noteworthy, lessNoteworthy, unclassified } = partitionLedger(posts); + expect(noteworthy.length + lessNoteworthy.length + unclassified.length).toBe(137); + }); +}); + +describe('buildConstellationGroups', () => { + it('groups posts by category then tag, honest basis on each group', () => { + const groups = buildConstellationGroups( + [ + makePost({ slug: 'hw', category: 'hardware', tags: ['soldering'] }), + makePost({ slug: 'tagged', tags: ['Birding'] }), + makePost({ slug: 'bare', tags: [] }), + ], + [], + ); + const byKey = Object.fromEntries(groups.map((g) => [g.key, g])); + expect(byKey.hardware.basis).toBe('category'); + expect(byKey.birding.basis).toBe('tag'); + expect(byKey.birding.label).toBe('Birding'); + expect(byKey.uncategorized.basis).toBe('category'); + }); + + it('is deterministic under post reordering', () => { + const posts = [ + makePost({ slug: 'a', category: 'music', date: '2026-01-01' }), + makePost({ slug: 'b', category: 'hardware', date: '2026-02-01' }), + makePost({ slug: 'c', category: 'hardware', date: '2026-03-01' }), + ]; + const shape = (ps: Post[]) => + buildConstellationGroups(ps, []).map((g) => `${g.key}:${g.nodes.map((n) => n.id).join(',')}`); + expect(shape([...posts].reverse())).toEqual(shape(posts)); + // hardware sorts before music by key; within hardware, c (newer) before b. + expect(shape(posts)).toEqual(['hardware:post:c,post:b', 'music:post:a']); + }); + + it('preserves pulse producer order and appends the pulse group last', () => { + const groups = buildConstellationGroups( + [makePost({ slug: 'p', category: 'hardware' })], + [makePulse({ id: 'first' }), makePulse({ id: 'second' }), makePulse({ id: 'third' })], + ); + const pulseGroup = groups[groups.length - 1]; + expect(pulseGroup.key).toBe('pulse'); + expect(pulseGroup.nodes.map((n) => n.id)).toEqual(['pulse:first', 'pulse:second', 'pulse:third']); + }); + + it('does not silently cap: every post and pulse becomes exactly one node', () => { + const posts = Array.from({ length: 40 }, (_, i) => + makePost({ slug: `p${i}`, category: i % 2 ? 'music' : 'hardware' }), + ); + const pulses = Array.from({ length: 6 }, (_, i) => makePulse({ id: `q${i}` })); + const groups = buildConstellationGroups(posts, pulses); + const postNodes = groups.flatMap((g) => g.nodes.filter((n) => n.kind === 'post')); + const pulseNodes = groups.flatMap((g) => g.nodes.filter((n) => n.kind === 'pulse')); + expect(postNodes).toHaveLength(40); + expect(pulseNodes).toHaveLength(6); + }); + + it('omits the pulse group entirely when there are no pulse items', () => { + const groups = buildConstellationGroups([makePost({ category: 'hardware' })], []); + expect(groups.some((g) => g.basis === 'pulse')).toBe(false); + }); +}); diff --git a/src/lib/reader/ledger.ts b/src/lib/reader/ledger.ts new file mode 100644 index 0000000..d4da97f --- /dev/null +++ b/src/lib/reader/ledger.ts @@ -0,0 +1,152 @@ +// Pure, framework-free model for the "/" Observatory Ledger reader (TIN-2903). +// This is the ONLY place the reader decides what is "noteworthy", and it does so +// strictly from the explicit `editorial_tier` frontmatter field. Tier is +// reader-weight / IA ONLY (docs/blog-editorial-taxonomy-2026-07-03.md): it is +// NEVER inferred from `featured`, category, tags, length, recency, or author, +// and it is never an authorization, privacy, or ActivityPub-delivery signal. +// Kept DOM-free so the partition + grouping stay unit-testable and callable at +// prerender time. + +import type { Post, PostEditorialTier } from '$lib/types'; +import type { PublicPulseItem } from '@blog/pulse-core/schema'; + +// Node provenance in the constellation model. `pulse` items ride alongside blog +// `post`s; the union is intentionally open so a future stratum (e.g. a +// git-pulse or signal-boost node) can join without reshaping callers. +export type LedgerNodeKind = 'post' | 'pulse'; + +export interface PartitionedLedger { + readonly noteworthy: Post[]; + readonly lessNoteworthy: Post[]; + readonly unclassified: Post[]; +} + +// Date-desc primary, slug asc tie-break: a total order that does not rely on +// input order or sort stability, so a reordered input yields identical output. +function byDateDescThenSlug(a: Post, b: Post): number { + const ad = new Date(a.date).getTime(); + const bd = new Date(b.date).getTime(); + if (ad !== bd) return bd - ad; + if (a.slug < b.slug) return -1; + if (a.slug > b.slug) return 1; + return 0; +} + +/** + * Split posts into the two editorial registers plus an `unclassified` bucket, + * strictly on the explicit `editorial_tier`. A post that is `featured` (or in + * any category, or heavily tagged) but carries no tier lands in `unclassified` + * — never promoted by inference. Every input post appears in exactly one + * bucket (N in -> N out); each bucket is date-desc with a slug tie-break. + */ +export function partitionLedger(posts: readonly Post[]): PartitionedLedger { + const noteworthy: Post[] = []; + const lessNoteworthy: Post[] = []; + const unclassified: Post[] = []; + + for (const post of posts) { + if (post.editorial_tier === 'noteworthy') noteworthy.push(post); + else if (post.editorial_tier === 'less-noteworthy') lessNoteworthy.push(post); + else unclassified.push(post); + } + + noteworthy.sort(byDateDescThenSlug); + lessNoteworthy.sort(byDateDescThenSlug); + unclassified.sort(byDateDescThenSlug); + + return { noteworthy, lessNoteworthy, unclassified }; +} + +// A single point in the constellation — a post or a pulse flattened to one +// shape the masthead render and the accessible disclosure section can share. +export interface ConstellationNode { + readonly kind: LedgerNodeKind; + // Stable, namespaced id (`post:` / `pulse:`). + readonly id: string; + readonly label: string; + readonly href: string; + // Reader-weight tier (post `editorial_tier` / pulse `salience`). Absent = untiered. + readonly tier?: PostEditorialTier; +} + +export interface ConstellationGroup { + // Normalized grouping key (lowercased category / tag, or `pulse`). + readonly key: string; + readonly label: string; + // How this group was formed — honest by construction, never an embedding. + readonly basis: 'category' | 'tag' | 'pulse'; + readonly nodes: ConstellationNode[]; +} + +function postToNode(post: Post): ConstellationNode { + return { + kind: 'post', + id: `post:${post.slug}`, + label: post.title, + href: `/blog/${post.slug}`, + tier: post.editorial_tier, + }; +} + +function pulseToNode(item: PublicPulseItem): ConstellationNode { + return { + kind: 'pulse', + id: `pulse:${item.id}`, + label: item.summary, + href: '/pulse', + tier: item.salience, + }; +} + +// A post's honest home: its typed `category`, else its first tag, else a shared +// `uncategorized` group. No embedding, no similarity model — just the metadata +// already on the post. +function groupingFor(post: Post): { key: string; label: string; basis: 'category' | 'tag' } { + if (post.category) return { key: post.category, label: post.category, basis: 'category' }; + const firstTag = post.tags.find((t) => t.trim().length > 0); + if (firstTag) return { key: firstTag.toLowerCase(), label: firstTag, basis: 'tag' }; + return { key: 'uncategorized', label: 'uncategorized', basis: 'category' }; +} + +/** + * Group posts by their existing category/tag metadata, plus a single `pulse` + * group for the live snapshot. Deterministic and honest by construction: every + * post falls into exactly one group (N in -> N out) sorted date-desc/slug, the + * post groups are ordered by key so any input reordering yields identical + * output, and the pulse group preserves producer order (never re-ranked). The + * pulse group is appended last as a distinct live stratum. + */ +export function buildConstellationGroups( + posts: readonly Post[], + pulseItems: readonly PublicPulseItem[], +): ConstellationGroup[] { + const byKey = new Map(); + + for (const post of posts) { + const { key, label, basis } = groupingFor(post); + const bucket = byKey.get(key); + if (bucket) bucket.posts.push(post); + else byKey.set(key, { label, basis, posts: [post] }); + } + + const groups: ConstellationGroup[] = [...byKey.entries()] + .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)) + .map(([key, bucket]) => ({ + key, + label: bucket.label, + basis: bucket.basis, + nodes: [...bucket.posts].sort(byDateDescThenSlug).map(postToNode), + })); + + if (pulseItems.length > 0) { + groups.push({ + key: 'pulse', + label: 'pulse', + basis: 'pulse', + // Producer order preserved — pulse is never re-ranked. + nodes: pulseItems.map(pulseToNode), + }); + } + + return groups; +} From 434e3ffcb6db007af634d9d144e6288e331e2e25 Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Wed, 15 Jul 2026 13:23:07 -0400 Subject: [PATCH 04/16] feat(reader): oscillating heron/constellation masthead (TIN-2903) State A is the existing heron hero (verbatim identity, assets, alt, scroll-fade); state B is a decorative aria-hidden constellation whose motion language (golden-angle plumage stars, time-rings, tag lines, twinkle, goldfinch pulse comets, slow drift) is carried from the recovered 2026-07-03 study's planetarium canvas. 14s dwell / 1.4s crossfade; pause on hover/focus; explicit toggle + pause buttons (aria-pressed); prefers-reduced-motion holds state A static and never starts the oscillation. --- .../reader/ObservatoryMasthead.svelte | 439 ++++++++++++++++++ 1 file changed, 439 insertions(+) create mode 100644 src/lib/components/reader/ObservatoryMasthead.svelte diff --git a/src/lib/components/reader/ObservatoryMasthead.svelte b/src/lib/components/reader/ObservatoryMasthead.svelte new file mode 100644 index 0000000..b2c8110 --- /dev/null +++ b/src/lib/components/reader/ObservatoryMasthead.svelte @@ -0,0 +1,439 @@ + + +
(hovered = true)} + onmouseleave={() => (hovered = false)} + onfocusin={() => (hovered = true)} + onfocusout={() => (hovered = false)} + aria-label="Trans Scend Survival" +> +
+ + +
+ + +
+ + + Great Blue Heron + +
+ + +

+ Trans: Latin prefix implying “across” or “Beyond”, often used in gender + nonconforming situations + + Scend: Archaic word describing a strong “surge” or “wave”, originating + with 15th century english sailors + + Survival: 15th century english compound word describing an existence only worth transcending +

+

Jess Sullivan

+
+
+ + + +
+ + From 20932753fa04623eab8f1dd35bf9f20311f43bee Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Wed, 15 Jul 2026 13:24:16 -0400 Subject: [PATCH 05/16] feat(reader): register, pulse-rail, and constellation-section components (TIN-2903) NoteworthySpread (register 01) + LedgerRegister (register 02, capped 8 + archive link) compose the harvested TierBadge; PulseRail composes the #217 PulseNoteCard/ PulseBirdCard donors and renders every snapshot item in producer order (no silent cap); ConstellationSection exposes buildConstellationGroups as native details/summary disclosures with an honest category/tag label. --- .../reader/ConstellationSection.svelte | 51 +++++++++++ .../components/reader/LedgerRegister.svelte | 85 +++++++++++++++++++ .../components/reader/NoteworthySpread.svelte | 84 ++++++++++++++++++ src/lib/components/reader/PulseRail.svelte | 69 +++++++++++++++ src/lib/components/reader/PulseRail.test.ts | 47 ++++++++++ 5 files changed, 336 insertions(+) create mode 100644 src/lib/components/reader/ConstellationSection.svelte create mode 100644 src/lib/components/reader/LedgerRegister.svelte create mode 100644 src/lib/components/reader/NoteworthySpread.svelte create mode 100644 src/lib/components/reader/PulseRail.svelte create mode 100644 src/lib/components/reader/PulseRail.test.ts diff --git a/src/lib/components/reader/ConstellationSection.svelte b/src/lib/components/reader/ConstellationSection.svelte new file mode 100644 index 0000000..f5e359a --- /dev/null +++ b/src/lib/components/reader/ConstellationSection.svelte @@ -0,0 +1,51 @@ + + +{#if groups.length > 0} +
+

+ Constellation · grouped by current category & tag metadata +

+

+ The accessible, deterministic view behind the masthead — the same nodes, an honest label, keyboard-native + disclosures. Not an embedding, not t-SNE. +

+ + {#each groups as group (group.key)} +
+ + {group.label} + · {group.nodes.length} {group.nodes.length === 1 ? 'entry' : 'entries'} + +
    + {#each group.nodes as node (node.id)} +
  1. + {node.label} +
  2. + {/each} +
+
+ {/each} +
+{/if} + + diff --git a/src/lib/components/reader/LedgerRegister.svelte b/src/lib/components/reader/LedgerRegister.svelte new file mode 100644 index 0000000..655f8e4 --- /dev/null +++ b/src/lib/components/reader/LedgerRegister.svelte @@ -0,0 +1,85 @@ + + +{#if rows.length > 0} +
+

+ {index} · {heading} + + {shown.length} of {rows.length}{#if rows.length > cap} + · rest in archive{/if} + +

+ + + + {#each shown as post (post.slug)} + + + + {#if post.category} + + {:else} + + {/if} + + {/each} + +
+ {monthDay(post.date)} + + + {post.title} + {post.category}
+ +

+ + Full archive at /blog — {total} entries → + +

+
+{/if} + + diff --git a/src/lib/components/reader/NoteworthySpread.svelte b/src/lib/components/reader/NoteworthySpread.svelte new file mode 100644 index 0000000..1962433 --- /dev/null +++ b/src/lib/components/reader/NoteworthySpread.svelte @@ -0,0 +1,84 @@ + + +{#if lead} +
+

+ 01 · Noteworthy {posts.length} {posts.length === 1 ? 'entry' : 'entries'} +

+ +
+ +

+ {lead.title} +

+ {#if lead.description} +

{lead.description}

+ {/if} +

+ {fmtDate(lead.date)}{#if lead.category} + · {lead.category}{/if} +

+
+ + {#if secondaries.length > 0} +
+ {#each secondaries as post (post.slug)} +
+ +

+ {post.title} +

+ {#if post.description} +

{post.description}

+ {/if} +

+ {fmtDate(post.date)}{#if post.category} + · {post.category}{/if} +

+
+ {/each} +
+ {/if} +
+{/if} + + diff --git a/src/lib/components/reader/PulseRail.svelte b/src/lib/components/reader/PulseRail.svelte new file mode 100644 index 0000000..26f1ce0 --- /dev/null +++ b/src/lib/components/reader/PulseRail.svelte @@ -0,0 +1,69 @@ + + + + + diff --git a/src/lib/components/reader/PulseRail.test.ts b/src/lib/components/reader/PulseRail.test.ts new file mode 100644 index 0000000..5c4cc25 --- /dev/null +++ b/src/lib/components/reader/PulseRail.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; +import { render } from 'svelte/server'; +import type { PublicPulseItem } from '@blog/pulse-core/schema'; +import PulseRail from './PulseRail.svelte'; + +function makeNote(i: number): PublicPulseItem { + return { + id: `note_${i}`, + kind: 'note', + occurredAt: `2026-05-${String((i % 27) + 1).padStart(2, '0')}T12:00:00.000Z`, + summary: `summary ${i}`, + content: `pulse-content-${i}`, + tags: [], + }; +} + +describe('PulseRail', () => { + it('renders every snapshot item — no silent cap (N in -> N out)', () => { + const items = Array.from({ length: 12 }, (_, i) => makeNote(i)); + const { html } = render(PulseRail, { props: { items } }); + + for (const item of items) { + expect(html).toContain(item.content); + } + // 12 note cards each carry the "💬 note" kind marker. + const cardCount = html.split('💬 note').length - 1; + expect(cardCount).toBe(12); + expect(html).toContain('12 of 12 rendered'); + }); + + it('preserves producer order (never re-ranked)', () => { + const items = [makeNote(0), makeNote(1), makeNote(2)]; + const { html } = render(PulseRail, { props: { items } }); + const first = html.indexOf('pulse-content-0'); + const second = html.indexOf('pulse-content-1'); + const third = html.indexOf('pulse-content-2'); + expect(first).toBeGreaterThan(-1); + expect(first).toBeLessThan(second); + expect(second).toBeLessThan(third); + }); + + it('degrades to a calm note when the snapshot is empty', () => { + const { html } = render(PulseRail, { props: { items: [] } }); + expect(html).toContain('No live items right now.'); + expect(html).not.toContain('💬 note'); + }); +}); From 856bc7f11449ee966989ebdbe4912cf656143923 Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Wed, 15 Jul 2026 13:25:54 -0400 Subject: [PATCH 06/16] feat(reader): make "/" the Observatory Ledger reader composition (TIN-2903) SSR-first, fail-soft loader (missing/invalid pulse snapshot -> empty rail; posts from the build-time static index). The page partitions on explicit editorial_tier only, so today's zero-tier data lands entirely in register 02 and the front page stays calm rather than empty. /blog is untouched as the paginated archive. --- src/routes/+page.svelte | 68 +++++++++++++++++++++++++++++++++++++---- src/routes/+page.ts | 21 +++++++++++++ 2 files changed, 83 insertions(+), 6 deletions(-) create mode 100644 src/routes/+page.ts diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 571a633..adc15ef 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,9 +1,65 @@ + + - - - + transscendsurvival.org — Jess Sullivan + + + + + + + -
-

Redirecting to /blog...

-
+ + +
+
+
+ {#if partition.noteworthy.length > 0} + +
+ {/if} + + {#if ledgerRows.length > 0} + 0 ? '02' : '01'} + archiveTotal={data.posts.length} + /> + {:else if partition.noteworthy.length === 0} +

No entries yet.

+ {/if} +
+ + +
+ +
+ + +
diff --git a/src/routes/+page.ts b/src/routes/+page.ts new file mode 100644 index 0000000..cd427ad --- /dev/null +++ b/src/routes/+page.ts @@ -0,0 +1,21 @@ +import { getPosts } from '$lib/posts'; +import { loadPulseSnapshot } from '$lib/pulse/load'; +import type { PublicPulseItem } from '@blog/pulse-core/schema'; +import type { PageLoad } from './$types'; + +// SSR-first, fail-soft. Posts come from the build-time static index (always at +// least as fresh as the deploy). The pulse snapshot is a best-effort enrichment: +// a missing or invalid snapshot degrades to an empty rail — the reader still +// renders. Prerendering is inherited from the root +layout.ts. +export const load: PageLoad = async ({ fetch }) => { + const posts = await getPosts(); + + let pulseItems: PublicPulseItem[] = []; + try { + pulseItems = (await loadPulseSnapshot(fetch)).items; + } catch { + pulseItems = []; + } + + return { posts, pulseItems }; +}; From 20bb420f8143b5d969dfaecfe6fd989420677ee3 Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Wed, 15 Jul 2026 13:26:52 -0400 Subject: [PATCH 07/16] feat(reader): reduce nav, add footer More group + conditional build-sha (TIN-2903) Retire the Blog nav link (wordmark -> /), demote Signal Boosts and surface /aag + /consultancy in a footer More group. Suppress the layout hero on / (the masthead carries identity there) and keep glass-nav tracking scroll. Add a build-from-{sha} footer line rendered only when a real PUBLIC_BUILD_SHA/GITHUB_SHA is present (wired via a Vite define), plus a TODO(TIN-2903) for the unrecorded heron asset provenance. Existing footer lines kept verbatim; mobile drawer theme/mode pickers untouched. --- src/app.d.ts | 4 + src/routes/+layout.svelte | 151 +++++++++++++++++++++++++------------- vite.config.ts | 26 ++++--- 3 files changed, 119 insertions(+), 62 deletions(-) diff --git a/src/app.d.ts b/src/app.d.ts index da08e6d..136ae16 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -1,6 +1,10 @@ // See https://svelte.dev/docs/kit/types#app.d.ts // for information about these interfaces declare global { + // Public build SHA injected by Vite `define` (see vite.config.ts). Empty + // string when no real build sha is present. + const __BUILD_SHA__: string; + namespace App { // interface Error {} // interface Locals {} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 451e710..520267f 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -43,25 +43,44 @@ let bannerNaturalHeight = 0; let scrolledPastBanner = $state(false); + // Primary nav (TIN-2903 ratified reduction): the Blog link is retired — "/" is + // now the reader home, reached via the wordmark — and Signal Boosts is demoted + // to the footer "More" group. Photography/Music/Making/CV/About are unchanged. const navLinks = [ - { href: '/blog', label: 'Blog' }, { href: '/photography', label: 'Photography' }, { href: '/music', label: 'Music' }, { href: '/making', label: 'Making' }, - { href: '/signal-boosts', label: 'Signal Boosts' }, { href: '/cv', label: 'CV' }, { href: '/about', label: 'About' }, ]; + // Footer "More" group: primary-demoted plus previously-unlinked destinations. + const footerMoreLinks = [ + { href: '/blog', label: 'Full archive (/blog)' }, + { href: '/signal-boosts', label: 'Signal Boosts' }, + { href: '/aag', label: 'AAG 2019 poster' }, + { href: '/consultancy', label: 'Consultancy' }, + ]; + + // Public build SHA, injected at build time via Vite `define` (see + // vite.config.ts). Empty unless a real published build sets PUBLIC_BUILD_SHA / + // GITHUB_SHA — the footer provenance line renders only when it is present. + const buildSha = __BUILD_SHA__; + const buildShaShort = buildSha.slice(0, 7); + + const isReaderHome = $derived($page.url.pathname === '/'); + onMount(() => { theme.init(); // Scroll-fade for hero banner if (bannerRef) bannerNaturalHeight = bannerRef.offsetHeight; const onScroll = () => { + // Glass-nav tracks scroll on every route, including "/" where the reader + // owns its own masthead and no layout hero banner is present. + scrolledPastBanner = window.scrollY > 50; if (!bannerRef || !bannerNaturalHeight) return; bannerOpacity = Math.max(0, 1 - window.scrollY / bannerNaturalHeight); - scrolledPastBanner = window.scrollY > 50; }; window.addEventListener('scroll', onScroll, { passive: true }); @@ -113,7 +132,7 @@ transscendsurvival.org @@ -122,21 +141,20 @@ @@ -249,43 +267,46 @@ - -
- - - Great Blue Heron - -
- - -

- Trans: Latin prefix implying “across” or “Beyond”, often used in gender - nonconforming situations - - Scend: Archaic word describing a strong “surge” or “wave”, originating - with 15th century english sailors - - Survival: 15th century english compound word describing an existence only worth transcending -

-

Jess Sullivan

-
-
+ + {#if !isReaderHome} +
+ + + Great Blue Heron + +
+ + +

+ Trans: Latin prefix implying “across” or “Beyond”, often used in gender + nonconforming situations + + Scend: Archaic word describing a strong “surge” or “wave”, originating + with 15th century english sailors + + Survival: 15th century english compound word describing an existence only worth transcending +

+

Jess Sullivan

+
+
+ {/if} - {#if $page.url.pathname.startsWith('/blog')} + {#if isReaderHome || $page.url.pathname.startsWith('/blog')}
{@render children()}
@@ -341,5 +362,29 @@ rel="noopener">source

+ + + + + {#if buildSha} +

+ built from {buildShaShort} · GitHub-verified +

+ {/if} + + diff --git a/vite.config.ts b/vite.config.ts index 791f5bd..6c764c5 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -32,6 +32,12 @@ function skeletonTailwindV4Compat(): Plugin { } export default defineConfig({ + // Public build SHA baked at build time. Empty unless a real published build + // sets PUBLIC_BUILD_SHA (or CI's GITHUB_SHA); the footer provenance line in + // +layout.svelte renders only when it is non-empty. + define: { + __BUILD_SHA__: JSON.stringify(process.env.PUBLIC_BUILD_SHA ?? process.env.GITHUB_SHA ?? ''), + }, plugins: [ skeletonTailwindV4Compat(), skeletonColorUtilities(), @@ -41,15 +47,17 @@ export default defineConfig({ failOnError: false, }), sveltekit(), - ...(process.env.BUILD_ANALYZE ? [ - visualizer({ - emitFile: true, - filename: 'stats.html', - template: 'treemap', - gzipSize: true, - brotliSize: true, - }), - ] : []), + ...(process.env.BUILD_ANALYZE + ? [ + visualizer({ + emitFile: true, + filename: 'stats.html', + template: 'treemap', + gzipSize: true, + brotliSize: true, + }), + ] + : []), ], build: { reportCompressedSize: true, From a47d8dfba6fbab0b50b25d289a5ead25ba5dc2f9 Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Wed, 15 Jul 2026 14:31:04 -0400 Subject: [PATCH 08/16] fix(reader): complete #217 BlogCard tier-badge harvest + explicit masthead control labels (TIN-2903) BlogCard wiring lifted verbatim from feat/blog-tiered-reader-ui (the harvested TierBadge test exercises it); aria-labels added to the masthead toggle/pause buttons flagged by the build a11y pass. --- src/lib/components/BlogCard.svelte | 7 ++++++- .../components/reader/ObservatoryMasthead.svelte | 14 ++++++++++++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/lib/components/BlogCard.svelte b/src/lib/components/BlogCard.svelte index 62e6f89..ea4f7e4 100644 --- a/src/lib/components/BlogCard.svelte +++ b/src/lib/components/BlogCard.svelte @@ -1,5 +1,6 @@ @@ -35,7 +76,7 @@ - +
@@ -56,7 +97,7 @@ {/if}
- +

From a2f35aaffe219d243ec1027b7d8f436dfa4af804 Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Thu, 16 Jul 2026 23:14:06 -0400 Subject: [PATCH 12/16] =?UTF-8?q?fix(reader):=20semantic=20honesty=20?= =?UTF-8?q?=E2=80=94=20real=20h1,=20single=20banner=20landmark,=20machine-?= =?UTF-8?q?readable=20dates=20(TIN-2903)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial semantic review findings, all branch-introduced: - the reader home had no h1 (site identity was a p[role=banner]); the masthead title is now the document h1, and the illegal role=banner (nested inside main, duplicating the AppBar's header landmark) is gone - dates render in
@@ -48,7 +48,7 @@

{post.description}

{/if}

- {fmtDate(post.date)}{#if post.category} + {#if post.category} · {post.category}{/if}

diff --git a/src/lib/components/reader/ObservatoryMasthead.svelte b/src/lib/components/reader/ObservatoryMasthead.svelte index 1e3e50c..809e6a6 100644 --- a/src/lib/components/reader/ObservatoryMasthead.svelte +++ b/src/lib/components/reader/ObservatoryMasthead.svelte @@ -396,7 +396,10 @@ />
- + +

Trans Scend Survival

Date: Fri, 17 Jul 2026 09:07:54 -0400 Subject: [PATCH 13/16] =?UTF-8?q?feat(reader):=20interactive=20constellati?= =?UTF-8?q?on=20=E2=80=94=20hover=20titles,=20click-to-navigate,=20tier/ti?= =?UTF-8?q?me=20axes=20in=20the=20accessible=20counterpart=20(TIN-2903=20M?= =?UTF-8?q?1.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pointer interaction on the deterministic golden-angle canvas, per the ratified M1.3 spec (07-03 study affordances, embeddings still deferred): - hover: point-in-radius pick over the live Star[] (26px), DOM tooltip (tier/tag kicker, title, date or reading-time meta), hovered star brightens and drift freezes so the target holds still; idle surfaces redraw only on hover CHANGE - click: navigates to the entry with a 200ms local-magnify flourish when motion runs; reduced-motion/paused navigate immediately with no motion; the pending navigation timer is tracked and cleared on unmount - labels: noteworthy stars always labeled, hovered stars labeled brighter (with zero-tier live data every label is hover-only today — honest) - ledger.ts: ConstellationNode gains optional tag/date/readingMinutes; postToConstellationNode exported so masthead + groups share one shape - ConstellationSection: gains the tier + time axes the canvas encodes (closes the semantic-review counterpart gap); canvas stays aria-hidden with pointer affordances only, keyboard access via the section Adversarially verified (ship-with-nits; both minors fixed at helm: change-gated idle redraw, tracked flourish timer). svelte-check 0 errors, vitest 133/133, build green. --- .../reader/ConstellationSection.svelte | 18 +- .../reader/ObservatoryMasthead.svelte | 230 +++++++++++++++++- src/lib/reader/ledger.ts | 15 +- 3 files changed, 250 insertions(+), 13 deletions(-) diff --git a/src/lib/components/reader/ConstellationSection.svelte b/src/lib/components/reader/ConstellationSection.svelte index f5e359a..de39251 100644 --- a/src/lib/components/reader/ConstellationSection.svelte +++ b/src/lib/components/reader/ConstellationSection.svelte @@ -1,10 +1,22 @@ {#if groups.length > 0} @@ -25,8 +37,12 @@

    {#each group.nodes as node (node.id)} -
  1. +
  2. {node.label} + + {#if node.date} + + {/if}
  3. {/each}
diff --git a/src/lib/components/reader/ObservatoryMasthead.svelte b/src/lib/components/reader/ObservatoryMasthead.svelte index 809e6a6..5a2fb82 100644 --- a/src/lib/components/reader/ObservatoryMasthead.svelte +++ b/src/lib/components/reader/ObservatoryMasthead.svelte @@ -1,8 +1,9 @@