A calm place to write long-form, and publish it to the open social web. skypress.blog/
0

Configure Feed

Select the types of activity you want to include in your feed.

Add implementation plan: reading-page author focus

+469
+469
docs/superpowers/plans/2026-06-09-reading-page-author-focus.md
··· 1 + # Reading-page author focus — Implementation Plan 2 + 3 + > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. 4 + 5 + **Goal:** Refocus the reading pages on the publication and its author — drop SkyPress branding from the headers and render dates in a long English format. 6 + 7 + **Architecture:** A new pure `formatLongDate` helper handles dates. The publication page loses its header; the single-post page swaps the SkyPress logo for the publication's logo+title (header) and the publication name for the author's avatar/name/handle (eyebrow). Page behaviour is pinned with source-level regex tests in the existing colocated `_*.meta.test.ts` files (page rendering isn't unit-testable in this jsdom suite). 8 + 9 + **Tech Stack:** Astro (SSR pages), TypeScript, Vitest, `Intl.DateTimeFormat`. 10 + 11 + Design: `docs/superpowers/specs/2026-06-09-reading-page-author-focus-design.md` 12 + 13 + --- 14 + 15 + ## File structure 16 + 17 + - Create: `src/lib/reader/dates.ts` — pure `formatLongDate(iso)` long-English formatter. 18 + - Create: `src/lib/reader/dates.test.ts` — unit tests for the formatter. 19 + - Modify: `src/pages/[author]/[slug]/index.astro` — remove header, long dates. 20 + - Modify: `src/pages/[author]/[slug]/_index.meta.test.ts` — pin header-removal + date. 21 + - Modify: `src/pages/[author]/[slug]/[rkey].astro` — publication header, author eyebrow, long dates. 22 + - Modify: `src/pages/[author]/[slug]/_[rkey].meta.test.ts` — pin new header + eyebrow + date. 23 + 24 + --- 25 + 26 + ## Task 1: Long-date helper 27 + 28 + **Files:** 29 + - Create: `src/lib/reader/dates.ts` 30 + - Test: `src/lib/reader/dates.test.ts` 31 + 32 + - [ ] **Step 1: Write the failing test** 33 + 34 + Create `src/lib/reader/dates.test.ts`: 35 + 36 + ```ts 37 + import { describe, expect, it } from 'vitest'; 38 + import { formatLongDate } from './dates'; 39 + 40 + describe( 'formatLongDate', () => { 41 + it( 'formats an ISO datetime as a long English date', () => { 42 + expect( formatLongDate( '2026-06-09T12:00:00Z' ) ).toBe( 'June 9, 2026' ); 43 + } ); 44 + 45 + it( 'pins the calendar day to UTC regardless of time-of-day', () => { 46 + // Late-evening UTC must not roll forward to the 10th. 47 + expect( formatLongDate( '2026-06-09T23:30:00Z' ) ).toBe( 'June 9, 2026' ); 48 + // Early-morning UTC must not roll back to the 8th. 49 + expect( formatLongDate( '2026-06-09T00:30:00Z' ) ).toBe( 'June 9, 2026' ); 50 + } ); 51 + 52 + it( 'accepts a date-only string', () => { 53 + expect( formatLongDate( '2026-12-25' ) ).toBe( 'December 25, 2026' ); 54 + } ); 55 + } ); 56 + ``` 57 + 58 + - [ ] **Step 2: Run test to verify it fails** 59 + 60 + Run: `npm run test -- src/lib/reader/dates.test.ts` 61 + Expected: FAIL — cannot resolve `./dates` (module not found). 62 + 63 + - [ ] **Step 3: Write minimal implementation** 64 + 65 + Create `src/lib/reader/dates.ts`: 66 + 67 + ```ts 68 + /** 69 + * Format an ISO date/datetime as a long English date, e.g. "June 9, 2026". 70 + * 71 + * The reading pages are server-rendered and the UI is English, so we render a 72 + * fixed `en-US` long format rather than negotiating the reader's locale (which 73 + * would need client JS — disallowed on the read path). `timeZone: 'UTC'` pins 74 + * the rendered calendar day so a late-evening UTC timestamp can't roll to the 75 + * next day on a server in a western zone. 76 + */ 77 + const longDate = new Intl.DateTimeFormat( 'en-US', { 78 + dateStyle: 'long', 79 + timeZone: 'UTC', 80 + } ); 81 + 82 + export function formatLongDate( iso: string ): string { 83 + return longDate.format( new Date( iso ) ); 84 + } 85 + ``` 86 + 87 + - [ ] **Step 4: Run test to verify it passes** 88 + 89 + Run: `npm run test -- src/lib/reader/dates.test.ts` 90 + Expected: PASS (3 tests). 91 + 92 + - [ ] **Step 5: Commit** 93 + 94 + ```bash 95 + git add src/lib/reader/dates.ts src/lib/reader/dates.test.ts 96 + git commit --no-gpg-sign -m "Add formatLongDate reader helper" 97 + ``` 98 + 99 + --- 100 + 101 + ## Task 2: Publication page — remove header, long dates 102 + 103 + **Files:** 104 + - Modify: `src/pages/[author]/[slug]/index.astro` 105 + - Test: `src/pages/[author]/[slug]/_index.meta.test.ts` 106 + 107 + - [ ] **Step 1: Write the failing test** 108 + 109 + Append these two `describe` blocks to `src/pages/[author]/[slug]/_index.meta.test.ts` (after the existing blocks, before the final newline): 110 + 111 + ```ts 112 + describe( 'publication page focuses on the publication', () => { 113 + it( 'no longer renders the SkyPress masthead/logo', () => { 114 + expect( page ).not.toMatch( /class="masthead"/ ); 115 + expect( page ).not.toMatch( /import Logo from/ ); 116 + } ); 117 + 118 + it( 'renders article dates in long format via formatLongDate', () => { 119 + expect( page ).toMatch( 120 + /import\s*\{\s*formatLongDate\s*\}\s*from\s*'[^']*lib\/reader\/dates'/ 121 + ); 122 + expect( page ).toMatch( /formatLongDate\(/ ); 123 + // The old ISO-slice date format is gone. 124 + expect( page ).not.toMatch( /publishedAt\?\.slice\(\s*0,\s*10\s*\)/ ); 125 + } ); 126 + } ); 127 + ``` 128 + 129 + - [ ] **Step 2: Run test to verify it fails** 130 + 131 + Run: `npm run test -- "src/pages/[author]/[slug]/_index.meta.test.ts"` 132 + Expected: FAIL — masthead/Logo still present, `formatLongDate` not imported. 133 + 134 + - [ ] **Step 3: Implement the changes** 135 + 136 + In `src/pages/[author]/[slug]/index.astro`: 137 + 138 + a) Remove the `Logo` import line: 139 + 140 + ```astro 141 + import Logo from '../../../components/Logo.astro'; 142 + ``` 143 + 144 + b) Add the `formatLongDate` import after the `fetchActorProfile` import: 145 + 146 + ```astro 147 + import { formatLongDate } from '../../../lib/reader/dates'; 148 + ``` 149 + 150 + c) Change the article date mapping. Replace: 151 + 152 + ```astro 153 + publishedLabel: record.value.publishedAt?.slice( 0, 10 ) ?? null, 154 + ``` 155 + 156 + with: 157 + 158 + ```astro 159 + publishedLabel: record.value.publishedAt 160 + ? formatLongDate( record.value.publishedAt ) 161 + : null, 162 + ``` 163 + 164 + d) Remove the entire header block: 165 + 166 + ```astro 167 + <header class="masthead"> 168 + <a href="/"><Logo /></a> 169 + </header> 170 + 171 + ``` 172 + 173 + e) Remove the now-unused masthead CSS rules from the `<style>` block: 174 + 175 + ```css 176 + .masthead { 177 + padding: 1.5rem clamp(1.25rem, 5vw, 4rem); 178 + border-bottom: 1px solid var(--line); 179 + } 180 + .masthead a { 181 + text-decoration: none; 182 + } 183 + ``` 184 + 185 + - [ ] **Step 4: Run tests + check** 186 + 187 + Run: `npm run test -- "src/pages/[author]/[slug]/_index.meta.test.ts"` 188 + Expected: PASS (all blocks, including the two new ones). 189 + 190 + Run: `npm run check` 191 + Expected: no type/astro errors. 192 + 193 + - [ ] **Step 5: Commit** 194 + 195 + ```bash 196 + git add "src/pages/[author]/[slug]/index.astro" "src/pages/[author]/[slug]/_index.meta.test.ts" 197 + git commit --no-gpg-sign -m "Drop SkyPress header and use long dates on publication page" 198 + ``` 199 + 200 + --- 201 + 202 + ## Task 3: Single post page — publication header + author eyebrow + long dates 203 + 204 + **Files:** 205 + - Modify: `src/pages/[author]/[slug]/[rkey].astro` 206 + - Test: `src/pages/[author]/[slug]/_[rkey].meta.test.ts` 207 + 208 + - [ ] **Step 1: Write the failing test** 209 + 210 + Append this `describe` block to `src/pages/[author]/[slug]/_[rkey].meta.test.ts` (after the existing block): 211 + 212 + ```ts 213 + describe( 'document page focuses on publication + author', () => { 214 + it( 'replaces the SkyPress logo header with the publication logo + title linking to the publication', () => { 215 + expect( page ).not.toMatch( /import Logo from/ ); 216 + expect( page ).not.toMatch( /<a href="\/"><Logo \/><\/a>/ ); 217 + expect( page ).toMatch( /class="masthead__pub"\s+href=\{pubUrl\}/ ); 218 + expect( page ).toMatch( /class="masthead__title">\{publication!?\.name\}/ ); 219 + } ); 220 + 221 + it( 'fetches the author profile for the eyebrow', () => { 222 + expect( page ).toMatch( 223 + /import\s*\{\s*fetchActorProfile\s*\}\s*from\s*'[^']*lib\/reader\/profile'/ 224 + ); 225 + expect( page ).toMatch( /fetchActorProfile\(/ ); 226 + } ); 227 + 228 + it( 'shows the author (name, avatar, handle) in the eyebrow, linked to the profile', () => { 229 + expect( page ).toMatch( /class="reader__author"\s+href=\{`\/@\$\{\s*handle\s*\}`\}/ ); 230 + expect( page ).toMatch( /reader__avatar/ ); 231 + expect( page ).toMatch( /authorName/ ); 232 + // The publication name no longer stands in for the byline in the eyebrow. 233 + expect( page ).not.toMatch( /class="reader__author"\s+href=\{pubUrl\}/ ); 234 + } ); 235 + 236 + it( 'renders dates in long format via formatLongDate', () => { 237 + expect( page ).toMatch( 238 + /import\s*\{\s*formatLongDate\s*\}\s*from\s*'[^']*lib\/reader\/dates'/ 239 + ); 240 + expect( page ).toMatch( /formatLongDate\(/ ); 241 + expect( page ).not.toMatch( /\.slice\(\s*0,\s*10\s*\)/ ); 242 + } ); 243 + } ); 244 + ``` 245 + 246 + - [ ] **Step 2: Run test to verify it fails** 247 + 248 + Run: `npm run test -- "src/pages/[author]/[slug]/_[rkey].meta.test.ts"` 249 + Expected: FAIL — Logo import present, no `masthead__pub`, no `fetchActorProfile`, no `formatLongDate`. 250 + 251 + - [ ] **Step 3: Implement the changes** 252 + 253 + In `src/pages/[author]/[slug]/[rkey].astro`: 254 + 255 + a) Remove the `Logo` import line: 256 + 257 + ```astro 258 + import Logo from '../../../components/Logo.astro'; 259 + ``` 260 + 261 + b) Add these imports after the `resolveReaderPublication` import: 262 + 263 + ```astro 264 + import { fetchActorProfile } from '../../../lib/reader/profile'; 265 + import { formatLongDate } from '../../../lib/reader/dates'; 266 + ``` 267 + 268 + c) Add new top-level `let` declarations alongside the existing ones (after `let updatedLabel: string | null = null;`): 269 + 270 + ```astro 271 + let authorName = ''; 272 + let authorHandle: string | null = null; 273 + let authorAvatar: string | null = null; 274 + let logoUrl: string | null = null; 275 + let initial = ''; 276 + ``` 277 + 278 + d) In the success branch (the `else` after `record.value` is validated), set the author + logo values. Add, right after `themeStyle = themeStyleBlock( publication.basicTheme );`: 279 + 280 + ```astro 281 + const profile = await fetchActorProfile( pdsUrl, did ); 282 + authorName = profile.displayName ?? `@${ handle }`; 283 + // Only show a separate @handle chip when there's a distinct display name. 284 + authorHandle = profile.displayName ? `@${ handle }` : null; 285 + authorAvatar = profile.avatar; 286 + logoUrl = publication.icon 287 + ? buildGetBlobUrl( pdsUrl, did, publication.icon.ref.$link ) 288 + : null; 289 + initial = publication.name.charAt( 0 ).toUpperCase(); 290 + ``` 291 + 292 + e) Change the date labels. Replace: 293 + 294 + ```astro 295 + publishedLabel = doc.publishedAt ? doc.publishedAt.slice( 0, 10 ) : null; 296 + updatedLabel = doc.updatedAt ? doc.updatedAt.slice( 0, 10 ) : null; 297 + ``` 298 + 299 + with: 300 + 301 + ```astro 302 + publishedLabel = doc.publishedAt ? formatLongDate( doc.publishedAt ) : null; 303 + updatedLabel = doc.updatedAt ? formatLongDate( doc.updatedAt ) : null; 304 + ``` 305 + 306 + f) Replace the header block. Replace: 307 + 308 + ```astro 309 + <header class="masthead"> 310 + <a href="/"><Logo /></a> 311 + </header> 312 + ``` 313 + 314 + with: 315 + 316 + ```astro 317 + <header class="masthead"> 318 + <a class="masthead__pub" href={pubUrl}> 319 + {logoUrl ? ( 320 + <img class="masthead__logo" src={logoUrl} alt="" width="36" height="36" /> 321 + ) : ( 322 + <span class="masthead__logo masthead__logo--fallback" aria-hidden="true">{initial}</span> 323 + )} 324 + <span class="masthead__title">{publication!.name}</span> 325 + </a> 326 + </header> 327 + ``` 328 + 329 + g) Replace the eyebrow block. Replace: 330 + 331 + ```astro 332 + <p class="reader__meta eyebrow"> 333 + <a class="reader__author" href={pubUrl}>{publication!.name}</a> 334 + {publishedLabel && <> · {publishedLabel}</>} 335 + {updatedLabel && <> · updated {updatedLabel}</>} 336 + · {readingMinutes} min read 337 + </p> 338 + ``` 339 + 340 + with: 341 + 342 + ```astro 343 + <p class="reader__meta eyebrow"> 344 + <a class="reader__author" href={`/@${ handle }`}> 345 + {authorAvatar && ( 346 + <img class="reader__avatar" src={authorAvatar} alt="" width="22" height="22" /> 347 + )} 348 + <span class="reader__authorname">{authorName}</span> 349 + {authorHandle && <span class="reader__handle">{authorHandle}</span>} 350 + </a> 351 + {publishedLabel && <> · {publishedLabel}</>} 352 + {updatedLabel && <> · updated {updatedLabel}</>} 353 + · {readingMinutes} min read 354 + </p> 355 + ``` 356 + 357 + h) Update the `<style>` block. Add the publication-header styles right after the existing `.masthead a { text-decoration: none; }` rule: 358 + 359 + ```css 360 + .masthead__pub { 361 + display: inline-flex; 362 + align-items: center; 363 + gap: 0.6rem; 364 + color: var(--ink); 365 + } 366 + .masthead__logo { 367 + width: 36px; 368 + height: 36px; 369 + border-radius: 9px; 370 + object-fit: cover; 371 + background: var(--paper-raised); 372 + } 373 + .masthead__logo--fallback { 374 + display: inline-flex; 375 + align-items: center; 376 + justify-content: center; 377 + font-family: var(--font-display); 378 + font-weight: 700; 379 + color: var(--sun); 380 + background: var(--sun-tint); 381 + } 382 + .masthead__title { 383 + font-family: var(--font-display); 384 + font-weight: 700; 385 + font-size: 1.18rem; 386 + letter-spacing: -0.015em; 387 + } 388 + ``` 389 + 390 + Then replace the existing `.reader__author` rule: 391 + 392 + ```css 393 + .reader__author { 394 + color: var(--sun); 395 + text-decoration: none; 396 + } 397 + ``` 398 + 399 + with: 400 + 401 + ```css 402 + .reader__author { 403 + display: inline-flex; 404 + align-items: center; 405 + gap: 0.4rem; 406 + color: var(--ink-soft); 407 + text-decoration: none; 408 + } 409 + .reader__author:hover { 410 + color: var(--sun); 411 + } 412 + .reader__avatar { 413 + width: 22px; 414 + height: 22px; 415 + border-radius: 50%; 416 + object-fit: cover; 417 + } 418 + .reader__handle { 419 + color: var(--muted); 420 + } 421 + ``` 422 + 423 + - [ ] **Step 4: Run tests + check** 424 + 425 + Run: `npm run test -- "src/pages/[author]/[slug]/_[rkey].meta.test.ts"` 426 + Expected: PASS (existing OG/error blocks + the new focus block). 427 + 428 + Run: `npm run check` 429 + Expected: no type/astro errors. (In particular `Logo` must be gone with no remaining references.) 430 + 431 + - [ ] **Step 5: Commit** 432 + 433 + ```bash 434 + git add "src/pages/[author]/[slug]/[rkey].astro" "src/pages/[author]/[slug]/_[rkey].meta.test.ts" 435 + git commit --no-gpg-sign -m "Show publication header and author byline on single post page" 436 + ``` 437 + 438 + --- 439 + 440 + ## Task 4: Full verification 441 + 442 + - [ ] **Step 1: Run the whole test suite** 443 + 444 + Run: `npm run test` 445 + Expected: all suites PASS (no regressions in render, sanitize, meta, etc.). 446 + 447 + - [ ] **Step 2: Type/astro check** 448 + 449 + Run: `npm run check` 450 + Expected: clean. 451 + 452 + - [ ] **Step 3: Build smoke** 453 + 454 + Run: `npm run build` 455 + Expected: build succeeds (the read pages are `prerender = false`, so this confirms no import-time breakage). 456 + 457 + --- 458 + 459 + ## Self-review notes 460 + 461 + - **Spec coverage:** header removal (Task 2d/e), publication header swap (Task 3f/h), 462 + author eyebrow (Task 3d/g/h), long dates both pages (Tasks 2c, 3e + helper Task 1). 463 + All four design goals mapped. 464 + - **No-JS constraint:** the date helper runs at SSR time; no client script added. 465 + - **Profile degradation:** `fetchActorProfile` returns nulls (never throws) on failure, 466 + so a missing profile yields `authorName = @handle`, no avatar, no separate handle chip — 467 + the page still renders. 468 + - **Type consistency:** `authorName`/`authorHandle`/`authorAvatar`/`logoUrl`/`initial` 469 + declared in Task 3c, assigned in 3d, consumed in 3f/3g.