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.

Merge branch 'rework-editor-page-layout' into trunk

+2017 -558
+1 -1
README.md
··· 124 124 pages/ index · editor · dashboard · client-metadata.json.ts (OAuth client doc, worker route) 125 125 [author]/index.astro (author index) · [author]/[slug]/index.astro (publication) 126 126 · [author]/[slug]/[rkey].astro (read-through document reader) 127 - components/ Studio · SkyEditor · PublishPanel · MyArticles · Dashboard · PublicationForm · Logo 127 + components/ Studio · SkyEditor · PublishPanel · AppBar · Dashboard · PublicationForm · AccountMenu · CreatePublicationCta · Logo · Footer 128 128 lib/ 129 129 blocks/ render.ts (dependency-free reader) · serialize.ts (@wordpress oracle) · allowlist.ts 130 130 auth/ oauth.ts · AuthProvider.tsx · config.ts · LoginForm.tsx
+1172
docs/superpowers/plans/2026-06-09-editor-page-rework.md
··· 1 + # Editor & Dashboard Rework 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:** Rework `/editor` into a focused writing surface and add a shared, auth-aware top bar across `/editor` and `/dashboard`, with article editing driven from the dashboard. 6 + 7 + **Architecture:** A new `AppBar` React component (consumes the existing client-only `useAuth`) renders at the top of both islands. The editor's published-article list is removed; editing opens via `/editor?edit=<rkey>`, loaded by reusing `listAllMyArticles`. The title becomes a borderless heading above a taller editor canvas. Page→nav mapping and edit-link parsing live in tested pure helpers; the React islands stay thin. 8 + 9 + **Tech Stack:** Astro 5, React 18, TypeScript, `@atproto/api`, vitest (jsdom), `@automattic/isolated-block-editor`. 10 + 11 + **Spec:** `docs/superpowers/specs/2026-06-09-editor-page-rework-design.md` 12 + 13 + --- 14 + 15 + ## File Structure 16 + 17 + **New files:** 18 + - `src/lib/auth/nav.ts` — `appBarNav(current)` pure helper returning the bar's contextual action. 19 + - `src/lib/auth/nav.test.ts` — its unit tests. 20 + - `src/lib/editor/edit-link.ts` — `editLinkFor(rkey)` + `editRkeyFromSearch(search)` pure helpers (single source of the `?edit=` param name). 21 + - `src/lib/editor/edit-link.test.ts` — its unit tests. 22 + - `src/components/AppBar.tsx` — the shared top bar (logo + contextual nav + account block + sign-out). 23 + - `src/styles/app-bar.css` — global styles for the bar, imported by both page shells. 24 + 25 + **Modified files:** 26 + - `src/lib/publish/publisher.ts` — add `getMyArticle`. 27 + - `src/lib/publish/publisher.test.ts` — add `getMyArticle` tests. 28 + - `src/components/Studio.tsx` — mount `AppBar`, remove account bar + `MyArticles`, `?edit=` load, lift title state, taller-canvas layout. 29 + - `src/components/PublishPanel.tsx` — accept `title`/`onTitleChange` props; drop internal title input + state. 30 + - `src/components/Dashboard.tsx` — mount `AppBar`; add **Edit** action to the Posts tab. 31 + - `src/pages/editor.astro` — drop the static header; import `app-bar.css`. 32 + - `src/pages/dashboard.astro` — drop the static header right-side; import `app-bar.css`. 33 + - `src/styles/editor-chrome.css` — remove `.studio__account*` + `.myarticles*`; add `.studio__title`; raise editor min-height. 34 + 35 + **Deleted files:** 36 + - `src/components/MyArticles.tsx` 37 + - `src/components/MyArticles.test.tsx` 38 + 39 + --- 40 + 41 + ## Task 1: `appBarNav` pure helper 42 + 43 + **Files:** 44 + - Create: `src/lib/auth/nav.ts` 45 + - Test: `src/lib/auth/nav.test.ts` 46 + 47 + - [ ] **Step 1: Write the failing test** 48 + 49 + Create `src/lib/auth/nav.test.ts`: 50 + 51 + ```ts 52 + import { describe, expect, it } from 'vitest'; 53 + import { appBarNav } from './nav'; 54 + 55 + describe( 'appBarNav', () => { 56 + it( 'links the editor bar back to the dashboard (Publications)', () => { 57 + expect( appBarNav( 'editor' ) ).toEqual( { 58 + href: '/dashboard', 59 + label: 'Publications', 60 + icon: 'publications', 61 + } ); 62 + } ); 63 + 64 + it( 'links the dashboard bar into the editor (Write, feather)', () => { 65 + expect( appBarNav( 'dashboard' ) ).toEqual( { 66 + href: '/editor', 67 + label: 'Write', 68 + icon: 'feather', 69 + } ); 70 + } ); 71 + } ); 72 + ``` 73 + 74 + - [ ] **Step 2: Run test to verify it fails** 75 + 76 + Run: `npm test -- src/lib/auth/nav.test.ts` 77 + Expected: FAIL — cannot resolve `./nav` / `appBarNav` is not defined. 78 + 79 + - [ ] **Step 3: Write minimal implementation** 80 + 81 + Create `src/lib/auth/nav.ts`: 82 + 83 + ```ts 84 + /** The page an AppBar renders on; selects its contextual nav action. */ 85 + export type AppBarContext = 'editor' | 'dashboard'; 86 + 87 + export interface AppBarNav { 88 + href: string; 89 + label: string; 90 + icon: 'feather' | 'publications'; 91 + } 92 + 93 + /** 94 + * The AppBar's single contextual nav action, by the page it renders on: 95 + * editor → back to your publications (the dashboard) 96 + * dashboard → into the editor (a feather "Write") 97 + */ 98 + export function appBarNav( current: AppBarContext ): AppBarNav { 99 + if ( current === 'dashboard' ) { 100 + return { href: '/editor', label: 'Write', icon: 'feather' }; 101 + } 102 + return { href: '/dashboard', label: 'Publications', icon: 'publications' }; 103 + } 104 + ``` 105 + 106 + - [ ] **Step 4: Run test to verify it passes** 107 + 108 + Run: `npm test -- src/lib/auth/nav.test.ts` 109 + Expected: PASS (2 tests). 110 + 111 + - [ ] **Step 5: Commit** 112 + 113 + ```bash 114 + git add src/lib/auth/nav.ts src/lib/auth/nav.test.ts 115 + git commit --no-gpg-sign -m "Add appBarNav helper for the shared top bar" 116 + ``` 117 + 118 + --- 119 + 120 + ## Task 2: `edit-link` pure helpers 121 + 122 + **Files:** 123 + - Create: `src/lib/editor/edit-link.ts` 124 + - Test: `src/lib/editor/edit-link.test.ts` 125 + 126 + - [ ] **Step 1: Write the failing test** 127 + 128 + Create `src/lib/editor/edit-link.test.ts`: 129 + 130 + ```ts 131 + import { describe, expect, it } from 'vitest'; 132 + import { editLinkFor, editRkeyFromSearch } from './edit-link'; 133 + 134 + describe( 'editLinkFor', () => { 135 + it( 'builds the editor edit URL for an rkey', () => { 136 + expect( editLinkFor( '3kabc123' ) ).toBe( '/editor?edit=3kabc123' ); 137 + } ); 138 + } ); 139 + 140 + describe( 'editRkeyFromSearch', () => { 141 + it( 'reads the rkey from the edit param', () => { 142 + expect( editRkeyFromSearch( '?edit=3kabc123' ) ).toBe( '3kabc123' ); 143 + } ); 144 + 145 + it( 'returns null when the param is absent', () => { 146 + expect( editRkeyFromSearch( '?foo=bar' ) ).toBeNull(); 147 + expect( editRkeyFromSearch( '' ) ).toBeNull(); 148 + } ); 149 + 150 + it( 'returns null when the param is present but empty', () => { 151 + expect( editRkeyFromSearch( '?edit=' ) ).toBeNull(); 152 + } ); 153 + } ); 154 + ``` 155 + 156 + - [ ] **Step 2: Run test to verify it fails** 157 + 158 + Run: `npm test -- src/lib/editor/edit-link.test.ts` 159 + Expected: FAIL — cannot resolve `./edit-link`. 160 + 161 + - [ ] **Step 3: Write minimal implementation** 162 + 163 + Create `src/lib/editor/edit-link.ts`: 164 + 165 + ```ts 166 + /** 167 + * The dashboard→editor "edit this article" link, and the editor-side parser for 168 + * it. Kept together so the `?edit=` param name has a single source of truth. 169 + * `rkey` uniquely identifies a document within the writer's repo, so it is all 170 + * the editor needs to re-fetch the article on load. 171 + */ 172 + const EDIT_PARAM = 'edit'; 173 + 174 + /** The editor URL that opens an existing article for editing. */ 175 + export function editLinkFor( rkey: string ): string { 176 + return `/editor?${ EDIT_PARAM }=${ rkey }`; 177 + } 178 + 179 + /** The rkey to edit, parsed from a URL search string (e.g. `window.location.search`). Null when absent or empty. */ 180 + export function editRkeyFromSearch( search: string ): string | null { 181 + const rkey = new URLSearchParams( search ).get( EDIT_PARAM ); 182 + return rkey && rkey.length > 0 ? rkey : null; 183 + } 184 + ``` 185 + 186 + - [ ] **Step 4: Run test to verify it passes** 187 + 188 + Run: `npm test -- src/lib/editor/edit-link.test.ts` 189 + Expected: PASS (4 tests). 190 + 191 + - [ ] **Step 5: Commit** 192 + 193 + ```bash 194 + git add src/lib/editor/edit-link.ts src/lib/editor/edit-link.test.ts 195 + git commit --no-gpg-sign -m "Add edit-link helpers for dashboard→editor editing" 196 + ``` 197 + 198 + --- 199 + 200 + ## Task 3: `getMyArticle` data helper 201 + 202 + **Files:** 203 + - Modify: `src/lib/publish/publisher.ts` (append after `listAllMyArticles`, ~line 300) 204 + - Test: `src/lib/publish/publisher.test.ts` (append a new `describe`) 205 + 206 + - [ ] **Step 1: Write the failing test** 207 + 208 + Append to `src/lib/publish/publisher.test.ts` (and add `getMyArticle` to the import on line 3, so it reads `import { publish, updateDocument, listAllMyArticles, listPublicationArticles, getMyArticle } from './publisher';`): 209 + 210 + ```ts 211 + describe( 'getMyArticle', () => { 212 + function repo() { 213 + return mockAgent( { 214 + 'site.standard.publication': [ 215 + { 216 + uri: `at://${ DID }/site.standard.publication/pub1`, 217 + value: { 218 + $type: 'site.standard.publication', 219 + url: `${ SITE_BASE }/@${ HANDLE }/blog-a`, 220 + name: 'Blog A', 221 + }, 222 + }, 223 + ], 224 + 'site.standard.document': [ 225 + { 226 + uri: `at://${ DID }/site.standard.document/d1`, 227 + value: { title: 'In A', site: `at://${ DID }/site.standard.publication/pub1` }, 228 + }, 229 + { 230 + uri: `at://${ DID }/site.standard.document/d2`, 231 + value: { title: 'Orphan', site: `at://${ DID }/site.standard.publication/gone` }, 232 + }, 233 + ], 234 + } ); 235 + } 236 + 237 + it( 'returns the writer’s article by rkey, slug-annotated', async () => { 238 + const { agent } = repo(); 239 + const article = await getMyArticle( agent, DID, 'd1' ); 240 + expect( article ).toMatchObject( { rkey: 'd1', title: 'In A', siteSlug: 'blog-a' } ); 241 + } ); 242 + 243 + it( 'returns null for an unknown rkey', async () => { 244 + const { agent } = repo(); 245 + expect( await getMyArticle( agent, DID, 'nope' ) ).toBeNull(); 246 + } ); 247 + 248 + it( 'returns null for a foreign/orphan document', async () => { 249 + const { agent } = repo(); 250 + expect( await getMyArticle( agent, DID, 'd2' ) ).toBeNull(); 251 + } ); 252 + } ); 253 + ``` 254 + 255 + - [ ] **Step 2: Run test to verify it fails** 256 + 257 + Run: `npm test -- src/lib/publish/publisher.test.ts` 258 + Expected: FAIL — `getMyArticle` is not exported. 259 + 260 + - [ ] **Step 3: Write minimal implementation** 261 + 262 + Append to `src/lib/publish/publisher.ts` (after `listAllMyArticles`, end of file): 263 + 264 + ```ts 265 + /** 266 + * Fetch a single SkyPress article by rkey (the editor's `?edit=` load). Reuses 267 + * `listAllMyArticles` so it inherits the same slug annotation and foreign/orphan 268 + * filtering; returns null when no owned document has that rkey. 269 + */ 270 + export async function getMyArticle( 271 + agent: Agent, 272 + did: string, 273 + rkey: string 274 + ): Promise< MyArticle | null > { 275 + const all = await listAllMyArticles( agent, did ); 276 + return all.find( ( article ) => article.rkey === rkey ) ?? null; 277 + } 278 + ``` 279 + 280 + - [ ] **Step 4: Run test to verify it passes** 281 + 282 + Run: `npm test -- src/lib/publish/publisher.test.ts` 283 + Expected: PASS (existing tests + 3 new). 284 + 285 + - [ ] **Step 5: Commit** 286 + 287 + ```bash 288 + git add src/lib/publish/publisher.ts src/lib/publish/publisher.test.ts 289 + git commit --no-gpg-sign -m "Add getMyArticle for the editor edit-by-rkey load" 290 + ``` 291 + 292 + --- 293 + 294 + ## Task 4: `AppBar` component + styles 295 + 296 + **Files:** 297 + - Create: `src/components/AppBar.tsx` 298 + - Create: `src/styles/app-bar.css` 299 + 300 + > No unit test: `AppBar` is thin chrome over `useAuth`; its only branching logic (`appBarNav`) is already tested in Task 1. Verified manually in Task 5/7. It imports only the `@wordpress`-free auth helpers. 301 + 302 + - [ ] **Step 1: Create the component** 303 + 304 + Create `src/components/AppBar.tsx`: 305 + 306 + ```tsx 307 + import { useState } from 'react'; 308 + import { useAuth } from '../lib/auth/useAuth'; 309 + import { displayNameFor, authorPath } from '../lib/auth/profile'; 310 + import { appBarNav, type AppBarContext } from '../lib/auth/nav'; 311 + 312 + /** Inline SkyPress mark — mirrors Logo.astro (that one is Astro-only, unusable in a React island). */ 313 + function LogoMark() { 314 + return ( 315 + <svg 316 + className="app-bar__mark" 317 + width={ 24 } 318 + height={ 24 } 319 + viewBox="0 0 32 32" 320 + fill="none" 321 + aria-hidden="true" 322 + > 323 + <rect x="2.5" y="2.5" width="27" height="27" rx="7.5" stroke="currentColor" strokeWidth="2.2" /> 324 + <circle cx="16" cy="12.5" r="3.6" stroke="currentColor" strokeWidth="1.9" /> 325 + <circle cx="16" cy="12.5" r="0.9" fill="currentColor" /> 326 + <path d="M7 18.5h18" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" /> 327 + <path d="M9 22.8h14M9 25.6h9" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" opacity="0.55" /> 328 + </svg> 329 + ); 330 + } 331 + 332 + function NavIcon( { name }: { name: 'feather' | 'publications' } ) { 333 + if ( name === 'feather' ) { 334 + return ( 335 + <svg className="app-bar__navicon" viewBox="0 0 24 24" width={ 18 } height={ 18 } fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"> 336 + <path d="M20.24 12.24a6 6 0 0 0-8.49-8.49L5 10.5V19h8.5z" /> 337 + <line x1="16" y1="8" x2="2" y2="22" /> 338 + <line x1="17.5" y1="15" x2="9" y2="15" /> 339 + </svg> 340 + ); 341 + } 342 + return ( 343 + <svg className="app-bar__navicon" viewBox="0 0 24 24" width={ 18 } height={ 18 } fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"> 344 + <path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" /> 345 + <path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" /> 346 + </svg> 347 + ); 348 + } 349 + 350 + /** 351 + * The shared top bar for the editor + dashboard islands. Logo on the left; 352 + * contextual nav + account + sign-out on the right. Rendered inside AuthProvider 353 + * in every auth state: logo-only while loading, + nav when signed out, + account 354 + * and sign-out when signed in. 355 + */ 356 + export default function AppBar( { current }: { current: AppBarContext } ) { 357 + const { status, handle, displayName, avatar, did, signOut } = useAuth(); 358 + const [ avatarOk, setAvatarOk ] = useState( true ); 359 + const nav = appBarNav( current ); 360 + const signedIn = status === 'signed-in' && Boolean( did ); 361 + 362 + const viewerName = did 363 + ? displayNameFor( { did, handle, displayName, avatar } ) 364 + : ''; 365 + const profileHref = authorPath( handle ); 366 + 367 + return ( 368 + <header className="app-bar"> 369 + <a className="app-bar__home" href="/" aria-label="SkyPress home"> 370 + <LogoMark /> 371 + <span className="app-bar__word">SkyPress</span> 372 + </a> 373 + 374 + <span className="app-bar__spacer" /> 375 + 376 + { status !== 'loading' && ( 377 + <a className="app-bar__nav" href={ nav.href }> 378 + <NavIcon name={ nav.icon } /> 379 + { nav.label } 380 + </a> 381 + ) } 382 + 383 + { signedIn && ( 384 + <> 385 + <IdentityBlock 386 + href={ profileHref } 387 + name={ viewerName } 388 + handle={ handle } 389 + avatar={ avatar } 390 + avatarOk={ avatarOk } 391 + onAvatarError={ () => setAvatarOk( false ) } 392 + /> 393 + <button type="button" className="app-bar__signout" onClick={ () => void signOut() }> 394 + Sign out 395 + </button> 396 + </> 397 + ) } 398 + </header> 399 + ); 400 + } 401 + 402 + /** Avatar + name + @handle. The whole block links to the public author page when a handle is known. */ 403 + function IdentityBlock( { 404 + href, 405 + name, 406 + handle, 407 + avatar, 408 + avatarOk, 409 + onAvatarError, 410 + }: { 411 + href: string | null; 412 + name: string; 413 + handle: string | null; 414 + avatar: string | null; 415 + avatarOk: boolean; 416 + onAvatarError: () => void; 417 + } ) { 418 + const inner = ( 419 + <> 420 + { avatar && avatarOk ? ( 421 + <img 422 + className="app-bar__avatar" 423 + src={ avatar } 424 + alt="" 425 + width={ 30 } 426 + height={ 30 } 427 + onError={ onAvatarError } 428 + /> 429 + ) : ( 430 + <span className="app-bar__avatar app-bar__avatar--fallback" aria-hidden="true"> 431 + { name.charAt( 0 ).toUpperCase() } 432 + </span> 433 + ) } 434 + <span className="app-bar__who"> 435 + <strong className="app-bar__name">{ name }</strong> 436 + { handle && <span className="app-bar__handle">@{ handle }</span> } 437 + </span> 438 + </> 439 + ); 440 + 441 + return href ? ( 442 + <a className="app-bar__identity" href={ href }> 443 + { inner } 444 + </a> 445 + ) : ( 446 + <span className="app-bar__identity">{ inner }</span> 447 + ); 448 + } 449 + ``` 450 + 451 + - [ ] **Step 2: Create the styles** 452 + 453 + Create `src/styles/app-bar.css`: 454 + 455 + ```css 456 + /** 457 + * Shared top bar for the editor + dashboard islands. Imported globally by both 458 + * page shells; the islands are client:only so Astro scoped styles can't reach 459 + * the bar's DOM. 460 + */ 461 + .app-bar { 462 + display: flex; 463 + align-items: center; 464 + gap: 0.75rem; 465 + max-width: var(--studio-measure, 60rem); 466 + margin: 0 auto; 467 + padding: 0.75rem 1.25rem; 468 + border-bottom: 1px solid var(--line); 469 + flex-wrap: wrap; 470 + } 471 + .app-bar__home { 472 + display: inline-flex; 473 + align-items: center; 474 + gap: 0.55rem; 475 + color: var(--ink); 476 + text-decoration: none; 477 + } 478 + .app-bar__mark { 479 + color: var(--sun); 480 + flex: none; 481 + } 482 + .app-bar__word { 483 + font-family: var(--font-display); 484 + font-weight: 700; 485 + font-size: 1.18rem; 486 + letter-spacing: -0.015em; 487 + } 488 + .app-bar__spacer { 489 + flex: 1; 490 + } 491 + .app-bar__nav { 492 + display: inline-flex; 493 + align-items: center; 494 + gap: 0.4rem; 495 + color: var(--sun); 496 + background: var(--sun-tint); 497 + text-decoration: none; 498 + font-size: 0.85rem; 499 + font-weight: 600; 500 + padding: 0.35rem 0.6rem; 501 + border-radius: var(--radius-sm); 502 + } 503 + .app-bar__nav:hover { 504 + text-decoration: underline; 505 + } 506 + .app-bar__navicon { 507 + flex: none; 508 + } 509 + .app-bar__identity { 510 + display: inline-flex; 511 + align-items: center; 512 + gap: 0.5rem; 513 + min-width: 0; 514 + text-decoration: none; 515 + color: inherit; 516 + padding: 0.2rem 0.35rem; 517 + border-radius: var(--radius-sm); 518 + } 519 + a.app-bar__identity:hover { 520 + background: var(--panel); 521 + } 522 + .app-bar__avatar { 523 + width: 30px; 524 + height: 30px; 525 + border-radius: 50%; 526 + object-fit: cover; 527 + flex: none; 528 + } 529 + .app-bar__avatar--fallback { 530 + display: inline-flex; 531 + align-items: center; 532 + justify-content: center; 533 + background: var(--sun-tint); 534 + color: var(--sun); 535 + font-weight: 700; 536 + font-size: 0.85rem; 537 + } 538 + .app-bar__who { 539 + display: flex; 540 + flex-direction: column; 541 + line-height: 1.15; 542 + min-width: 0; 543 + } 544 + .app-bar__name { 545 + font-weight: 650; 546 + font-size: 0.9rem; 547 + } 548 + .app-bar__handle { 549 + color: var(--muted); 550 + font-size: 0.75rem; 551 + } 552 + .app-bar__signout { 553 + border: 1px solid var(--line-strong); 554 + background: var(--paper-raised); 555 + border-radius: var(--radius-sm); 556 + padding: 0.3rem 0.7rem; 557 + cursor: pointer; 558 + font: inherit; 559 + font-size: 0.85rem; 560 + } 561 + ``` 562 + 563 + - [ ] **Step 3: Type-check** 564 + 565 + Run: `npm run check` 566 + Expected: PASS (no type/lint errors from the new files). 567 + 568 + - [ ] **Step 4: Commit** 569 + 570 + ```bash 571 + git add src/components/AppBar.tsx src/styles/app-bar.css 572 + git commit --no-gpg-sign -m "Add shared AppBar component and styles" 573 + ``` 574 + 575 + --- 576 + 577 + ## Task 5: Mount AppBar in the editor; remove the account bar + article list; add the ?edit load 578 + 579 + **Files:** 580 + - Modify: `src/components/Studio.tsx` 581 + - Modify: `src/pages/editor.astro` 582 + 583 + This task keeps the title where it is (in `PublishPanel`) — the title relocation is Task 6 — so the app stays runnable. 584 + 585 + - [ ] **Step 1: Update the editor page shell** 586 + 587 + In `src/pages/editor.astro`, replace the frontmatter import block and the `<header>`/`<style>` so the bar comes from `app-bar.css` and the React island. Replace lines 1–23 (the frontmatter through the closing of `<main>`'s `<Studio>` block is unchanged) — specifically: 588 + 589 + Replace the import of `Logo` and add the stylesheet import. Change the frontmatter to: 590 + 591 + ```astro 592 + --- 593 + import Base from '../layouts/Base.astro'; 594 + import Studio from '../components/Studio.tsx'; 595 + // The Studio is a `client:only` React island, so Astro's scoped styles never 596 + // reach its DOM — its chrome is styled globally from these shared stylesheets. 597 + import '../styles/app-bar.css'; 598 + import '../styles/editor-chrome.css'; 599 + import '../styles/login.css'; 600 + --- 601 + ``` 602 + 603 + Then replace the `<main>` body so the static header is gone (the AppBar now renders inside the island): 604 + 605 + ```astro 606 + <Base title="Write — SkyPress"> 607 + <main class="editor-shell"> 608 + <!-- client:only — auth + editor run only in the browser; their bundle never 609 + reaches reading pages (Decisions 0001 & 0004). --> 610 + <Studio client:only="react"> 611 + <p slot="fallback" class="editor-shell__loading">Loading…</p> 612 + </Studio> 613 + </main> 614 + </Base> 615 + ``` 616 + 617 + Finally delete the entire `<style>…</style>` block at the bottom EXCEPT keep the loading rule. Replace the whole `<style>` block with: 618 + 619 + ```astro 620 + <style> 621 + .editor-shell__loading { 622 + padding: 2rem 1.25rem; 623 + color: var(--muted); 624 + } 625 + </style> 626 + ``` 627 + 628 + - [ ] **Step 2: Mount AppBar and remove the account bar in Studio** 629 + 630 + In `src/components/Studio.tsx`: 631 + 632 + Add imports near the top (after the existing component imports): 633 + 634 + ```tsx 635 + import AppBar from './AppBar'; 636 + import { getMyArticle } from '../lib/publish/publisher'; 637 + import { editRkeyFromSearch } from '../lib/editor/edit-link'; 638 + ``` 639 + 640 + Remove the now-unused `MyArticles` import (line 8) and the `displayNameFor, authorPath` import (line 10) only if no longer referenced after this task — `displayNameFor`/`authorPath` move into `AppBar`, so delete line 10's import. Keep `useRef` etc. 641 + 642 + Wrap the loading + signed-in + signed-out returns so `AppBar` always renders. Change the `status === 'loading'` early return to include the bar: 643 + 644 + ```tsx 645 + if ( status === 'loading' ) { 646 + return ( 647 + <> 648 + <AppBar current="editor" /> 649 + <p className="studio__loading">Connecting to your identity…</p> 650 + </> 651 + ); 652 + } 653 + ``` 654 + 655 + In the signed-in branch, delete the entire `<div className="studio__account">…</div>` block (lines ~78–116) AND the `<MyArticles … />` block (lines ~118–124), and wrap the returned fragment so it leads with `<AppBar current="editor" />`. The signed-in `return` becomes exactly: 656 + 657 + ```tsx 658 + return ( 659 + <> 660 + <AppBar current="editor" /> 661 + 662 + <div className="studio__mode"> 663 + <span>{ editing ? `Editing: ${ editing.title }` : 'New article' }</span> 664 + { editing && ( 665 + <button type="button" onClick={ startNew }> 666 + + New article 667 + </button> 668 + ) } 669 + </div> 670 + 671 + <div key={ editorKey }> 672 + <PublishPanel 673 + agent={ agent } 674 + identity={ { did, handle } } 675 + blocks={ blocks } 676 + blobRegistry={ registry } 677 + publications={ publications } 678 + editing={ 679 + editing 680 + ? { 681 + rkey: editing.rkey, 682 + siteUri: editing.siteUri, 683 + siteSlug: editing.siteSlug, 684 + publishedAt: editing.publishedAt ?? new Date().toISOString(), 685 + bskyPostRef: editing.bskyPostRef, 686 + } 687 + : undefined 688 + } 689 + initialTitle={ editing?.title } 690 + onComplete={ () => setRefreshKey( ( k ) => k + 1 ) } 691 + /> 692 + <SkyEditor 693 + onChange={ setBlocks } 694 + mediaUpload={ mediaUpload } 695 + initialBlocks={ editing?.blocks } 696 + /> 697 + </div> 698 + </> 699 + ); 700 + ``` 701 + 702 + Also remove the now-unused `avatarOk`/`setAvatarOk` state, the `viewerName`/`publicPath` consts, and the signed-out branch's structure stays but gains the bar (next step). Remove `displayNameFor`/`authorPath` usage from this file. 703 + 704 + - [ ] **Step 3: Add the bar to the signed-out branch** 705 + 706 + Replace the final signed-out `return` block with: 707 + 708 + ```tsx 709 + // signed-out or error 710 + return ( 711 + <> 712 + <AppBar current="editor" /> 713 + <div className="studio__login"> 714 + <LoginForm /> 715 + { status === 'error' && error && ( 716 + <p className="studio__error" role="alert"> 717 + Couldn't start the auth client: { error } 718 + </p> 719 + ) } 720 + </div> 721 + </> 722 + ); 723 + ``` 724 + 725 + - [ ] **Step 4: Add the `?edit=<rkey>` load effect** 726 + 727 + In `StudioGate`, add a one-shot loader effect after the existing `publications` effect. It reads the URL once when the agent is ready, fetches the article, and seeds the editor via the same path `startEdit` uses. Because `startEdit` is defined inside the signed-in branch, hoist the load into an effect that sets `editing`/`blocks` directly: 728 + 729 + ```tsx 730 + // One-shot: if the page was opened as /editor?edit=<rkey>, load that article. 731 + const editLoadedRef = useRef( false ); 732 + useEffect( () => { 733 + if ( editLoadedRef.current || ! agent || ! did ) { 734 + return; 735 + } 736 + const rkey = editRkeyFromSearch( window.location.search ); 737 + if ( ! rkey ) { 738 + editLoadedRef.current = true; 739 + return; 740 + } 741 + editLoadedRef.current = true; 742 + let cancelled = false; 743 + getMyArticle( agent, did, rkey ) 744 + .then( ( article ) => { 745 + if ( ! cancelled && article ) { 746 + setEditing( article ); 747 + setBlocks( article.blocks as unknown as BlockInstance[] ); 748 + } 749 + } ) 750 + .catch( () => { 751 + /* stale/bad edit link → start a new article */ 752 + } ); 753 + return () => { 754 + cancelled = true; 755 + }; 756 + }, [ agent, did ] ); 757 + ``` 758 + 759 + Add `useRef` to the React import if not already present (it is, line 1). 760 + 761 + - [ ] **Step 5: Type-check and run the full test suite** 762 + 763 + Run: `npm run check && npm test` 764 + Expected: `check` PASS; tests PASS. (Note: `MyArticles.test.tsx` still exists and passes — it's deleted in Task 8.) If `check` flags `MyArticles` as unused import, confirm its import line was removed in Step 2. 765 + 766 + - [ ] **Step 6: Manual verification** 767 + 768 + Run: `npm run dev` and open `http://127.0.0.1:<port>/editor`. 769 + Expected: the shared bar shows at the top (logo left; when signed in: "Publications" link, your avatar+name+@handle linking to your public page, Sign out). No "Your articles" list. The editor still works. 770 + With `?edit=<an existing rkey>` appended: the editor loads that article (title + blocks). With a bogus rkey: a blank editor (no crash). 771 + 772 + - [ ] **Step 7: Commit** 773 + 774 + ```bash 775 + git add src/components/Studio.tsx src/pages/editor.astro 776 + git commit --no-gpg-sign -m "Mount AppBar in editor, drop article list, load ?edit by rkey" 777 + ``` 778 + 779 + --- 780 + 781 + ## Task 6: Relocate the title — borderless heading above the canvas 782 + 783 + **Files:** 784 + - Modify: `src/components/PublishPanel.tsx` 785 + - Modify: `src/components/Studio.tsx` 786 + - Modify: `src/styles/editor-chrome.css` 787 + 788 + Title state moves up to `StudioGate`; `PublishPanel` becomes a controlled consumer. Both files change together so the app compiles. 789 + 790 + - [ ] **Step 1: Make PublishPanel title-controlled** 791 + 792 + In `src/components/PublishPanel.tsx`: 793 + 794 + Replace the `initialTitle?: string;` prop (line 37) with controlled props: 795 + 796 + ```tsx 797 + /** Controlled title (lifted to the editor so it can render as a heading above the canvas). */ 798 + title: string; 799 + onTitleChange: ( value: string ) => void; 800 + ``` 801 + 802 + Remove `initialTitle` from the destructured props (line 54) and add `title`, `onTitleChange`. Delete the internal title state line (`const [ title, setTitle ] = useState( initialTitle ?? '' );`, line 59). 803 + 804 + Remove the title `<input>` (lines ~152–160) from the returned JSX — the editor renders it now. The `canSubmit` calc (line 74) keeps using `title` (now the prop). The publish/update calls keep using `title.trim()` (the prop). 805 + 806 + - [ ] **Step 2: Render the title heading in Studio and pass props** 807 + 808 + In `src/components/Studio.tsx`: 809 + 810 + Add title state to `StudioGate` (near the other `useState`s): 811 + 812 + ```tsx 813 + const [ title, setTitle ] = useState( '' ); 814 + ``` 815 + 816 + Sync it wherever editing changes. In `startEdit`, after `setEditing( article )` add `setTitle( article.title );`. In `startNew`, after `setEditing( null )` add `setTitle( '' );`. In the `?edit` load effect (Task 5 Step 4), inside the `if ( ! cancelled && article )` block add `setTitle( article.title );`. 817 + 818 + In the signed-in JSX, pass the controlled props to `PublishPanel` (replace `initialTitle={ editing?.title }` with the two props) and render the heading between `PublishPanel` and `SkyEditor`, inside the `key={ editorKey }` div: 819 + 820 + ```tsx 821 + <div key={ editorKey }> 822 + <PublishPanel 823 + agent={ agent } 824 + identity={ { did, handle } } 825 + blocks={ blocks } 826 + blobRegistry={ registry } 827 + publications={ publications } 828 + editing={ 829 + editing 830 + ? { 831 + rkey: editing.rkey, 832 + siteUri: editing.siteUri, 833 + siteSlug: editing.siteSlug, 834 + publishedAt: editing.publishedAt ?? new Date().toISOString(), 835 + bskyPostRef: editing.bskyPostRef, 836 + } 837 + : undefined 838 + } 839 + title={ title } 840 + onTitleChange={ setTitle } 841 + onComplete={ () => setRefreshKey( ( k ) => k + 1 ) } 842 + /> 843 + <input 844 + className="studio__title" 845 + type="text" 846 + placeholder="Add title" 847 + aria-label="Article title" 848 + value={ title } 849 + onChange={ ( event ) => setTitle( event.target.value ) } 850 + /> 851 + <SkyEditor 852 + onChange={ setBlocks } 853 + mediaUpload={ mediaUpload } 854 + initialBlocks={ editing?.blocks } 855 + /> 856 + </div> 857 + ``` 858 + 859 + - [ ] **Step 3: Style the borderless title** 860 + 861 + In `src/styles/editor-chrome.css`, add (near the editor-surface rules): 862 + 863 + ```css 864 + /* Borderless article title, sitting above the framed editor canvas — echoes the 865 + block-editor post title (large display heading, no box). */ 866 + .studio__title { 867 + display: block; 868 + max-width: var(--studio-measure); 869 + margin: 1.25rem auto 0; 870 + padding: 0 var(--studio-gutter); 871 + width: 100%; 872 + box-sizing: border-box; 873 + border: 0; 874 + background: transparent; 875 + color: var(--ink); 876 + font-family: var(--font-display); 877 + font-size: clamp(1.9rem, 4vw, 2.6rem); 878 + font-weight: 700; 879 + line-height: 1.15; 880 + } 881 + .studio__title::placeholder { 882 + color: var(--muted); 883 + opacity: 0.6; 884 + } 885 + .studio__title:focus { 886 + outline: none; 887 + } 888 + /* Tighten the gap between the title and the editor surface below it. */ 889 + .studio__title + .skypress-editor { 890 + margin-top: 0.75rem; 891 + } 892 + ``` 893 + 894 + - [ ] **Step 4: Type-check and test** 895 + 896 + Run: `npm run check && npm test` 897 + Expected: PASS. (`PublishPanel` no longer has internal title state; `Studio` owns it.) 898 + 899 + - [ ] **Step 5: Manual verification** 900 + 901 + Run: `npm run dev`, open `/editor` signed in. 902 + Expected: a large borderless "Add title" heading sits above the editor; typing updates it; the Publish button enables once title + content exist; editing an article pre-fills the title; "+ New article" clears it. 903 + 904 + - [ ] **Step 6: Commit** 905 + 906 + ```bash 907 + git add src/components/PublishPanel.tsx src/components/Studio.tsx src/styles/editor-chrome.css 908 + git commit --no-gpg-sign -m "Lift the article title into a borderless heading above the canvas" 909 + ``` 910 + 911 + --- 912 + 913 + ## Task 7: Mount AppBar in the dashboard; add the Edit action 914 + 915 + **Files:** 916 + - Modify: `src/components/Dashboard.tsx` 917 + - Modify: `src/pages/dashboard.astro` 918 + 919 + - [ ] **Step 1: Update the dashboard page shell** 920 + 921 + In `src/pages/dashboard.astro`, change the frontmatter to drop the `Logo` import and add the bar stylesheet: 922 + 923 + ```astro 924 + --- 925 + import Base from '../layouts/Base.astro'; 926 + import Dashboard from '../components/Dashboard.tsx'; 927 + // Shared top-bar styles (the Dashboard island is client:only, so Astro scoped 928 + // styles can't reach its DOM). The sign-in form styles are shared with the editor. 929 + import '../styles/app-bar.css'; 930 + import '../styles/login.css'; 931 + --- 932 + ``` 933 + 934 + Replace the `<main>` body to drop the static header (the AppBar renders inside the island): 935 + 936 + ```astro 937 + <Base title="Dashboard — SkyPress"> 938 + <main class="dash-shell"> 939 + <!-- client:only — auth runs only in the browser; its bundle never reaches 940 + reading pages (Decisions 0003 & 0004). No `@wordpress/*` here. --> 941 + <Dashboard client:only="react"> 942 + <p slot="fallback" class="dash__loading">Loading…</p> 943 + </Dashboard> 944 + </main> 945 + </Base> 946 + ``` 947 + 948 + Delete the first `<style>` block (the `.dash-shell__*` rules, lines ~24–43). Keep the `<style is:global>` block untouched EXCEPT remove the now-unused `.dash__studio` and `.dash__signout` rules (the bar's Studio link + sign-out moved to `AppBar`); also remove the `.dash__bar` and `.dash__bar-actions` rules. Keep `.dash__crumb` (still used by the breadcrumb). 949 + 950 + - [ ] **Step 2: Mount AppBar and trim the dashboard bar** 951 + 952 + In `src/components/Dashboard.tsx`: 953 + 954 + Add the imports: 955 + 956 + ```tsx 957 + import AppBar from './AppBar'; 958 + import { editLinkFor } from '../lib/editor/edit-link'; 959 + ``` 960 + 961 + Remove `signOut` from the `useAuth()` destructure (line 27) — the bar owns sign-out now. 962 + 963 + Add `<AppBar current="dashboard" />` to each returned tree. For `status === 'loading'`: 964 + 965 + ```tsx 966 + if ( status === 'loading' ) { 967 + return ( 968 + <> 969 + <AppBar current="dashboard" /> 970 + <p className="dash__loading">Connecting to your identity…</p> 971 + </> 972 + ); 973 + } 974 + ``` 975 + 976 + For the signed-out branch, wrap with the bar: 977 + 978 + ```tsx 979 + if ( status !== 'signed-in' || ! agent || ! did ) { 980 + return ( 981 + <> 982 + <AppBar current="dashboard" /> 983 + <div className="dash__login"> 984 + <LoginForm /> 985 + { status === 'error' && error && ( 986 + <p className="dash__error" role="alert"> 987 + Couldn't start the auth client: { error } 988 + </p> 989 + ) } 990 + </div> 991 + </> 992 + ); 993 + } 994 + ``` 995 + 996 + In the main signed-in `return`, render the bar above `.dash`, and replace the old `<header className="dash__bar">…</header>` with just the breadcrumb (the Studio link + sign-out are gone). The top becomes: 997 + 998 + ```tsx 999 + return ( 1000 + <> 1001 + <AppBar current="dashboard" /> 1002 + <div className="dash"> 1003 + <header className="dash__bar"> 1004 + <button 1005 + type="button" 1006 + className="dash__crumb" 1007 + onClick={ () => setView( { kind: 'list' } ) } 1008 + disabled={ view.kind === 'list' } 1009 + > 1010 + Your publications 1011 + </button> 1012 + </header> 1013 + ``` 1014 + 1015 + …and add the matching closing `</>` before the final `);` of the component (after the existing closing `</div>` of `.dash`). 1016 + 1017 + - [ ] **Step 3: Add the Edit action to the Posts tab** 1018 + 1019 + In `PublicationManager`'s Posts list (the `articles.map` in the `tab === 'posts'` block, ~line 393), add an Edit link before the Unpublish button: 1020 + 1021 + ```tsx 1022 + <li className="dash__post" key={ article.rkey }> 1023 + <a 1024 + className="dash__postlink" 1025 + href={ `/@${ handle }/${ pub.slug }/${ article.rkey }` } 1026 + > 1027 + { article.title } 1028 + </a> 1029 + { article.publishedAt && ( 1030 + <span className="dash__postdate"> 1031 + { article.publishedAt.slice( 0, 10 ) } 1032 + </span> 1033 + ) } 1034 + <a className="dash__edit" href={ editLinkFor( article.rkey ) }> 1035 + Edit 1036 + </a> 1037 + <button 1038 + type="button" 1039 + disabled={ busy === article.rkey } 1040 + onClick={ () => void onUnpublish( article ) } 1041 + > 1042 + { busy === article.rkey ? 'Unpublishing…' : 'Unpublish' } 1043 + </button> 1044 + </li> 1045 + ``` 1046 + 1047 + - [ ] **Step 4: Style the Edit link to match the post buttons** 1048 + 1049 + In `src/pages/dashboard.astro`'s `<style is:global>` block, extend the existing `.dash__pubactions button, .dash__post button` selector to include the Edit anchor. Find: 1050 + 1051 + ```css 1052 + .dash__pubactions button, 1053 + .dash__post button { 1054 + ``` 1055 + 1056 + and replace with: 1057 + 1058 + ```css 1059 + .dash__pubactions button, 1060 + .dash__post button, 1061 + .dash__post .dash__edit { 1062 + ``` 1063 + 1064 + Then add directly after that rule: 1065 + 1066 + ```css 1067 + .dash__post .dash__edit { 1068 + color: inherit; 1069 + text-decoration: none; 1070 + display: inline-flex; 1071 + align-items: center; 1072 + } 1073 + ``` 1074 + 1075 + - [ ] **Step 5: Type-check and test** 1076 + 1077 + Run: `npm run check && npm test` 1078 + Expected: PASS. 1079 + 1080 + - [ ] **Step 6: Manual verification** 1081 + 1082 + Run: `npm run dev`, open `/dashboard` signed in. 1083 + Expected: shared bar at top with a feather **Write** action → `/editor`; account block → public page; Sign out works. The "Your publications" breadcrumb still resets the view. Open a publication → Posts tab → each post has an **Edit** link; clicking it opens `/editor?edit=<rkey>` with the article loaded. 1084 + 1085 + - [ ] **Step 7: Commit** 1086 + 1087 + ```bash 1088 + git add src/components/Dashboard.tsx src/pages/dashboard.astro 1089 + git commit --no-gpg-sign -m "Mount AppBar in dashboard and add per-post Edit action" 1090 + ``` 1091 + 1092 + --- 1093 + 1094 + ## Task 8: Remove `MyArticles`; clean up editor-chrome styles; final check 1095 + 1096 + **Files:** 1097 + - Delete: `src/components/MyArticles.tsx`, `src/components/MyArticles.test.tsx` 1098 + - Modify: `src/styles/editor-chrome.css` 1099 + 1100 + - [ ] **Step 1: Confirm `MyArticles` has no importers** 1101 + 1102 + Run: `grep -rn "MyArticles" src --include=*.tsx --include=*.ts` 1103 + Expected: only `src/components/MyArticles.tsx` and `src/components/MyArticles.test.tsx` (its own definition/test). If `Studio.tsx` still references it, remove that import first. 1104 + 1105 + - [ ] **Step 2: Delete the component and its test** 1106 + 1107 + ```bash 1108 + trash src/components/MyArticles.tsx src/components/MyArticles.test.tsx 1109 + ``` 1110 + 1111 + - [ ] **Step 3: Remove dead styles from `editor-chrome.css`** 1112 + 1113 + In `src/styles/editor-chrome.css`, delete the now-unused rule blocks: 1114 + - the `Account bar (signed in)` section: `.studio__account`, `.studio__identity`, `.studio__avatar`, `.studio__avatar--fallback`, `.studio__who`, `.studio__name`, `.studio__handle`, `.studio__account-actions`, `.studio__viewpage`, `.studio__viewpage:hover`, `.studio__signout` (lines ~25–96). 1115 + - the `Your articles + mode bar` section's `.myarticles*` rules: `.myarticles`, `.myarticles__heading`, `.myarticles__loading`, `.myarticles__list`, `.myarticles__item`, `.myarticles__edited`, `.myarticles__pub`, `.myarticles__actions`, `.myarticles__actions button, .myarticles__actions a`, `.myarticles__actions a` (lines ~199–259). 1116 + 1117 + Keep `.studio__loading`, `.studio__login`, `.studio__error`, the `.publish*` rules, `.studio__mode*`, the `.studio__title*` rules (added in Task 6), and all `.skypress-editor*` rules. 1118 + 1119 + - [ ] **Step 4: Raise the editor canvas height** 1120 + 1121 + In `src/styles/editor-chrome.css`, find the `.skypress-editor .iso-editor` rule and add a `min-height` so the canvas fills ~70% of the viewport by default. Change: 1122 + 1123 + ```css 1124 + .skypress-editor .iso-editor { 1125 + background-color: var(--paper-raised); 1126 + border: 1px solid var(--line-strong); 1127 + border-radius: var(--radius); 1128 + color: var(--ink); 1129 + box-shadow: var(--shadow); 1130 + } 1131 + ``` 1132 + 1133 + to: 1134 + 1135 + ```css 1136 + .skypress-editor .iso-editor { 1137 + background-color: var(--paper-raised); 1138 + border: 1px solid var(--line-strong); 1139 + border-radius: var(--radius); 1140 + color: var(--ink); 1141 + box-shadow: var(--shadow); 1142 + min-height: 70vh; 1143 + } 1144 + ``` 1145 + 1146 + - [ ] **Step 5: Full check + test suite** 1147 + 1148 + Run: `npm run check && npm test` 1149 + Expected: `check` PASS; tests PASS (the deleted `MyArticles.test.tsx` no longer runs). No references to deleted symbols remain. 1150 + 1151 + - [ ] **Step 6: Manual verification (the whole flow)** 1152 + 1153 + Run: `npm run dev`. 1154 + - `/editor`: shared bar, borderless title above a tall editor, no article list, Publish works. 1155 + - `/dashboard`: shared bar (feather → Write), per-post Edit → opens the article in the editor. 1156 + - Editor canvas is visibly taller than before. Confirm in the browser that `min-height: 70vh` lands on a sensible node — if the framed surface looks wrong (e.g. the toolbar floats away from content), move the `min-height` to the content region (`.skypress-editor .iso-editor .edit-post-visual-editor`) instead and re-verify. 1157 + 1158 + - [ ] **Step 7: Commit** 1159 + 1160 + ```bash 1161 + git add src/components/MyArticles.tsx src/components/MyArticles.test.tsx src/styles/editor-chrome.css 1162 + git commit --no-gpg-sign -m "Remove MyArticles list and clean up editor chrome styles" 1163 + ``` 1164 + 1165 + --- 1166 + 1167 + ## Final verification 1168 + 1169 + - [ ] `npm run check` passes (types + lint). 1170 + - [ ] `npm test` passes (new `nav`, `edit-link`, `getMyArticle` tests included; `MyArticles.test.tsx` removed). 1171 + - [ ] No `@wordpress/*` import reaches `AppBar.tsx`, `nav.ts`, or `edit-link.ts` (grep to confirm): `grep -rn "@wordpress" src/components/AppBar.tsx src/lib/auth/nav.ts src/lib/editor/edit-link.ts` → no matches. 1172 + - [ ] Manual: editor + dashboard share the bar; editing flows dashboard → editor via `?edit=`; title is borderless above a ~70vh canvas; published-article list is gone from the editor.
+219
docs/superpowers/specs/2026-06-09-editor-page-rework-design.md
··· 1 + # Editor & dashboard rework — shared app bar, cleaner writing surface 2 + 3 + **Date:** 2026-06-09 4 + **Status:** Approved (design) 5 + 6 + ## Summary 7 + 8 + The `/editor` page does too much: it lists every published article, shows a 9 + separate bordered title input, frames the block editor in a short box, and keeps 10 + account identity + sign-out in a bar *below* the page header. This project 11 + reworks the editor into a focused writing surface and introduces a single, 12 + auth-aware top bar shared with `/dashboard`. 13 + 14 + Five changes: 15 + 16 + 1. **Shared `AppBar`** across `/editor` and `/dashboard` — logo on the left; 17 + contextual nav + account identity + sign-out on the right. 18 + 2. **Drop the published-articles list** from the editor. Editing moves to the 19 + dashboard's Posts tab, which gains an **Edit** action that opens 20 + `/editor?edit=<rkey>`. 21 + 3. **Restyle the title** as a large, borderless heading (block-editor post-title 22 + feel) sitting *above* the editor canvas. 23 + 4. **Taller editor canvas** by default (~70vh) for long-form drafting. 24 + 5. **Contextual nav icon**: on `/dashboard`, a feather icon → `/editor`; on 25 + `/editor`, a "Publications" link → `/dashboard`. 26 + 27 + ## Decisions (from brainstorming) 28 + 29 + - **One shared bar, two contexts.** A single `AppBar` React component renders at 30 + the top of both islands. The old static Astro headers (`editor-shell__bar`, 31 + `dash-shell__bar`) and the in-island `studio__account` bar are removed. 32 + - **Contextual action is symmetric** — icon + label on both pages: feather + 33 + "Write" → `/editor` on the dashboard; book/Publications icon + "Publications" 34 + → `/dashboard` on the editor. 35 + - **The account block is the profile link.** Avatar + name + `@handle` together 36 + form a single link to the public author page (`authorPath(handle)`). This 37 + replaces the separate "View my public page" link. Sign-out stays a distinct 38 + button beside it. 39 + - **Editing is URL-driven (Option A from brainstorming).** The editor's article 40 + list is removed entirely; the dashboard becomes the single place to manage and 41 + open articles. An `?edit=<rkey>` param is sufficient because `rkey` uniquely 42 + identifies a document within the writer's repo, and it makes edit links 43 + shareable/bookmarkable. 44 + - **Title above the canvas, borderless.** Not inside the canvas (rejected in 45 + brainstorming) — it sits above the framed editor as a large serif heading on 46 + the page background. 47 + - **Editor min-height ≈ 70vh.** Comfortable for long-form without forcing 48 + fill-to-bottom. 49 + - **Publish controls in a slim row above the title** (target selector + Publish 50 + button + editing indicator + "+ New article"). Not pinned to the bottom. 51 + 52 + ## Components & files 53 + 54 + ### New — `src/components/AppBar.tsx` (`client:only` via its host island) 55 + 56 + Rendered inside `AuthProvider` (it consumes `useAuth`), at the top of both 57 + `StudioGate` and `DashboardGate`, in **every** auth state. 58 + 59 + - **Props:** `current: 'editor' | 'dashboard'`. 60 + - **Left:** SkyPress `Logo` → `/`. 61 + - **Right, by state:** 62 + - *loading* → nothing but the logo. 63 + - *signed-out* → contextual nav link only (no account/sign-out). 64 + - *signed-in* → contextual nav · account block · Sign out. 65 + - **Contextual nav** comes from a pure helper (below) so the page→destination 66 + mapping is tested in isolation; the component just renders the returned 67 + `{ href, label, icon }`. 68 + - **Account block:** a single `<a href={authorPath(handle)}>` wrapping avatar 69 + (initial-letter fallback on null/`onError`, matching today's pattern) + name 70 + (`displayNameFor`) + `@handle`. Omitted-handle → still renders name/avatar but 71 + not as a profile link (no broken `/@` link). 72 + - **Sign out:** `<button onClick={signOut}>`. 73 + 74 + ### New — pure helper in `src/lib/auth/nav.ts` (small new module) 75 + 76 + ```ts 77 + export interface AppBarNav { 78 + href: string; 79 + label: string; 80 + icon: 'feather' | 'publications'; 81 + } 82 + 83 + // The contextual nav action for the bar, given the page it renders on: 84 + // editor → { href: '/dashboard', label: 'Publications', icon: 'publications' } 85 + // dashboard → { href: '/editor', label: 'Write', icon: 'feather' } 86 + export function appBarNav( current: 'editor' | 'dashboard' ): AppBarNav; 87 + ``` 88 + 89 + Keeps the navigation model in tested pure code; the island stays thin. The two 90 + inline SVGs (feather, publications) live in `AppBar.tsx` keyed by `icon`. 91 + 92 + ### New — `getMyArticle` in `src/lib/publish/publisher.ts` 93 + 94 + ```ts 95 + // Fetch a single SkyPress document by rkey for the editor's ?edit load. 96 + // Returns the matching MyArticle, or null when the record is missing or its 97 + // `site` isn't one of the writer's SkyPress publications (foreign / orphan doc). 98 + export async function getMyArticle( 99 + agent: Agent, 100 + did: string, 101 + rkey: string 102 + ): Promise< MyArticle | null >; 103 + ``` 104 + 105 + Implemented by reusing the already-tested `listAllMyArticles` and selecting by 106 + `rkey` (`.find(...) ?? null`). This inherits its slug-annotation and 107 + foreign/orphan-doc filtering for free, rather than re-implementing a `getRecord` 108 + + slug-resolution path. 109 + 110 + ### Changed — `src/components/Studio.tsx` 111 + 112 + - Render `<AppBar current="editor" />` at the top of `StudioGate` in all states. 113 + - **Remove** the `studio__account` bar and the `<MyArticles>` section. 114 + - **URL load:** on mount, read `?edit=<rkey>` (via `URLSearchParams`). If present 115 + and signed in, `getMyArticle(agent, did, rkey)` → on success seed `editing` + 116 + `blocks` (same as today's `startEdit`); on null, ignore and start fresh. 117 + - **Title lifts up:** `title` state moves from `PublishPanel` to `StudioGate`. 118 + `StudioGate` renders the borderless `<input class="studio__title">` above the 119 + editor and passes `title` / `onTitleChange` to `PublishPanel`. 120 + - **"+ New article"** clears the param (navigate to `/editor`) and resets state. 121 + - The existing `studio__mode` indicator (`Editing: …` + "+ New article") is 122 + retained and sits in the publish row; `StudioGate` keeps ownership of it (it 123 + drives navigation/reset), rendered adjacent to `PublishPanel`. 124 + - Layout order: `AppBar` → publish row (mode indicator + `PublishPanel`) → title 125 + input → `SkyEditor`. 126 + 127 + ### Changed — `src/components/PublishPanel.tsx` 128 + 129 + - No longer renders the title `<input>` or owns title state. Receives 130 + `title: string` and `onTitleChange: (v: string) => void` as props (title still 131 + drives `canSubmit` and the publish/update calls). 132 + - Keeps the target selector + Publish button + confirm flow. (The `Editing: …` 133 + indicator stays in `StudioGate`'s mode bar, rendered beside it.) 134 + 135 + ### Changed — `src/components/Dashboard.tsx` 136 + 137 + - Render `<AppBar current="dashboard" />` at the top of `DashboardGate`. 138 + - Remove the old `dash__bar` right-side actions (`Studio` link + Sign out) — now 139 + in `AppBar`. The **"Your publications" breadcrumb** stays as in-page nav. 140 + - **Posts tab:** add an **Edit** action per article → 141 + `<a href={`/editor?edit=${article.rkey}`}>`. View link + Unpublish button stay. 142 + 143 + ### Changed — styles 144 + 145 + - **New `src/styles/app-bar.css`** (global; imported by both `editor.astro` and 146 + `dashboard.astro`) holds all `.app-bar*` rules. 147 + - `editor.astro` / `dashboard.astro`: drop the static `<header>` markup and its 148 + scoped styles; import `app-bar.css`. 149 + - `editor-chrome.css`: remove `.studio__account*` and `.myarticles*` rules; add 150 + `.studio__title` (borderless serif heading) and bump the editor surface to 151 + `min-height: ~70vh` (exact node verified in-browser during implementation — 152 + likely the `.iso-editor` content region). 153 + 154 + ### Removed 155 + 156 + - `src/components/MyArticles.tsx` and `src/components/MyArticles.test.tsx` 157 + (no remaining importer once the editor list is gone — confirm before deleting). 158 + 159 + ## Data flow & states 160 + 161 + **Editor (`/editor`):** 162 + 1. `StudioGate` renders `AppBar` immediately (logo; account once session loads). 163 + 2. `?edit=<rkey>` present + signed in → `getMyArticle` → seed editor; else new. 164 + 3. Title typed above the canvas; publish row drives publish/update via lifted 165 + `title` state. 166 + 167 + **Dashboard (`/dashboard`):** 168 + 1. `AppBar` with feather → `/editor`. 169 + 2. Posts tab Edit → `/editor?edit=<rkey>` (full navigation; editor re-fetches). 170 + 171 + ## Error handling & edge cases 172 + 173 + - `getMyArticle`: record missing / `getRecord` throws / foreign `site` → returns 174 + `null`; the editor silently starts a new article (no crash, no error banner — 175 + a stale/bad edit link just opens a blank editor). 176 + - No handle known → account block renders without a profile link (no broken 177 + `/@` href), consistent with the masthead `AccountMenu` rule. 178 + - Avatar `<img>` fails → initial-letter fallback (existing `onError` pattern). 179 + - Signed-out on `/editor` → `AppBar` shows logo + "Publications" link only; the 180 + page body shows the existing `LoginForm`. 181 + 182 + ## Testing (test-first, repo convention) 183 + 184 + The repo favors **pure helpers unit-tested + thin islands** (per the masthead 185 + spec); component-level `react-dom`/`act` tests exist (e.g. 186 + `PublicationForm.test.tsx`) and are used only where behavior is genuinely 187 + component-bound. 188 + 189 + - **`nav.test.ts`** (new): `appBarNav('editor')` → Publications/`/dashboard`; 190 + `appBarNav('dashboard')` → Write/`/editor`. 191 + - **`publisher.test.ts`** (extend): `getMyArticle` returns a `MyArticle` for an 192 + owned SkyPress doc; returns `null` for a missing rkey and for a doc whose 193 + `site` is foreign/unknown. (Mirrors existing `listAllMyArticles` test setup.) 194 + - **`edit-link.test.ts`** (new): `editRkeyFromSearch` returns the rkey for 195 + `?edit=<rkey>`, `null` for an absent/empty param; `editLinkFor(rkey)` returns 196 + `/editor?edit=<rkey>`. (The fetch+seed wiring in `StudioGate` is thin glue, 197 + verified manually — `StudioGate` can't be unit-rendered because it pulls in the 198 + browser-only `isolated-block-editor`.) 199 + - Manual verification (`npm run dev`): shared bar on both pages; contextual icon 200 + per page; account block → public page; sign-out; borderless title; taller 201 + canvas; dashboard Edit → editor loads the article. 202 + - `npm run check` passes (types + lint). No new `@wordpress/*` import outside the 203 + editor island (`AppBar` / `nav.ts` / `getMyArticle` are `@wordpress`-free). 204 + 205 + ## Constraints honored 206 + 207 + - **Reading pages never import `@wordpress/*`** (AGENTS.md rule 3): `AppBar`, 208 + `nav.ts`, and `getMyArticle` are `@wordpress`-free and live in the auth/editor 209 + islands only. 210 + - **OAuth is a browser public client** (rule 7): `AppBar` consumes the existing 211 + client-only `useAuth`; nothing runs server-side. 212 + - **No block/render changes** — render fidelity (rule 4) untouched. 213 + 214 + ## Out of scope 215 + 216 + - The home-page masthead (`AccountMenu`) — separate, already specced. 217 + - Autosave/draft changes, new block types, publish-flow behavior. 218 + - A shared cross-page session/profile store. 219 + - Arrow-key roving focus or a dropdown menu in the `AppBar` (it's flat links).
+142
src/components/AppBar.tsx
··· 1 + import { useState } from 'react'; 2 + import { useAuth } from '../lib/auth/useAuth'; 3 + import { displayNameFor, authorPath } from '../lib/auth/profile'; 4 + import { appBarNav, type AppBarContext } from '../lib/auth/nav'; 5 + 6 + /** Inline SkyPress mark — mirrors Logo.astro (that one is Astro-only, unusable in a React island). */ 7 + function LogoMark() { 8 + return ( 9 + <svg 10 + className="app-bar__mark" 11 + width={ 24 } 12 + height={ 24 } 13 + viewBox="0 0 32 32" 14 + fill="none" 15 + aria-hidden="true" 16 + > 17 + <rect x="2.5" y="2.5" width="27" height="27" rx="7.5" stroke="currentColor" strokeWidth="2.2" /> 18 + <circle cx="16" cy="12.5" r="3.6" stroke="currentColor" strokeWidth="1.9" /> 19 + <circle cx="16" cy="12.5" r="0.9" fill="currentColor" /> 20 + <path d="M7 18.5h18" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" /> 21 + <path d="M9 22.8h14M9 25.6h9" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" opacity="0.55" /> 22 + </svg> 23 + ); 24 + } 25 + 26 + function NavIcon( { name }: { name: 'feather' | 'publications' } ) { 27 + if ( name === 'feather' ) { 28 + return ( 29 + <svg className="app-bar__navicon" viewBox="0 0 24 24" width={ 18 } height={ 18 } fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"> 30 + <path d="M20.24 12.24a6 6 0 0 0-8.49-8.49L5 10.5V19h8.5z" /> 31 + <line x1="16" y1="8" x2="2" y2="22" /> 32 + <line x1="17.5" y1="15" x2="9" y2="15" /> 33 + </svg> 34 + ); 35 + } 36 + return ( 37 + <svg className="app-bar__navicon" viewBox="0 0 24 24" width={ 18 } height={ 18 } fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"> 38 + <path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" /> 39 + <path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" /> 40 + </svg> 41 + ); 42 + } 43 + 44 + /** 45 + * The shared top bar for the editor + dashboard islands. Logo on the left; 46 + * contextual nav + account + sign-out on the right. Rendered inside AuthProvider 47 + * in every auth state: logo-only while loading, + nav when signed out, + account 48 + * and sign-out when signed in. 49 + */ 50 + export default function AppBar( { current }: { current: AppBarContext } ) { 51 + const { status, handle, displayName, avatar, did, signOut } = useAuth(); 52 + const [ avatarOk, setAvatarOk ] = useState( true ); 53 + const nav = appBarNav( current ); 54 + const signedIn = status === 'signed-in' && Boolean( did ); 55 + 56 + const viewerName = did 57 + ? displayNameFor( { did, handle, displayName, avatar } ) 58 + : ''; 59 + const profileHref = authorPath( handle ); 60 + 61 + return ( 62 + <header className="app-bar"> 63 + <a className="app-bar__home" href="/" aria-label="SkyPress home"> 64 + <LogoMark /> 65 + <span className="app-bar__word">SkyPress</span> 66 + </a> 67 + 68 + <span className="app-bar__spacer" /> 69 + 70 + { status !== 'loading' && ( 71 + <a className="app-bar__nav" href={ nav.href }> 72 + <NavIcon name={ nav.icon } /> 73 + { nav.label } 74 + </a> 75 + ) } 76 + 77 + { signedIn && ( 78 + <> 79 + <IdentityBlock 80 + href={ profileHref } 81 + name={ viewerName } 82 + handle={ handle } 83 + avatar={ avatar } 84 + avatarOk={ avatarOk } 85 + onAvatarError={ () => setAvatarOk( false ) } 86 + /> 87 + <button type="button" className="app-bar__signout" onClick={ () => void signOut() }> 88 + Sign out 89 + </button> 90 + </> 91 + ) } 92 + </header> 93 + ); 94 + } 95 + 96 + /** Avatar + name + @handle. The whole block links to the public author page when a handle is known. */ 97 + function IdentityBlock( { 98 + href, 99 + name, 100 + handle, 101 + avatar, 102 + avatarOk, 103 + onAvatarError, 104 + }: { 105 + href: string | null; 106 + name: string; 107 + handle: string | null; 108 + avatar: string | null; 109 + avatarOk: boolean; 110 + onAvatarError: () => void; 111 + } ) { 112 + const inner = ( 113 + <> 114 + { avatar && avatarOk ? ( 115 + <img 116 + className="app-bar__avatar" 117 + src={ avatar } 118 + alt="" 119 + width={ 30 } 120 + height={ 30 } 121 + onError={ onAvatarError } 122 + /> 123 + ) : ( 124 + <span className="app-bar__avatar app-bar__avatar--fallback" aria-hidden="true"> 125 + { name.charAt( 0 ).toUpperCase() } 126 + </span> 127 + ) } 128 + <span className="app-bar__who"> 129 + <strong className="app-bar__name">{ name }</strong> 130 + { handle && <span className="app-bar__handle">@{ handle }</span> } 131 + </span> 132 + </> 133 + ); 134 + 135 + return href ? ( 136 + <a className="app-bar__identity" href={ href }> 137 + { inner } 138 + </a> 139 + ) : ( 140 + <span className="app-bar__identity">{ inner }</span> 141 + ); 142 + }
+83 -71
src/components/Dashboard.tsx
··· 16 16 type MyArticle, 17 17 } from '../lib/publish/publisher'; 18 18 import { buildGetBlobUrl, type BlobRefJson } from '../lib/media/blob'; 19 + import AppBar from './AppBar'; 20 + import { editLinkFor } from '../lib/editor/edit-link'; 19 21 20 22 type View = 21 23 | { kind: 'list' } ··· 24 26 25 27 /** The publication dashboard (SP10, step D). Authed, client-only — no `@wordpress/*`. */ 26 28 function DashboardGate() { 27 - const { status, agent, handle, did, pdsUrl, error, signOut } = useAuth(); 29 + const { status, agent, handle, did, pdsUrl, error } = useAuth(); 28 30 const [ data, setData ] = useState< 29 31 { owned: Publication[]; foreign: ForeignPublication[] } | null 30 32 >( null ); ··· 44 46 }, [ agent, did ] ); 45 47 46 48 if ( status === 'loading' ) { 47 - return <p className="dash__loading">Connecting to your identity…</p>; 49 + return ( 50 + <> 51 + <AppBar current="dashboard" /> 52 + <p className="dash__loading">Connecting to your identity…</p> 53 + </> 54 + ); 48 55 } 49 56 50 57 if ( status !== 'signed-in' || ! agent || ! did ) { 51 58 return ( 52 - <div className="dash__login"> 53 - <LoginForm /> 54 - { status === 'error' && error && ( 55 - <p className="dash__error" role="alert"> 56 - Couldn't start the auth client: { error } 57 - </p> 58 - ) } 59 - </div> 59 + <> 60 + <AppBar current="dashboard" /> 61 + <div className="dash__login"> 62 + <LoginForm /> 63 + { status === 'error' && error && ( 64 + <p className="dash__error" role="alert"> 65 + Couldn't start the auth client: { error } 66 + </p> 67 + ) } 68 + </div> 69 + </> 60 70 ); 61 71 } 62 72 ··· 69 79 }; 70 80 71 81 return ( 72 - <div className="dash"> 73 - <header className="dash__bar"> 74 - <button 75 - type="button" 76 - className="dash__crumb" 77 - onClick={ () => setView( { kind: 'list' } ) } 78 - disabled={ view.kind === 'list' } 79 - > 80 - Your publications 81 - </button> 82 - <span className="dash__bar-actions"> 83 - <a className="dash__studio" href="/editor"> 84 - Studio 85 - </a> 86 - <button type="button" className="dash__signout" onClick={ () => void signOut() }> 87 - Sign out 82 + <> 83 + <AppBar current="dashboard" /> 84 + <div className="dash"> 85 + <header className="dash__bar"> 86 + <button 87 + type="button" 88 + className="dash__crumb" 89 + onClick={ () => setView( { kind: 'list' } ) } 90 + disabled={ view.kind === 'list' } 91 + > 92 + Your publications 88 93 </button> 89 - </span> 90 - </header> 94 + </header> 91 95 92 - { view.kind === 'create' && ( 93 - <PublicationForm 94 - agent={ agent } 95 - did={ did } 96 - pdsUrl={ pdsUrl } 97 - handle={ writerHandle } 98 - onSaved={ () => { 99 - reload(); 100 - setView( { kind: 'list' } ); 101 - } } 102 - onCancel={ () => setView( { kind: 'list' } ) } 103 - /> 104 - ) } 96 + { view.kind === 'create' && ( 97 + <PublicationForm 98 + agent={ agent } 99 + did={ did } 100 + pdsUrl={ pdsUrl } 101 + handle={ writerHandle } 102 + onSaved={ () => { 103 + reload(); 104 + setView( { kind: 'list' } ); 105 + } } 106 + onCancel={ () => setView( { kind: 'list' } ) } 107 + /> 108 + ) } 105 109 106 - { view.kind === 'manage' && ( 107 - <PublicationManager 108 - agent={ agent } 109 - did={ did } 110 - pdsUrl={ pdsUrl } 111 - handle={ writerHandle } 112 - pub={ view.pub } 113 - onChanged={ ( pub ) => { 114 - reload(); 115 - setView( { kind: 'manage', pub } ); 116 - } } 117 - onDeleted={ () => { 118 - reload(); 119 - setView( { kind: 'list' } ); 120 - } } 121 - onBack={ () => setView( { kind: 'list' } ) } 122 - /> 123 - ) } 110 + { view.kind === 'manage' && ( 111 + <PublicationManager 112 + agent={ agent } 113 + did={ did } 114 + pdsUrl={ pdsUrl } 115 + handle={ writerHandle } 116 + pub={ view.pub } 117 + onChanged={ ( pub ) => { 118 + reload(); 119 + setView( { kind: 'manage', pub } ); 120 + } } 121 + onDeleted={ () => { 122 + reload(); 123 + setView( { kind: 'list' } ); 124 + } } 125 + onBack={ () => setView( { kind: 'list' } ) } 126 + /> 127 + ) } 124 128 125 - { view.kind === 'list' && ( 126 - <PublicationList 127 - publications={ data ? data.owned : null } 128 - foreign={ data?.foreign ?? [] } 129 - did={ did } 130 - handle={ writerHandle } 131 - pdsUrl={ pdsUrl } 132 - onNew={ () => setView( { kind: 'create' } ) } 133 - onManage={ ( pub ) => setView( { kind: 'manage', pub } ) } 134 - /> 135 - ) } 136 - </div> 129 + { view.kind === 'list' && ( 130 + <PublicationList 131 + publications={ data ? data.owned : null } 132 + foreign={ data?.foreign ?? [] } 133 + did={ did } 134 + handle={ writerHandle } 135 + pdsUrl={ pdsUrl } 136 + onNew={ () => setView( { kind: 'create' } ) } 137 + onManage={ ( pub ) => setView( { kind: 'manage', pub } ) } 138 + /> 139 + ) } 140 + </div> 141 + </> 137 142 ); 138 143 } 139 144 ··· 404 409 { article.publishedAt.slice( 0, 10 ) } 405 410 </span> 406 411 ) } 412 + <a 413 + className="dash__edit" 414 + href={ editLinkFor( article.rkey ) } 415 + aria-label={ `Edit ${ article.title }` } 416 + > 417 + Edit 418 + </a> 407 419 <button 408 420 type="button" 409 421 disabled={ busy === article.rkey }
-74
src/components/MyArticles.test.tsx
··· 1 - import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; 2 - import { createElement, act } from 'react'; 3 - import { createRoot, type Root } from 'react-dom/client'; 4 - import type { Agent } from '@atproto/api'; 5 - import type { MyArticle } from '../lib/publish/publisher'; 6 - 7 - // React needs this flag to run effects synchronously inside act(). 8 - ( globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } ).IS_REACT_ACT_ENVIRONMENT = true; 9 - 10 - const sampleArticle: MyArticle = { 11 - rkey: '3kabc123', 12 - title: 'Hello world', 13 - siteUri: 'at://did:plc:alice/site.standard.publication/pub1', 14 - siteSlug: 'my-blog', 15 - blocks: [], 16 - }; 17 - 18 - const listAllMyArticles = vi.fn(); 19 - vi.mock( '../lib/publish/publisher', () => ( { 20 - listAllMyArticles: () => listAllMyArticles(), 21 - unpublish: vi.fn().mockResolvedValue( undefined ), 22 - } ) ); 23 - 24 - // Imported after the mock so the component picks up the mocked module. 25 - const { default: MyArticles } = await import( './MyArticles' ); 26 - 27 - let container: HTMLDivElement; 28 - let root: Root; 29 - 30 - beforeEach( () => { 31 - listAllMyArticles.mockResolvedValue( [ sampleArticle ] ); 32 - container = document.createElement( 'div' ); 33 - document.body.appendChild( container ); 34 - root = createRoot( container ); 35 - } ); 36 - 37 - afterEach( () => { 38 - act( () => root.unmount() ); 39 - container.remove(); 40 - vi.clearAllMocks(); 41 - } ); 42 - 43 - async function renderList( handle: string | null ): Promise< void > { 44 - await act( async () => { 45 - root.render( 46 - createElement( MyArticles, { 47 - agent: {} as Agent, 48 - did: 'did:plc:alice', 49 - handle, 50 - refreshKey: 0, 51 - onEdit: () => {}, 52 - } ) 53 - ); 54 - } ); 55 - } 56 - 57 - describe( 'MyArticles view link', () => { 58 - it( 'links each article to its public page when a handle is known', async () => { 59 - await renderList( 'alice.test' ); 60 - 61 - const link = container.querySelector< HTMLAnchorElement >( 'a.myarticles__view' ); 62 - expect( link ).not.toBeNull(); 63 - expect( link?.getAttribute( 'href' ) ).toBe( '/@alice.test/my-blog/3kabc123' ); 64 - expect( link?.textContent ).toContain( 'View' ); 65 - } ); 66 - 67 - it( 'omits the view link when no handle is available', async () => { 68 - await renderList( null ); 69 - 70 - expect( container.querySelector( 'a.myarticles__view' ) ).toBeNull(); 71 - // The other actions still render. 72 - expect( container.textContent ).toContain( 'Edit' ); 73 - } ); 74 - } );
-94
src/components/MyArticles.tsx
··· 1 - import { useEffect, useState } from 'react'; 2 - import type { Agent } from '@atproto/api'; 3 - import { listAllMyArticles, unpublish, type MyArticle } from '../lib/publish/publisher'; 4 - 5 - interface Props { 6 - agent: Agent; 7 - did: string; 8 - /** Writer's handle, for building public article URLs. Null if unresolved. */ 9 - handle: string | null; 10 - /** Bump to re-fetch after a publish/update. */ 11 - refreshKey: number; 12 - onEdit: ( article: MyArticle ) => void; 13 - } 14 - 15 - /** Lists the signed-in writer's SkyPress articles across all their publications (SP5/SP10). */ 16 - export default function MyArticles( { agent, did, handle, refreshKey, onEdit }: Props ) { 17 - const [ articles, setArticles ] = useState< MyArticle[] | null >( null ); 18 - const [ busy, setBusy ] = useState< string | null >( null ); 19 - 20 - useEffect( () => { 21 - let cancelled = false; 22 - listAllMyArticles( agent, did ) 23 - .then( ( list ) => ! cancelled && setArticles( list ) ) 24 - .catch( () => ! cancelled && setArticles( [] ) ); 25 - return () => { 26 - cancelled = true; 27 - }; 28 - }, [ agent, did, refreshKey ] ); 29 - 30 - async function onDelete( article: MyArticle ) { 31 - const ok = window.confirm( 32 - `Unpublish “${ article.title }”?\n\nThis deletes the article AND its Bluesky post.` 33 - ); 34 - if ( ! ok ) { 35 - return; 36 - } 37 - setBusy( article.rkey ); 38 - try { 39 - await unpublish( agent, did, { 40 - rkey: article.rkey, 41 - bskyPostRef: article.bskyPostRef, 42 - } ); 43 - setArticles( ( prev ) => prev?.filter( ( a ) => a.rkey !== article.rkey ) ?? null ); 44 - } finally { 45 - setBusy( null ); 46 - } 47 - } 48 - 49 - if ( articles === null ) { 50 - return <p className="myarticles__loading">Loading your articles…</p>; 51 - } 52 - if ( articles.length === 0 ) { 53 - return null; 54 - } 55 - 56 - return ( 57 - <section className="myarticles" aria-label="Your articles"> 58 - <h2 className="myarticles__heading">Your articles</h2> 59 - <ul className="myarticles__list"> 60 - { articles.map( ( article ) => ( 61 - <li className="myarticles__item" key={ article.rkey }> 62 - <span className="myarticles__title"> 63 - { article.title } 64 - <em className="myarticles__pub"> · { article.siteSlug }</em> 65 - { article.updatedAt && <em className="myarticles__edited"> · edited</em> } 66 - </span> 67 - <span className="myarticles__actions"> 68 - { handle && ( 69 - <a 70 - className="myarticles__view" 71 - href={ `/@${ handle }/${ article.siteSlug }/${ article.rkey }` } 72 - target="_blank" 73 - rel="noopener noreferrer" 74 - > 75 - View 76 - </a> 77 - ) } 78 - <button type="button" onClick={ () => onEdit( article ) }> 79 - Edit 80 - </button> 81 - <button 82 - type="button" 83 - disabled={ busy === article.rkey } 84 - onClick={ () => void onDelete( article ) } 85 - > 86 - { busy === article.rkey ? 'Unpublishing…' : 'Unpublish' } 87 - </button> 88 - </span> 89 - </li> 90 - ) ) } 91 - </ul> 92 - </section> 93 - ); 94 - }
+3 -12
src/components/PublishPanel.tsx
··· 34 34 publications: Publication[] | null; 35 35 /** When set, the panel updates an existing article instead of publishing a new one. */ 36 36 editing?: EditingTarget; 37 - initialTitle?: string; 37 + /** Controlled title (lifted to the editor so it can render as a heading above the canvas). */ 38 + title: string; 38 39 /** Called after a successful publish/update so the parent can refresh. */ 39 40 onComplete?: () => void; 40 41 } ··· 52 53 blobRegistry, 53 54 publications, 54 55 editing, 55 - initialTitle, 56 + title, 56 57 onComplete, 57 58 }: Props ) { 58 59 const pubs = publications ?? []; 59 - const [ title, setTitle ] = useState( initialTitle ?? '' ); 60 60 const [ targetUri, setTargetUri ] = useState( 61 61 () => editing?.siteUri ?? pubs[ 0 ]?.uri ?? '' 62 62 ); ··· 150 150 151 151 return ( 152 152 <section className="publish" aria-label={ isEditing ? 'Update article' : 'Publish' }> 153 - <input 154 - className="publish__title" 155 - type="text" 156 - placeholder="Article title" 157 - value={ title } 158 - onChange={ ( event ) => setTitle( event.target.value ) } 159 - disabled={ phase === 'working' } 160 - /> 161 - 162 153 { isEditing ? ( 163 154 <span className="publish__target publish__target--fixed"> 164 155 In <strong>{ editingPubName ?? 'this publication' }</strong>
+99 -74
src/components/Studio.tsx
··· 5 5 import LoginForm from '../lib/auth/LoginForm'; 6 6 import SkyEditor from './SkyEditor'; 7 7 import PublishPanel from './PublishPanel'; 8 - import MyArticles from './MyArticles'; 8 + import AppBar from './AppBar'; 9 9 import { createMediaUpload, revokeBlobRegistry, type BlobRegistry } from '../lib/media/mediaUpload'; 10 - import { displayNameFor, authorPath } from '../lib/auth/profile'; 11 - import type { MyArticle } from '../lib/publish/publisher'; 10 + import { getMyArticle, type MyArticle } from '../lib/publish/publisher'; 11 + import { editRkeyFromSearch } from '../lib/editor/edit-link'; 12 12 import { listPublications, type Publication } from '../lib/publish/publications'; 13 13 14 14 /** 15 15 * The authenticated writing surface. Gates the editor behind atproto OAuth: 16 - * loading → (signed-out: login form) | (signed-in: account bar + editor). 16 + * loading → (signed-out: login form) | (signed-in: editor). 17 17 */ 18 18 function StudioGate() { 19 - const { status, agent, handle, displayName, avatar, did, pdsUrl, error, signOut } = useAuth(); 19 + const { status, agent, handle, did, pdsUrl, error } = useAuth(); 20 20 const [ blocks, setBlocks ] = useState< BlockInstance[] >( [] ); 21 + const [ title, setTitle ] = useState( '' ); 21 22 const [ editing, setEditing ] = useState< MyArticle | null >( null ); 23 + // Set when an `?edit=<rkey>` load fails to fetch (vs. simply not found). 24 + const [ editLoadError, setEditLoadError ] = useState< string | null >( null ); 22 25 const [ refreshKey, setRefreshKey ] = useState( 0 ); 23 - const [ avatarOk, setAvatarOk ] = useState( true ); 24 26 // `null` = still loading; `[]` = loaded, none exist. PublishPanel needs the distinction. 25 27 const [ publications, setPublications ] = useState< Publication[] | null >( null ); 26 28 // Shared between mediaUpload (writes blob refs) and publish (reads them). ··· 40 42 }; 41 43 }, [ agent, did, refreshKey ] ); 42 44 45 + // One-shot: if the page was opened as /editor?edit=<rkey>, load that article. 46 + const editLoadedRef = useRef( false ); 47 + useEffect( () => { 48 + if ( editLoadedRef.current || ! agent || ! did ) { 49 + return; 50 + } 51 + const rkey = editRkeyFromSearch( window.location.search ); 52 + if ( ! rkey ) { 53 + editLoadedRef.current = true; 54 + return; 55 + } 56 + editLoadedRef.current = true; 57 + let cancelled = false; 58 + getMyArticle( agent, did, rkey ) 59 + .then( ( article ) => { 60 + if ( cancelled ) { 61 + return; 62 + } 63 + if ( article ) { 64 + setEditing( article ); 65 + setBlocks( article.blocks as unknown as BlockInstance[] ); 66 + setTitle( article.title ); 67 + } 68 + // `article === null` → no owned document has this rkey (stale/bad edit 69 + // link). Silently start a new article, as before. 70 + } ) 71 + .catch( ( err ) => { 72 + // The fetch itself failed (network/auth) — distinct from a stale link. 73 + // Surface it so the blank "New article" editor isn't mistaken for the 74 + // requested article having loaded. 75 + if ( ! cancelled ) { 76 + setEditLoadError( 77 + err instanceof Error ? err.message : String( err ) 78 + ); 79 + } 80 + } ); 81 + return () => { 82 + cancelled = true; 83 + }; 84 + }, [ agent, did ] ); 85 + 43 86 // Release the preview object URLs this session minted when the Studio unmounts. 44 87 useEffect( () => () => revokeBlobRegistry( registry ), [ registry ] ); 45 88 ··· 51 94 }, [ agent, did, pdsUrl, registry ] ); 52 95 53 96 if ( status === 'loading' ) { 54 - return <p className="studio__loading">Connecting to your identity…</p>; 97 + return ( 98 + <> 99 + <AppBar current="editor" /> 100 + <p className="studio__loading">Connecting to your identity…</p> 101 + </> 102 + ); 55 103 } 56 104 57 105 if ( status === 'signed-in' && agent && did ) { 58 - // Re-mount the editor + panel when switching article so onLoad + the title reset. 106 + // Re-mount the editor when switching article (or after a new publish) so the 107 + // SkyEditor canvas resets via onLoad/initialBlocks. The title is Studio-owned 108 + // state now, so it doesn't reset on remount — the title/blocks reset for a new 109 + // publish happens in PublishPanel's `onComplete` below. 59 110 const editorKey = editing ? `edit-${ editing.rkey }` : `new-${ refreshKey }`; 60 - const viewerName = displayNameFor( { did, handle, displayName, avatar } ); 61 - const publicPath = authorPath( handle ); 62 111 63 - // Switching articles re-mounts the editor, so the current previews leave the DOM — 64 - // safe to release the object URLs they held before loading the next article. 65 - const startEdit = ( article: MyArticle ) => { 66 - revokeBlobRegistry( registry ); 67 - setEditing( article ); 68 - setBlocks( article.blocks as unknown as BlockInstance[] ); 69 - }; 70 112 const startNew = () => { 71 113 revokeBlobRegistry( registry ); 72 114 setEditing( null ); 73 115 setBlocks( [] ); 116 + setTitle( '' ); 117 + setEditLoadError( null ); 74 118 }; 75 119 76 120 return ( 77 121 <> 78 - <div className="studio__account"> 79 - <span className="studio__identity"> 80 - { avatar && avatarOk ? ( 81 - <img 82 - className="studio__avatar" 83 - src={ avatar } 84 - alt="" 85 - width={ 38 } 86 - height={ 38 } 87 - onError={ () => setAvatarOk( false ) } 88 - /> 89 - ) : ( 90 - <span className="studio__avatar studio__avatar--fallback" aria-hidden="true"> 91 - { viewerName.charAt( 0 ).toUpperCase() } 92 - </span> 93 - ) } 94 - <span className="studio__who"> 95 - <strong className="studio__name">{ viewerName }</strong> 96 - { handle && <span className="studio__handle">@{ handle }</span> } 97 - </span> 98 - </span> 99 - <span className="studio__account-actions"> 100 - <a className="studio__viewpage" href="/dashboard"> 101 - Dashboard 102 - </a> 103 - { publicPath && ( 104 - <a className="studio__viewpage" href={ publicPath }> 105 - View my public page 106 - </a> 107 - ) } 108 - <button 109 - type="button" 110 - className="studio__signout" 111 - onClick={ () => void signOut() } 112 - > 113 - Sign out 114 - </button> 115 - </span> 116 - </div> 117 - 118 - <MyArticles 119 - agent={ agent } 120 - did={ did } 121 - handle={ handle } 122 - refreshKey={ refreshKey } 123 - onEdit={ startEdit } 124 - /> 122 + <AppBar current="editor" /> 125 123 126 124 <div className="studio__mode"> 127 125 <span>{ editing ? `Editing: ${ editing.title }` : 'New article' }</span> ··· 132 130 ) } 133 131 </div> 134 132 133 + { editLoadError && ( 134 + <p className="studio__error studio__error--banner" role="alert"> 135 + Couldn't open that article for editing: { editLoadError }. You can 136 + retry from your dashboard, or start a new article below. 137 + </p> 138 + ) } 139 + 135 140 <div key={ editorKey }> 136 141 <PublishPanel 137 142 agent={ agent } ··· 150 155 } 151 156 : undefined 152 157 } 153 - initialTitle={ editing?.title } 154 - onComplete={ () => setRefreshKey( ( k ) => k + 1 ) } 158 + title={ title } 159 + onComplete={ () => { 160 + setRefreshKey( ( k ) => k + 1 ); 161 + // A new publish leaves the editor on a fresh "new article": clear the 162 + // title + blocks (the editorKey bump remounts SkyEditor empty). On an 163 + // update we stay on the same article, so keep its content in place. 164 + if ( ! editing ) { 165 + setTitle( '' ); 166 + setBlocks( [] ); 167 + } 168 + } } 169 + /> 170 + <input 171 + className="studio__title" 172 + type="text" 173 + placeholder="Add title" 174 + aria-label="Article title" 175 + value={ title } 176 + onChange={ ( event ) => setTitle( event.target.value ) } 155 177 /> 156 178 <SkyEditor 157 179 onChange={ setBlocks } ··· 165 187 166 188 // signed-out or error 167 189 return ( 168 - <div className="studio__login"> 169 - <LoginForm /> 170 - { status === 'error' && error && ( 171 - <p className="studio__error" role="alert"> 172 - Couldn't start the auth client: { error } 173 - </p> 174 - ) } 175 - </div> 190 + <> 191 + <AppBar current="editor" /> 192 + <div className="studio__login"> 193 + <LoginForm /> 194 + { status === 'error' && error && ( 195 + <p className="studio__error" role="alert"> 196 + Couldn't start the auth client: { error } 197 + </p> 198 + ) } 199 + </div> 200 + </> 176 201 ); 177 202 } 178 203
+20
src/lib/auth/nav.test.ts
··· 1 + import { describe, expect, it } from 'vitest'; 2 + import { appBarNav } from './nav'; 3 + 4 + describe( 'appBarNav', () => { 5 + it( 'links the editor bar back to the dashboard (Publications)', () => { 6 + expect( appBarNav( 'editor' ) ).toEqual( { 7 + href: '/dashboard', 8 + label: 'Publications', 9 + icon: 'publications', 10 + } ); 11 + } ); 12 + 13 + it( 'links the dashboard bar into the editor (Write, feather)', () => { 14 + expect( appBarNav( 'dashboard' ) ).toEqual( { 15 + href: '/editor', 16 + label: 'Write', 17 + icon: 'feather', 18 + } ); 19 + } ); 20 + } );
+20
src/lib/auth/nav.ts
··· 1 + /** The page an AppBar renders on; selects its contextual nav action. */ 2 + export type AppBarContext = 'editor' | 'dashboard'; 3 + 4 + export interface AppBarNav { 5 + href: string; 6 + label: string; 7 + icon: 'feather' | 'publications'; 8 + } 9 + 10 + /** 11 + * The AppBar's single contextual nav action, by the page it renders on: 12 + * editor → back to your publications (the dashboard) 13 + * dashboard → into the editor (a feather "Write") 14 + */ 15 + export function appBarNav( current: AppBarContext ): AppBarNav { 16 + if ( current === 'dashboard' ) { 17 + return { href: '/editor', label: 'Write', icon: 'feather' }; 18 + } 19 + return { href: '/dashboard', label: 'Publications', icon: 'publications' }; 20 + }
+23
src/lib/editor/edit-link.test.ts
··· 1 + import { describe, expect, it } from 'vitest'; 2 + import { editLinkFor, editRkeyFromSearch } from './edit-link'; 3 + 4 + describe( 'editLinkFor', () => { 5 + it( 'builds the editor edit URL for an rkey', () => { 6 + expect( editLinkFor( '3kabc123' ) ).toBe( '/editor?edit=3kabc123' ); 7 + } ); 8 + } ); 9 + 10 + describe( 'editRkeyFromSearch', () => { 11 + it( 'reads the rkey from the edit param', () => { 12 + expect( editRkeyFromSearch( '?edit=3kabc123' ) ).toBe( '3kabc123' ); 13 + } ); 14 + 15 + it( 'returns null when the param is absent', () => { 16 + expect( editRkeyFromSearch( '?foo=bar' ) ).toBeNull(); 17 + expect( editRkeyFromSearch( '' ) ).toBeNull(); 18 + } ); 19 + 20 + it( 'returns null when the param is present but empty', () => { 21 + expect( editRkeyFromSearch( '?edit=' ) ).toBeNull(); 22 + } ); 23 + } );
+18
src/lib/editor/edit-link.ts
··· 1 + /** 2 + * The dashboard→editor "edit this article" link, and the editor-side parser for 3 + * it. Kept together so the `?edit=` param name has a single source of truth. 4 + * `rkey` uniquely identifies a document within the writer's repo, so it is all 5 + * the editor needs to re-fetch the article on load. 6 + */ 7 + const EDIT_PARAM = 'edit'; 8 + 9 + /** The editor URL that opens an existing article for editing. */ 10 + export function editLinkFor( rkey: string ): string { 11 + return `/editor?${ EDIT_PARAM }=${ rkey }`; 12 + } 13 + 14 + /** The rkey to edit, parsed from a URL search string (e.g. `window.location.search`). Null when absent or empty. */ 15 + export function editRkeyFromSearch( search: string ): string | null { 16 + const rkey = new URLSearchParams( search ).get( EDIT_PARAM ); 17 + return rkey && rkey.length > 0 ? rkey : null; 18 + }
+44 -1
src/lib/publish/publisher.test.ts
··· 1 1 import { describe, expect, it, vi } from 'vitest'; 2 2 import type { Agent } from '@atproto/api'; 3 - import { publish, updateDocument, listAllMyArticles, listPublicationArticles } from './publisher'; 3 + import { publish, updateDocument, listAllMyArticles, listPublicationArticles, getMyArticle } from './publisher'; 4 4 import { SITE_BASE } from './records'; 5 5 import type { BlockNode } from '../blocks/render'; 6 6 ··· 229 229 expect( articles[ 0 ] ).toMatchObject( { rkey: 'd1', siteSlug: 'blog-a' } ); 230 230 } ); 231 231 } ); 232 + 233 + describe( 'getMyArticle', () => { 234 + function repo() { 235 + return mockAgent( { 236 + 'site.standard.publication': [ 237 + { 238 + uri: `at://${ DID }/site.standard.publication/pub1`, 239 + value: { 240 + $type: 'site.standard.publication', 241 + url: `${ SITE_BASE }/@${ HANDLE }/blog-a`, 242 + name: 'Blog A', 243 + }, 244 + }, 245 + ], 246 + 'site.standard.document': [ 247 + { 248 + uri: `at://${ DID }/site.standard.document/d1`, 249 + value: { title: 'In A', site: `at://${ DID }/site.standard.publication/pub1` }, 250 + }, 251 + { 252 + uri: `at://${ DID }/site.standard.document/d2`, 253 + value: { title: 'Orphan', site: `at://${ DID }/site.standard.publication/gone` }, 254 + }, 255 + ], 256 + } ); 257 + } 258 + 259 + it( 'returns the writer’s article by rkey, slug-annotated', async () => { 260 + const { agent } = repo(); 261 + const article = await getMyArticle( agent, DID, 'd1' ); 262 + expect( article ).toMatchObject( { rkey: 'd1', title: 'In A', siteSlug: 'blog-a' } ); 263 + } ); 264 + 265 + it( 'returns null for an unknown rkey', async () => { 266 + const { agent } = repo(); 267 + expect( await getMyArticle( agent, DID, 'nope' ) ).toBeNull(); 268 + } ); 269 + 270 + it( 'returns null for a foreign/orphan document', async () => { 271 + const { agent } = repo(); 272 + expect( await getMyArticle( agent, DID, 'd2' ) ).toBeNull(); 273 + } ); 274 + } );
+14
src/lib/publish/publisher.ts
··· 298 298 } ) 299 299 .filter( ( article ): article is MyArticle => article !== null ); 300 300 } 301 + 302 + /** 303 + * Fetch a single SkyPress article by rkey (the editor's `?edit=` load). Reuses 304 + * `listAllMyArticles` so it inherits the same slug annotation and foreign/orphan 305 + * filtering; returns null when no owned document has that rkey. 306 + */ 307 + export async function getMyArticle( 308 + agent: Agent, 309 + did: string, 310 + rkey: string 311 + ): Promise< MyArticle | null > { 312 + const all = await listAllMyArticles( agent, did ); 313 + return all.find( ( article ) => article.rkey === rkey ) ?? null; 314 + }
+12 -61
src/pages/dashboard.astro
··· 1 1 --- 2 2 import Base from '../layouts/Base.astro'; 3 - import Logo from '../components/Logo.astro'; 4 3 import Dashboard from '../components/Dashboard.tsx'; 5 - // The signed-out sign-in form (`LoginForm`) is shared with the editor page; its 6 - // styles live in this global stylesheet so the form looks the same on both. 4 + // Shared top-bar styles (the Dashboard island is client:only, so Astro scoped 5 + // styles can't reach its DOM). The sign-in form styles are shared with the editor. 6 + import '../styles/app-bar.css'; 7 7 import '../styles/login.css'; 8 8 --- 9 9 10 10 <Base title="Dashboard — SkyPress"> 11 11 <main class="dash-shell"> 12 - <header class="dash-shell__bar"> 13 - <a class="dash-shell__home" href="/"><Logo size={24} /></a> 14 - <span class="dash-shell__hint eyebrow">Publications</span> 15 - </header> 16 12 <!-- client:only — auth runs only in the browser; its bundle never reaches 17 13 reading pages (Decisions 0003 & 0004). No `@wordpress/*` here. --> 18 14 <Dashboard client:only="react"> ··· 21 17 </main> 22 18 </Base> 23 19 24 - <style> 25 - .dash-shell__bar { 26 - display: flex; 27 - align-items: center; 28 - gap: 1rem; 29 - padding: 0.75rem 1.25rem; 30 - border-bottom: 1px solid var(--line); 31 - flex-wrap: wrap; 32 - } 33 - .dash-shell__home { 34 - display: inline-flex; 35 - font-weight: 700; 36 - color: var(--ink); 37 - text-decoration: none; 38 - } 39 - .dash-shell__hint { 40 - font-size: 0.85rem; 41 - line-height: 1; 42 - } 43 - </style> 44 - 45 20 <!-- Dashboard is a `client:only` React island, so Astro's scoped styles never reach 46 21 its DOM. These rules must be global. --> 47 22 <style is:global> ··· 65 40 padding: 0 1.25rem 5rem; 66 41 } 67 42 .dash__bar { 68 - display: flex; 69 - align-items: center; 70 - justify-content: space-between; 71 - gap: 1rem; 72 - padding: 0.75rem 0; 73 - flex-wrap: wrap; 43 + padding: 0.9rem 0; 74 44 } 75 45 .dash__crumb { 76 46 border: 0; ··· 85 55 color: var(--muted); 86 56 cursor: default; 87 57 } 88 - .dash__bar-actions { 89 - display: flex; 90 - align-items: center; 91 - gap: 0.75rem; 92 - } 93 58 .dash__link { 94 59 color: var(--sun); 95 60 text-decoration: none; ··· 97 62 } 98 63 .dash__link:hover { 99 64 text-decoration: underline; 100 - } 101 - .dash__studio { 102 - border: 1px solid var(--line-strong); 103 - background: var(--paper-raised); 104 - border-radius: var(--radius-sm); 105 - padding: 0.3rem 0.7rem; 106 - text-decoration: none; 107 - color: inherit; 108 - font-size: 0.85rem; 109 - } 110 - .dash__studio:hover { 111 - background: var(--paper); 112 - } 113 - .dash__signout { 114 - border: 1px solid var(--line-strong); 115 - background: var(--paper-raised); 116 - border-radius: var(--radius-sm); 117 - padding: 0.3rem 0.7rem; 118 - cursor: pointer; 119 - font: inherit; 120 - font-size: 0.85rem; 121 65 } 122 66 .dash__section-head { 123 67 display: flex; ··· 220 164 gap: 0.6rem; 221 165 } 222 166 .dash__pubactions button, 223 - .dash__post button { 167 + .dash__post button, 168 + .dash__post .dash__edit { 224 169 border: 1px solid var(--line-strong); 225 170 background: var(--paper-raised); 226 171 border-radius: 6px; ··· 228 173 font: inherit; 229 174 font-size: 0.85rem; 230 175 cursor: pointer; 176 + } 177 + .dash__post .dash__edit { 178 + color: inherit; 179 + text-decoration: none; 180 + display: inline-flex; 181 + align-items: center; 231 182 } 232 183 .dash__tabs { 233 184 display: flex;
+2 -26
src/pages/editor.astro
··· 1 1 --- 2 2 import Base from '../layouts/Base.astro'; 3 - import Logo from '../components/Logo.astro'; 4 3 import Studio from '../components/Studio.tsx'; 5 4 // The Studio is a `client:only` React island, so Astro's scoped styles never 6 - // reach its DOM — its chrome is styled globally from this shared stylesheet. 5 + // reach its DOM — its chrome is styled globally from these shared stylesheets. 6 + import '../styles/app-bar.css'; 7 7 import '../styles/editor-chrome.css'; 8 8 import '../styles/login.css'; 9 9 --- 10 10 11 11 <Base title="Write — SkyPress"> 12 12 <main class="editor-shell"> 13 - <header class="editor-shell__bar"> 14 - <a class="editor-shell__home" href="/"><Logo size={24} /></a> 15 - <span class="editor-shell__hint eyebrow">The studio</span> 16 - </header> 17 13 <!-- client:only — auth + editor run only in the browser; their bundle never 18 14 reaches reading pages (Decisions 0001 & 0004). --> 19 15 <Studio client:only="react"> ··· 23 19 </Base> 24 20 25 21 <style> 26 - .editor-shell__bar { 27 - display: flex; 28 - align-items: center; 29 - gap: 1rem; 30 - max-width: 60rem; 31 - margin: 0 auto; 32 - padding: 0.75rem 1.25rem; 33 - border-bottom: 1px solid var(--line); 34 - flex-wrap: wrap; 35 - } 36 - .editor-shell__home { 37 - display: inline-flex; 38 - font-weight: 700; 39 - color: var(--ink); 40 - text-decoration: none; 41 - } 42 - .editor-shell__hint { 43 - font-size: 0.85rem; 44 - line-height: 1; 45 - } 46 22 .editor-shell__loading { 47 23 padding: 2rem 1.25rem; 48 24 color: var(--muted);
+105
src/styles/app-bar.css
··· 1 + /** 2 + * Shared top bar for the editor + dashboard islands. Imported globally by both 3 + * page shells; the islands are client:only so Astro scoped styles can't reach 4 + * the bar's DOM. 5 + */ 6 + .app-bar { 7 + display: flex; 8 + align-items: center; 9 + gap: 0.75rem; 10 + max-width: var(--studio-measure, 60rem); 11 + margin: 0 auto; 12 + padding: 0.75rem 1.25rem; 13 + border-bottom: 1px solid var(--line); 14 + flex-wrap: wrap; 15 + } 16 + .app-bar__home { 17 + display: inline-flex; 18 + align-items: center; 19 + gap: 0.55rem; 20 + color: var(--ink); 21 + text-decoration: none; 22 + } 23 + .app-bar__mark { 24 + color: var(--sun); 25 + flex: none; 26 + } 27 + .app-bar__word { 28 + font-family: var(--font-display); 29 + font-weight: 700; 30 + font-size: 1.18rem; 31 + letter-spacing: -0.015em; 32 + } 33 + .app-bar__spacer { 34 + flex: 1; 35 + } 36 + .app-bar__nav { 37 + display: inline-flex; 38 + align-items: center; 39 + gap: 0.4rem; 40 + color: var(--sun); 41 + background: var(--sun-tint); 42 + text-decoration: none; 43 + font-size: 0.85rem; 44 + font-weight: 600; 45 + padding: 0.35rem 0.6rem; 46 + border-radius: var(--radius-sm); 47 + } 48 + .app-bar__nav:hover { 49 + text-decoration: underline; 50 + } 51 + .app-bar__navicon { 52 + flex: none; 53 + } 54 + .app-bar__identity { 55 + display: inline-flex; 56 + align-items: center; 57 + gap: 0.5rem; 58 + min-width: 0; 59 + text-decoration: none; 60 + color: inherit; 61 + padding: 0.2rem 0.35rem; 62 + border-radius: var(--radius-sm); 63 + } 64 + a.app-bar__identity:hover { 65 + background: var(--panel); 66 + } 67 + .app-bar__avatar { 68 + width: 30px; 69 + height: 30px; 70 + border-radius: 50%; 71 + object-fit: cover; 72 + flex: none; 73 + } 74 + .app-bar__avatar--fallback { 75 + display: inline-flex; 76 + align-items: center; 77 + justify-content: center; 78 + background: var(--sun-tint); 79 + color: var(--sun); 80 + font-weight: 700; 81 + font-size: 0.85rem; 82 + } 83 + .app-bar__who { 84 + display: flex; 85 + flex-direction: column; 86 + line-height: 1.15; 87 + min-width: 0; 88 + } 89 + .app-bar__name { 90 + font-weight: 650; 91 + font-size: 0.9rem; 92 + } 93 + .app-bar__handle { 94 + color: var(--muted); 95 + font-size: 0.75rem; 96 + } 97 + .app-bar__signout { 98 + border: 1px solid var(--line-strong); 99 + background: var(--paper-raised); 100 + border-radius: var(--radius-sm); 101 + padding: 0.3rem 0.7rem; 102 + cursor: pointer; 103 + font: inherit; 104 + font-size: 0.85rem; 105 + }
+40 -144
src/styles/editor-chrome.css
··· 5 5 * never reach its DOM — these rules must be global. Shared by the real editor 6 6 * page (`src/pages/editor.astro`). 7 7 * 8 - * The signed-in bars (account / articles / mode / publish) and the editor 9 - * surface share one centred content column so they line up with each other. 8 + * The signed-in bars (mode / publish) and the editor surface share one centred 9 + * content column so they line up with each other. 10 10 */ 11 11 12 12 :root { ··· 22 22 color: var(--muted); 23 23 } 24 24 25 - /* Account bar (signed in) */ 26 - .studio__account { 27 - display: flex; 28 - align-items: center; 29 - justify-content: space-between; 30 - gap: 1rem; 31 - max-width: var(--studio-measure); 32 - margin: 0 auto; 33 - padding: 0.5rem var(--studio-gutter); 34 - background: var(--panel); 35 - border-radius: var(--radius); 36 - font-size: 0.9rem; 37 - flex-wrap: wrap; 38 - } 39 - .studio__identity { 40 - display: flex; 41 - align-items: center; 42 - gap: 0.6rem; 43 - min-width: 0; 44 - } 45 - .studio__avatar { 46 - width: 38px; 47 - height: 38px; 48 - border-radius: 50%; 49 - object-fit: cover; 50 - flex: none; 51 - } 52 - .studio__avatar--fallback { 53 - display: inline-flex; 54 - align-items: center; 55 - justify-content: center; 56 - background: var(--sun-tint); 57 - color: var(--sun); 58 - font-weight: 700; 59 - } 60 - .studio__who { 61 - display: flex; 62 - flex-direction: column; 63 - line-height: 1.15; 64 - min-width: 0; 65 - } 66 - .studio__name { 67 - font-weight: 680; 68 - } 69 - .studio__handle { 70 - color: var(--muted); 71 - font-size: 0.8rem; 72 - } 73 - .studio__account-actions { 74 - display: flex; 75 - align-items: center; 76 - gap: 0.5rem; 77 - flex-wrap: wrap; 78 - } 79 - .studio__viewpage { 80 - color: var(--sun); 81 - text-decoration: none; 82 - font-size: 0.85rem; 83 - padding: 0.3rem 0.5rem; 84 - border-radius: var(--radius-sm); 85 - } 86 - .studio__viewpage:hover { 87 - text-decoration: underline; 88 - } 89 - .studio__signout { 90 - border: 1px solid var(--line-strong); 91 - background: var(--paper-raised); 92 - border-radius: var(--radius-sm); 93 - padding: 0.3rem 0.7rem; 94 - cursor: pointer; 95 - font: inherit; 96 - } 97 - 98 25 /* Login (signed out). The `.login__*` form rules are shared with the dashboard 99 26 and live in `login.css` (imported by editor.astro alongside this file). */ 100 27 .studio__login { ··· 106 33 color: var(--ember); 107 34 font-size: 0.9rem; 108 35 } 36 + /* Same alignment when the error sits on its own above the editor column (the 37 + signed-in `?edit=` load-failure notice) rather than inside the login card. */ 38 + .studio__error--banner { 39 + max-width: var(--studio-measure); 40 + margin: 0.5rem auto 0; 41 + padding: 0 var(--studio-gutter); 42 + } 109 43 110 44 /* Publish panel */ 111 45 .publish { ··· 116 50 max-width: var(--studio-measure); 117 51 margin: 0 auto; 118 52 padding: 0.75rem var(--studio-gutter); 119 - } 120 - .publish__title { 121 - flex: 1 1 18rem; 122 - padding: 0.5rem 0.7rem; 123 - border: 1px solid var(--line-strong); 124 - border-radius: 8px; 125 - font: inherit; 126 - font-size: 1.05rem; 127 53 } 128 54 .publish__target { 129 55 display: inline-flex; ··· 196 122 color: var(--ember); 197 123 } 198 124 199 - /* Your articles + mode bar */ 200 - .myarticles { 201 - max-width: var(--studio-measure); 202 - margin: 0 auto; 203 - padding: 1rem var(--studio-gutter); 204 - border-bottom: 1px solid var(--line); 205 - } 206 - .myarticles__heading { 207 - font-size: 0.75rem; 208 - text-transform: uppercase; 209 - letter-spacing: 0.1em; 210 - color: var(--muted); 211 - margin: 0 0 0.5rem; 212 - } 213 - .myarticles__loading { 214 - max-width: var(--studio-measure); 215 - margin: 0 auto; 216 - padding: 1rem var(--studio-gutter); 217 - color: var(--muted); 218 - font-size: 0.9rem; 219 - } 220 - .myarticles__list { 221 - list-style: none; 222 - margin: 0; 223 - padding: 0; 224 - } 225 - .myarticles__item { 226 - display: flex; 227 - align-items: center; 228 - justify-content: space-between; 229 - gap: 1rem; 230 - padding: 0.4rem 0; 231 - } 232 - .myarticles__edited, 233 - .myarticles__pub { 234 - color: var(--muted); 235 - font-style: normal; 236 - font-size: 0.85rem; 237 - } 238 - .myarticles__pub { 239 - font-family: var(--font-mono); 240 - font-size: 0.78rem; 241 - } 242 - .myarticles__actions { 243 - display: flex; 244 - gap: 0.5rem; 245 - } 246 - .myarticles__actions button, 247 - .myarticles__actions a { 248 - border: 1px solid var(--line-strong); 249 - background: var(--paper-raised); 250 - border-radius: 6px; 251 - padding: 0.25rem 0.6rem; 252 - font: inherit; 253 - font-size: 0.85rem; 254 - cursor: pointer; 255 - } 256 - .myarticles__actions a { 257 - color: inherit; 258 - text-decoration: none; 259 - } 125 + /* Mode bar */ 260 126 .studio__mode { 261 127 display: flex; 262 128 align-items: center; ··· 277 143 cursor: pointer; 278 144 } 279 145 146 + /* Borderless article title, sitting above the framed editor canvas — echoes the 147 + block-editor post title (large display heading, no box). */ 148 + .studio__title { 149 + display: block; 150 + max-width: var(--studio-measure); 151 + margin: 1.25rem auto 0; 152 + padding: 0 var(--studio-gutter); 153 + width: 100%; 154 + box-sizing: border-box; 155 + border: 0; 156 + background: transparent; 157 + color: var(--ink); 158 + font-family: var(--font-display); 159 + font-size: clamp(1.9rem, 4vw, 2.6rem); 160 + font-weight: 700; 161 + line-height: 1.15; 162 + } 163 + .studio__title::placeholder { 164 + color: var(--muted); 165 + opacity: 0.6; 166 + } 167 + .studio__title:focus { 168 + outline: none; 169 + } 170 + /* Tighten the gap between the title and the editor surface below it. */ 171 + .studio__title + .skypress-editor { 172 + margin-top: 0.75rem; 173 + } 174 + 280 175 /* Editor surface. The bundled isolated-block-editor CSS hard-codes a 281 176 full-width white surface that ignores `prefers-color-scheme`. Constrain 282 177 and frame it as a contained writing panel, and drive its colours from the ··· 292 187 border-radius: var(--radius); 293 188 color: var(--ink); 294 189 box-shadow: var(--shadow); 190 + min-height: 70vh; 295 191 } 296 192 /* With `has-fixed-toolbar`, the fixed toolbar sits at the top of the layout, 297 193 flush against the framed surface's top edge. Pad the header region (which