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 owner-only "Create your first publication" CTA on the empty author page

When a signed-in writer views their own public author page and has no
publications yet, surface a button linking to /dashboard instead of only the
"No SkyPress publications yet." line. The button is gated to the profile owner
(viewer DID === profile DID); logged-out visitors and other accounts see the
unchanged static text.

The author page is server-rendered and cannot import the auth stack, so owner
detection runs in a client:only React island (CreatePublicationCta) wrapping the
existing @wordpress-free AuthProvider. The DID-comparison decision lives in a
pure isProfileOwner helper, unit-tested per the repo's pure-logic test
convention; its CSS sits in a global block since the island's DOM is outside
Astro's scoped styles.

+484 -1
+249
docs/superpowers/plans/2026-06-09-empty-state-create-publication-cta.md
··· 1 + # Empty-state "Create your first publication" CTA 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:** Show a "Create your first publication" button next to the public author page's "No SkyPress publications yet." empty state, visible only to the signed-in owner of that profile. 6 + 7 + **Architecture:** The author page (`src/pages/[author]/index.astro`) is server-rendered and cannot import the auth stack. Owner detection happens in a small `client:only="react"` island (`CreatePublicationCta`) that wraps the existing `@wordpress`-free `AuthProvider` and compares the viewer's DID against the server-resolved profile DID. The DID-comparison decision is extracted into a pure helper (`isProfileOwner`) so it can be unit-tested — the repo has no React component-test framework, only vitest/jsdom over pure-logic `.test.ts` files. 8 + 9 + **Tech Stack:** Astro (islands, `client:only="react"`), React 18, `@atproto/oauth-client-browser` via `AuthProvider`, vitest. 10 + 11 + **Reference spec:** `docs/superpowers/specs/2026-06-09-empty-state-create-publication-cta-design.md` 12 + 13 + --- 14 + 15 + ### Task 1: Pure owner-detection helper 16 + 17 + **Files:** 18 + - Create: `src/lib/auth/cta.ts` 19 + - Test: `src/lib/auth/cta.test.ts` 20 + 21 + - [ ] **Step 1: Write the failing test** 22 + 23 + Create `src/lib/auth/cta.test.ts`: 24 + 25 + ```ts 26 + import { describe, expect, it } from 'vitest'; 27 + import { isProfileOwner } from './cta'; 28 + 29 + const PROFILE = 'did:plc:profile'; 30 + 31 + describe( 'isProfileOwner', () => { 32 + it( 'is true when signed in and the viewer DID matches the profile DID', () => { 33 + expect( isProfileOwner( 'signed-in', 'did:plc:profile', PROFILE ) ).toBe( true ); 34 + } ); 35 + 36 + it( 'is false when signed in as a different DID', () => { 37 + expect( isProfileOwner( 'signed-in', 'did:plc:someone-else', PROFILE ) ).toBe( false ); 38 + } ); 39 + 40 + it( 'is false when the viewer DID is null', () => { 41 + expect( isProfileOwner( 'signed-in', null, PROFILE ) ).toBe( false ); 42 + } ); 43 + 44 + it( 'is false for loading, signed-out and error statuses even if a DID matches', () => { 45 + expect( isProfileOwner( 'loading', PROFILE, PROFILE ) ).toBe( false ); 46 + expect( isProfileOwner( 'signed-out', PROFILE, PROFILE ) ).toBe( false ); 47 + expect( isProfileOwner( 'error', PROFILE, PROFILE ) ).toBe( false ); 48 + } ); 49 + } ); 50 + ``` 51 + 52 + - [ ] **Step 2: Run test to verify it fails** 53 + 54 + Run: `npm test -- src/lib/auth/cta.test.ts` 55 + Expected: FAIL — cannot resolve `./cta` / `isProfileOwner is not a function`. 56 + 57 + - [ ] **Step 3: Write minimal implementation** 58 + 59 + Create `src/lib/auth/cta.ts`: 60 + 61 + ```ts 62 + import type { AuthStatus } from './AuthProvider'; 63 + 64 + /** 65 + * Whether the signed-in viewer owns the profile being viewed. 66 + * 67 + * The public author page resolves the profile DID server-side; the viewer DID 68 + * comes from the browser-only auth session. The CTA to create a publication is 69 + * meaningful only to the owner, since publications are always written under the 70 + * viewer's own DID. 71 + */ 72 + export function isProfileOwner( 73 + status: AuthStatus, 74 + viewerDid: string | null, 75 + profileDid: string 76 + ): boolean { 77 + return status === 'signed-in' && viewerDid !== null && viewerDid === profileDid; 78 + } 79 + ``` 80 + 81 + - [ ] **Step 4: Run test to verify it passes** 82 + 83 + Run: `npm test -- src/lib/auth/cta.test.ts` 84 + Expected: PASS (4 tests). 85 + 86 + - [ ] **Step 5: Commit** 87 + 88 + ```bash 89 + git add src/lib/auth/cta.ts src/lib/auth/cta.test.ts 90 + git commit --no-gpg-sign -m "Add isProfileOwner helper for the author-page CTA" 91 + ``` 92 + 93 + --- 94 + 95 + ### Task 2: The CreatePublicationCta island 96 + 97 + **Files:** 98 + - Create: `src/components/CreatePublicationCta.tsx` 99 + 100 + This is a thin React island with no pure logic of its own (the decision lives in `isProfileOwner`, already tested in Task 1), so it follows the repo convention of unit-testing the helper and verifying the island manually. No new test file. 101 + 102 + - [ ] **Step 1: Write the component** 103 + 104 + Create `src/components/CreatePublicationCta.tsx`: 105 + 106 + ```tsx 107 + import { AuthProvider } from '../lib/auth/AuthProvider'; 108 + import { useAuth } from '../lib/auth/useAuth'; 109 + import { isProfileOwner } from '../lib/auth/cta'; 110 + 111 + /** 112 + * Owner-only CTA shown beneath the public author page's empty state. Renders 113 + * nothing unless the signed-in viewer owns this profile, so logged-out visitors 114 + * and other writers just see the static "No SkyPress publications yet." line. 115 + * 116 + * Reading-page island: imports only the `@wordpress`-free auth chain (AGENTS.md 117 + * rules 3 & 7). Mounted with `client:only="react"`. 118 + */ 119 + function Cta( { profileDid }: { profileDid: string } ) { 120 + const { status, did } = useAuth(); 121 + if ( ! isProfileOwner( status, did, profileDid ) ) { 122 + return null; 123 + } 124 + return ( 125 + <a className="author__cta" href="/dashboard"> 126 + Create your first publication 127 + </a> 128 + ); 129 + } 130 + 131 + export default function CreatePublicationCta( { profileDid }: { profileDid: string } ) { 132 + return ( 133 + <AuthProvider> 134 + <Cta profileDid={ profileDid } /> 135 + </AuthProvider> 136 + ); 137 + } 138 + ``` 139 + 140 + - [ ] **Step 2: Type-check** 141 + 142 + Run: `npm run check` 143 + Expected: PASS — no type errors. 144 + 145 + - [ ] **Step 3: Commit** 146 + 147 + ```bash 148 + git add src/components/CreatePublicationCta.tsx 149 + git commit --no-gpg-sign -m "Add CreatePublicationCta owner-only island" 150 + ``` 151 + 152 + --- 153 + 154 + ### Task 3: Mount the CTA on the author page 155 + 156 + **Files:** 157 + - Modify: `src/pages/[author]/index.astro` (import; the `publications.length === 0` branch around line 55; the `<style>`/global-style block) 158 + 159 + - [ ] **Step 1: Import the island** 160 + 161 + In the frontmatter imports of `src/pages/[author]/index.astro`, after the existing component/util imports (e.g. after the `buildGetBlobUrl` import on line 7), add: 162 + 163 + ```ts 164 + import CreatePublicationCta from '../../components/CreatePublicationCta.tsx'; 165 + ``` 166 + 167 + - [ ] **Step 2: Render the island in the empty-state branch** 168 + 169 + Replace the empty-state branch (currently): 170 + 171 + ```jsx 172 + {publications.length === 0 ? ( 173 + <p class="author__empty">No SkyPress publications yet.</p> 174 + ) : ( 175 + ``` 176 + 177 + with: 178 + 179 + ```jsx 180 + {publications.length === 0 ? ( 181 + <div class="author__emptyblock"> 182 + <p class="author__empty">No SkyPress publications yet.</p> 183 + <CreatePublicationCta client:only="react" profileDid={did} /> 184 + </div> 185 + ) : ( 186 + ``` 187 + 188 + `did` is already destructured from `resolved` earlier in the frontmatter (line 21), so it is in scope. 189 + 190 + - [ ] **Step 3: Add styles** 191 + 192 + The page's existing `<style>` block (starting at line 86) is Astro-scoped and will **not** reach the `client:only` island's DOM. Add the button rule in a new global block so it applies to the island. Append after the closing `</style>` of the existing scoped block (the file currently ends at the `</style>` on line 211): 193 + 194 + ```html 195 + <!-- CreatePublicationCta is a `client:only` React island, so Astro's scoped 196 + styles never reach its DOM. The CTA rule must be global. --> 197 + <style is:global> 198 + .author__emptyblock { 199 + display: flex; 200 + flex-direction: column; 201 + align-items: flex-start; 202 + gap: 1rem; 203 + } 204 + .author__cta { 205 + display: inline-block; 206 + border-radius: 8px; 207 + background: var(--sun); 208 + color: #fff; 209 + font-weight: 600; 210 + text-decoration: none; 211 + padding: 0.5rem 1rem; 212 + } 213 + .author__cta:hover { 214 + text-decoration: none; 215 + opacity: 0.92; 216 + } 217 + </style> 218 + ``` 219 + 220 + Note: `.author__empty` itself stays styled by the existing scoped rule (line 157) — it is server-rendered, so the scoped style reaches it. Only the island-rendered `.author__cta` needs the global rule. The `.author__emptyblock` wrapper is server-rendered too, but lives in the global block here for cohesion with the CTA spacing; this is harmless (a global rule also matches server-rendered DOM). 221 + 222 + - [ ] **Step 4: Type-check and run the full test suite** 223 + 224 + Run: `npm run check && npm test` 225 + Expected: type-check PASS; all tests PASS (including `cta.test.ts`). 226 + 227 + - [ ] **Step 5: Manual verification** 228 + 229 + Run: `npm run dev` and open `http://127.0.0.1:<port>` (loopback, per AGENTS.md rule 7 — not `localhost`). 230 + Verify: 231 + - Signed out, visit an author page with no publications → only "No SkyPress publications yet." (no button). 232 + - Signed in as the profile owner, same page → "Create your first publication" button appears and links to `/dashboard`. 233 + - Signed in as a different account, viewing the first profile → no button. 234 + 235 + - [ ] **Step 6: Commit** 236 + 237 + ```bash 238 + git add src/pages/\[author\]/index.astro 239 + git commit --no-gpg-sign -m "Show create-publication CTA to the owner on the empty author page" 240 + ``` 241 + 242 + --- 243 + 244 + ## Self-review notes 245 + 246 + - **Spec coverage:** pure helper (Task 1) ↔ spec "New — pure gating helper"; island (Task 2) ↔ "New — CTA island"; page mount + global style (Task 3) ↔ "Changed — `[author]/index.astro`"; tests (Task 1) ↔ spec "Testing"; manual + guard verification (Task 3 step 5) ↔ spec manual checks. 247 + - **Types:** `isProfileOwner(status: AuthStatus, viewerDid: string | null, profileDid: string)` is defined once in Task 1 and called identically in Task 2. `AuthStatus` is exported from `AuthProvider.tsx` (verified). `useAuth()` returns `{ status, did, ... }` (verified). 248 + - **No placeholders:** every code/command step is concrete. 249 + - **`@wordpress` guard:** the island imports only `AuthProvider` / `useAuth` / `cta`, all `@wordpress`-free (same chain `AuthorPill` already uses on a reading page).
+134
docs/superpowers/specs/2026-06-09-empty-state-create-publication-cta-design.md
··· 1 + # Empty-state "Create your first publication" CTA on the public author page 2 + 3 + **Date:** 2026-06-09 4 + **Status:** Approved (design) 5 + 6 + ## Summary 7 + 8 + When a writer who has signed in visits their own public author page 9 + (`/@{handle}`) and has **no publications yet**, the page shows a plain 10 + `No SkyPress publications yet.` line. This project layers a 11 + **"Create your first publication"** call-to-action next to that line — 12 + visible *only* to the signed-in owner of the profile — linking to 13 + `/dashboard`, where publications are created. 14 + 15 + For every other visitor (logged out, or signed in as someone else) the page 16 + is unchanged: the static empty-state text stands alone. 17 + 18 + ## Constraints honored 19 + 20 + - **Reading pages must never import `@wordpress/*`** (AGENTS.md rule 3). The 21 + CTA reuses the existing `@wordpress`-free auth chain (`AuthProvider` → 22 + `oauth` → `config` / `media/pds`), same as the `AuthorPill` island, so no 23 + editor bundle reaches the reading page. 24 + - **OAuth is a browser public client** (AGENTS.md rule 7). The CTA resolves the 25 + session client-side only; the empty-state text remains server-rendered. 26 + - The CTA is **read-only** chrome — it links to `/dashboard`; it does not sign 27 + in, sign out, or create anything itself. 28 + 29 + ## Decisions (from brainstorming) 30 + 31 + - **Audience:** owner-only. Render the CTA only when the signed-in viewer's 32 + DID equals the profile's DID (resolved server-side and passed to the island). 33 + A logged-in non-owner can only create publications under their *own* DID, so 34 + a button on someone else's profile would mislead. 35 + - **Placement:** directly after the existing `No SkyPress publications yet.` 36 + paragraph, inside the `publications.length === 0` branch. The static text 37 + stays server-rendered (good for logged-out visitors and SEO); the button 38 + layers in for the owner once auth resolves. 39 + - **Label & target:** "Create your first publication" → `/dashboard`. No 40 + deep-link that auto-opens the create form; `/dashboard` already surfaces 41 + "+ New publication" and its own empty-state prompt. 42 + - **Loading / non-owner / signed-out / error:** render nothing extra. No 43 + skeleton — avoids flashing a placeholder at the common (non-owner) visitor. 44 + - **Implementation approach:** a dedicated `client:only` island wrapping 45 + `AuthProvider`, mirroring `AuthorPill`. Gating logic extracted to a pure 46 + helper so it is unit-tested without a component-test framework. 47 + 48 + ## Components & files 49 + 50 + ### New — pure gating helper 51 + 52 + A small pure function in a new file `src/lib/auth/cta.ts`, so the decision is 53 + unit-testable without a component-test framework: 54 + 55 + ```ts 56 + import type { AuthStatus } from './AuthProvider'; 57 + 58 + // True only when the signed-in viewer owns the profile being viewed. 59 + export function isProfileOwner( 60 + status: AuthStatus, 61 + viewerDid: string | null, 62 + profileDid: string 63 + ): boolean { 64 + return status === 'signed-in' && viewerDid !== null && viewerDid === profileDid; 65 + } 66 + ``` 67 + 68 + ### New — CTA island: `src/components/CreatePublicationCta.tsx` (`client:only="react"`) 69 + 70 + - Wraps `AuthProvider` (default export) around a thin gate, like `Studio` / 71 + `Dashboard`. 72 + - Prop: `profileDid: string`. 73 + - Gate consumes `useAuth()`, calls `isProfileOwner(status, did, profileDid)`. 74 + - `false` → render `null`. 75 + - `true` → render `<a class="author__cta" href="/dashboard">Create your 76 + first publication</a>`. 77 + 78 + ### Changed — `src/pages/[author]/index.astro` 79 + 80 + - Import `CreatePublicationCta`. 81 + - In the `publications.length === 0` branch, after the empty-state `<p>`, add 82 + `<CreatePublicationCta client:only="react" profileDid={did} />`. 83 + - Add a `<style is:global>` rule for `.author__cta` (the island is 84 + `client:only`, so the page's scoped styles never reach it — same constraint 85 + noted in `dashboard.astro`). Style it as a sun-colored button matching 86 + `.dash__new`. 87 + 88 + ## Data flow & states 89 + 90 + 1. Server renders the profile page; the empty-state `<p>` is in the HTML 91 + immediately. `did` (profile DID) is already resolved server-side. 92 + 2. `CreatePublicationCta` hydrates → `AuthProvider` runs `createOAuthClient()` 93 + → `client.init()`. 94 + 3. No session / signed in as a different DID / `loading` / `error` → island 95 + renders nothing; only the static text shows. 96 + 4. Session restored AND viewer DID === profile DID → the 97 + "Create your first publication" button appears beneath the text. 98 + 99 + ## Error handling & edge cases 100 + 101 + - `client.init()` throws → `AuthProvider` sets `status === 'error'`; 102 + `isProfileOwner` returns `false` → no button. The page is unharmed. 103 + - Session restored but profile fetch within `AuthProvider` partially fails: 104 + irrelevant here — gating uses `status` and `did` only, both set as soon as 105 + the session is adopted. 106 + - Profile DID is always present (the page 404s earlier if the author can't be 107 + resolved), so the prop is never empty. 108 + 109 + ## Testing (test-first) 110 + 111 + Following the repo convention (no React component-test setup; vitest/jsdom runs 112 + only pure-logic `src/**/*.test.ts`): test the **pure helper**, keep the island 113 + thin, verify the island manually. 114 + 115 + - `cta.test.ts` (new): 116 + - returns `true` when `status === 'signed-in'` and `viewerDid === profileDid`. 117 + - returns `false` when signed in as a different DID. 118 + - returns `false` when `viewerDid` is `null`. 119 + - returns `false` for `loading`, `signed-out`, and `error` statuses. 120 + - Type-check: `npm run check`. 121 + - Manual verification (`npm run dev`): own empty profile while signed in → 122 + button shows and links to `/dashboard`; signed out → text only; signed in as 123 + another account viewing the first profile → text only. 124 + - Guard: no `@wordpress` import added to the reading-page bundle (the CTA 125 + imports only the already-`@wordpress`-free auth chain). 126 + - No `render.ts` / fidelity changes (no new blocks). 127 + 128 + ## Out of scope 129 + 130 + - A deep-link that auto-opens the create form on `/dashboard`. 131 + - Any CTA for the non-empty state, or for non-owners. 132 + - Changes to `/dashboard` itself. 133 + - Mirroring the CTA onto a publication's empty post list (that already has its 134 + own "Open the studio" prompt).
+31
src/components/CreatePublicationCta.tsx
··· 1 + import { AuthProvider } from '../lib/auth/AuthProvider'; 2 + import { useAuth } from '../lib/auth/useAuth'; 3 + import { isProfileOwner } from '../lib/auth/cta'; 4 + 5 + /** 6 + * Owner-only CTA shown beneath the public author page's empty state. Renders 7 + * nothing unless the signed-in viewer owns this profile, so logged-out visitors 8 + * and other writers just see the static "No SkyPress publications yet." line. 9 + * 10 + * Reading-page island: imports only the `@wordpress`-free auth chain (AGENTS.md 11 + * rules 3 & 7). Mounted with `client:only="react"`. 12 + */ 13 + function Cta( { profileDid }: { profileDid: string } ) { 14 + const { status, did } = useAuth(); 15 + if ( ! isProfileOwner( status, did, profileDid ) ) { 16 + return null; 17 + } 18 + return ( 19 + <a className="author__cta" href="/dashboard"> 20 + Create your first publication 21 + </a> 22 + ); 23 + } 24 + 25 + export default function CreatePublicationCta( { profileDid }: { profileDid: string } ) { 26 + return ( 27 + <AuthProvider> 28 + <Cta profileDid={ profileDid } /> 29 + </AuthProvider> 30 + ); 31 + }
+24
src/lib/auth/cta.test.ts
··· 1 + import { describe, expect, it } from 'vitest'; 2 + import { isProfileOwner } from './cta'; 3 + 4 + const PROFILE = 'did:plc:profile'; 5 + 6 + describe( 'isProfileOwner', () => { 7 + it( 'is true when signed in and the viewer DID matches the profile DID', () => { 8 + expect( isProfileOwner( 'signed-in', 'did:plc:profile', PROFILE ) ).toBe( true ); 9 + } ); 10 + 11 + it( 'is false when signed in as a different DID', () => { 12 + expect( isProfileOwner( 'signed-in', 'did:plc:someone-else', PROFILE ) ).toBe( false ); 13 + } ); 14 + 15 + it( 'is false when the viewer DID is null', () => { 16 + expect( isProfileOwner( 'signed-in', null, PROFILE ) ).toBe( false ); 17 + } ); 18 + 19 + it( 'is false for loading, signed-out and error statuses even if a DID matches', () => { 20 + expect( isProfileOwner( 'loading', PROFILE, PROFILE ) ).toBe( false ); 21 + expect( isProfileOwner( 'signed-out', PROFILE, PROFILE ) ).toBe( false ); 22 + expect( isProfileOwner( 'error', PROFILE, PROFILE ) ).toBe( false ); 23 + } ); 24 + } );
+17
src/lib/auth/cta.ts
··· 1 + import type { AuthStatus } from './AuthProvider'; 2 + 3 + /** 4 + * Whether the signed-in viewer owns the profile being viewed. 5 + * 6 + * The public author page resolves the profile DID server-side; the viewer DID 7 + * comes from the browser-only auth session. The CTA to create a publication is 8 + * meaningful only to the owner, since publications are always written under the 9 + * viewer's own DID. 10 + */ 11 + export function isProfileOwner( 12 + status: AuthStatus, 13 + viewerDid: string | null, 14 + profileDid: string 15 + ): boolean { 16 + return status === 'signed-in' && viewerDid !== null && viewerDid === profileDid; 17 + }
+29 -1
src/pages/[author]/index.astro
··· 5 5 import { listReaderPublications } from '../../lib/reader/publications'; 6 6 import { fetchActorProfile } from '../../lib/reader/profile'; 7 7 import { buildGetBlobUrl } from '../../lib/media/blob'; 8 + import CreatePublicationCta from '../../components/CreatePublicationCta.tsx'; 8 9 9 10 export const prerender = false; 10 11 ··· 53 54 54 55 <h2 class="author__heading">Publications</h2> 55 56 {publications.length === 0 ? ( 56 - <p class="author__empty">No SkyPress publications yet.</p> 57 + <div class="author__emptyblock"> 58 + <p class="author__empty">No SkyPress publications yet.</p> 59 + <CreatePublicationCta client:only="react" profileDid={did} /> 60 + </div> 57 61 ) : ( 58 62 <ul class="author__list"> 59 63 {publications.map( ( pub ) => { ··· 209 213 font-size: 0.95rem; 210 214 } 211 215 </style> 216 + 217 + <!-- CreatePublicationCta is a `client:only` React island, so Astro's scoped 218 + styles never reach its DOM. The CTA rule must be global. --> 219 + <style is:global> 220 + .author__emptyblock { 221 + display: flex; 222 + flex-direction: column; 223 + align-items: flex-start; 224 + gap: 1rem; 225 + } 226 + .author__cta { 227 + display: inline-block; 228 + border-radius: 8px; 229 + background: var(--sun); 230 + color: #fff; 231 + font-weight: 600; 232 + text-decoration: none; 233 + padding: 0.5rem 1rem; 234 + } 235 + .author__cta:hover { 236 + text-decoration: none; 237 + opacity: 0.92; 238 + } 239 + </style>