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

Configure Feed

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

Add like/repost/quote/reply actions to reader article pages

Readers can now react to a published article from the bottom of the post.
Each action writes a native app.bsky.feed.* record to the reader's own PDS,
targeting the article's companion Bluesky post (bskyPostRef) so the reply
threads under it and the like/repost show on Bluesky:

- like -> app.bsky.feed.like
- repost -> app.bsky.feed.repost
- reply -> app.bsky.feed.post with reply.{root,parent} = companion post
- quote -> app.bsky.feed.post embedding the companion post

Replies are app.bsky.feed.post (not a site.standard.* record) because only
app.bsky.* records thread/federate on Bluesky; that caps replies at 300
graphemes, so a plain composer with an Intl.Segmenter counter replaces the
block editor for replies (Decision 0015).

A client:only PostActions island reuses the existing OAuth AuthProvider to
sign readers in; the read path still imports no @wordpress/*. No new OAuth
scope (transition:generic already covers app.bsky.feed.* writes). Actions are
disclosed as public Bluesky actions. The bar is omitted for legacy documents
without a bskyPostRef.

+1161
+70
docs/decisions/0015-social-actions-on-posts.md
··· 1 + # 0015 — Reader social actions write `app.bsky.feed.*` (not `site.standard.*`) 2 + 3 + - **Status:** Accepted 4 + - **Date:** 2026-06-09 5 + - **Scope:** The first write-path feature on the *reading* side — like / repost / quote / 6 + reply on a published article (design 7 + `docs/superpowers/specs/2026-06-09-social-actions-on-posts-design.md`). 8 + 9 + ## Context 10 + 11 + The brief asked whether reader replies could be **unlimited-length, block-editor-authored**, 12 + and written as a `site.standard.*` record (the lexicon family used for publications + 13 + documents), while *also* appearing as a reply under the companion Bluesky post that publish 14 + already creates (Decision 0005 / 0013). 15 + 16 + Research against the canonical lexicons (2026-06-09) showed these goals are mutually 17 + exclusive: 18 + 19 + 1. **Only `app.bsky.*` records thread / federate on Bluesky.** The Bluesky AppView is a 20 + Lexicon-specific indexer; it materialises only `app.bsky.*` collections into threads, 21 + like/repost counts, and notifications. A `site.standard.*` (or any non-`app.bsky`) record 22 + is never indexed into a bsky thread — which is exactly why `site.standard` has **no** 23 + comment/reply type and instead carries `bskyPostRef` to delegate comments to a companion 24 + `app.bsky.feed.post`. 25 + 2. **`app.bsky.feed.post.text` is hard-capped at `maxGraphemes: 300`.** There is no 26 + sanctioned threaded longform — whitewind / leaflet longform records don't thread either; 27 + they live off-thread behind a short companion post. 28 + 29 + ## Decision 30 + 31 + Reader actions are **native `app.bsky.feed.*` records targeting the article's companion 32 + post** by its strongRef (`bskyPostRef`, Decision 0013). A reply that should show on Bluesky 33 + **must** be an `app.bsky.feed.post`; it is therefore capped at 300 graphemes, and the block 34 + editor is **not** reused for replies — a plain composer with an `Intl.Segmenter` grapheme 35 + counter is used instead. 36 + 37 + - **like** → `app.bsky.feed.like` `{ subject, createdAt }` 38 + - **repost** → `app.bsky.feed.repost` `{ subject, createdAt }` 39 + - **reply** → `app.bsky.feed.post` `{ text, createdAt, reply: { root, parent } }` — the 40 + companion post is the thread origin, so `root === parent === bskyPostRef`. 41 + - **quote** → `app.bsky.feed.post` `{ text, createdAt, embed: { $type: 42 + 'app.bsky.embed.record', record: bskyPostRef } }` — a standalone post, not threaded. 43 + 44 + `unlike` / `unrepost` delete the viewer's own like/repost record (atproto has no native 45 + "unlike"); the record URI to delete comes from `app.bsky.feed.getPosts`' `viewer.like` / 46 + `viewer.repost`, which also drives counts + active button state. 47 + 48 + No SkyPress lexicon change — these are Bluesky-native records, not part of `site.standard.*` 49 + or `blog.skypress.*`. 50 + 51 + ## Consequences 52 + 53 + - **No new OAuth scope:** the existing `transition:generic` (`OAUTH_SCOPE`) already 54 + authorizes writing `app.bsky.feed.*`. Granular `repo:app.bsky.feed.*` scopes are a later 55 + tightening (Decision 0005's scope-narrowing note). 56 + - **Auth on the read path:** the article page mounts a `client:only` `PostActions` island 57 + wrapping the existing `AuthProvider`; the OAuth redirect returns to the article URL. The 58 + read path still imports **no** `@wordpress/*` (Decision 0003) — the island uses only 59 + `@atproto/api` + the OAuth client. 60 + - **No CSRF surface added:** all writes go browser → the reader's own PDS over OAuth/DPoP; 61 + there is no SkyPress server write endpoint. Rate-limiting / abuse is the PDS's concern. 62 + - **Legacy documents without `bskyPostRef`** (pre–Decision 0013, or a failed third write) 63 + render no action bar — there is no Bluesky thread to act on. 64 + - **"Don't surprise users" (brief §10):** the UI states, in both signed-out and signed-in 65 + states, that actions are public and happen on Bluesky. 66 + 67 + ## Out of scope (v1) 68 + 69 + Inline rendering of the reply thread (we link out to Bluesky), rich-text facets (clickable 70 + links/mentions) in replies, and the granular `repo:app.bsky.feed.*` scopes.
+55
src/components/PostActions.test.tsx
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { createElement } from 'react'; 3 + import { renderToStaticMarkup } from 'react-dom/server'; 4 + import { ActionsGate } from './PostActions'; 5 + import { AuthContext, type AuthContextValue } from '../lib/auth/AuthProvider'; 6 + 7 + const PROPS = { postUri: 'at://did:plc:writer/app.bsky.feed.post/3kpost', postCid: 'bafypost' }; 8 + 9 + /** Render ActionsGate under a hand-rolled auth context (bypasses real OAuth). */ 10 + function renderGate( auth: Partial< AuthContextValue > ): string { 11 + const value: AuthContextValue = { 12 + status: 'signed-out', 13 + agent: null, 14 + did: null, 15 + handle: null, 16 + displayName: null, 17 + avatar: null, 18 + pdsUrl: null, 19 + error: null, 20 + signIn: async () => {}, 21 + signOut: async () => {}, 22 + ...auth, 23 + }; 24 + return renderToStaticMarkup( 25 + createElement( AuthContext.Provider, { value }, createElement( ActionsGate, PROPS ) ) 26 + ); 27 + } 28 + 29 + describe( 'PostActions render branches', () => { 30 + it( 'shows a loading state while auth initialises', () => { 31 + expect( renderGate( { status: 'loading' } ) ).toContain( 'Loading actions…' ); 32 + } ); 33 + 34 + it( 'prompts sign-in (with the public-actions note) when signed out', () => { 35 + const markup = renderGate( { status: 'signed-out' } ); 36 + expect( markup ).toContain( 'Sign in to react' ); 37 + expect( markup.toLowerCase() ).toContain( 'public' ); 38 + // Even signed-out, the thread is linkable on Bluesky. 39 + expect( markup ).toContain( 'bsky.app/profile/did:plc:writer/post/3kpost' ); 40 + } ); 41 + 42 + it( 'renders the four actions when signed in', () => { 43 + const markup = renderGate( { 44 + status: 'signed-in', 45 + agent: {} as never, 46 + did: 'did:plc:reader', 47 + handle: 'reader.test', 48 + } ); 49 + expect( markup ).toContain( 'Like' ); 50 + expect( markup ).toContain( 'Repost' ); 51 + expect( markup ).toContain( 'Quote' ); 52 + expect( markup ).toContain( 'Reply' ); 53 + expect( markup.toLowerCase() ).toContain( 'public' ); 54 + } ); 55 + } );
+328
src/components/PostActions.tsx
··· 1 + import { useEffect, useRef, useState } from 'react'; 2 + import { AuthProvider } from '../lib/auth/AuthProvider'; 3 + import { useAuth } from '../lib/auth/useAuth'; 4 + import { 5 + like, 6 + unlike, 7 + repost, 8 + unrepost, 9 + postReply, 10 + postQuote, 11 + fetchPostState, 12 + type PostState, 13 + } from '../lib/social/interactions'; 14 + import { 15 + graphemeLength, 16 + validateReplyText, 17 + bskyPostWebUrl, 18 + MAX_REPLY_GRAPHEMES, 19 + type StrongRef, 20 + } from '../lib/social/records'; 21 + 22 + export interface PostActionsProps { 23 + /** The companion `app.bsky.feed.post` AT-URI (the article's Bluesky post). */ 24 + postUri: string; 25 + /** Its cid — together with `postUri` it forms the strongRef every action targets. */ 26 + postCid: string; 27 + } 28 + 29 + type Composer = 'reply' | 'quote' | null; 30 + 31 + /** A composed reply/quote in flight, surfaced as a success or error banner. */ 32 + interface Flash { 33 + kind: 'success' | 'error'; 34 + message: string; 35 + /** For a success, the bsky.app link to the freshly created post. */ 36 + href?: string; 37 + } 38 + 39 + /** 40 + * The signed-in / signed-out action bar. Renders inside <AuthProvider>; reads the 41 + * companion post's counts + the viewer's like/repost state, and writes like / repost / 42 + * reply / quote records to the reader's own PDS. 43 + */ 44 + export function ActionsGate( { postUri, postCid }: PostActionsProps ) { 45 + const { status, agent, did, signIn, error: authError } = useAuth(); 46 + const subject: StrongRef = { uri: postUri, cid: postCid }; 47 + 48 + const [ state, setState ] = useState< PostState | null >( null ); 49 + const [ busy, setBusy ] = useState< null | 'like' | 'repost' >( null ); 50 + const [ composer, setComposer ] = useState< Composer >( null ); 51 + const [ text, setText ] = useState( '' ); 52 + const [ submitting, setSubmitting ] = useState( false ); 53 + const [ flash, setFlash ] = useState< Flash | null >( null ); 54 + const [ handle, setHandle ] = useState( '' ); 55 + 56 + // Guards a late `refresh()` setState after unmount (refresh runs from event 57 + // handlers, so it can't rely on an effect cleanup like the mount load below). 58 + const mounted = useRef( true ); 59 + useEffect( () => () => { 60 + mounted.current = false; 61 + }, [] ); 62 + 63 + // Load counts + viewer state once signed in (and refresh after each write). 64 + useEffect( () => { 65 + if ( status !== 'signed-in' || ! agent ) { 66 + return; 67 + } 68 + let cancelled = false; 69 + fetchPostState( agent, postUri ) 70 + .then( ( next ) => ! cancelled && next && setState( next ) ) 71 + .catch( () => { 72 + /* counts are best-effort; the action buttons still work */ 73 + } ); 74 + return () => { 75 + cancelled = true; 76 + }; 77 + }, [ status, agent, postUri ] ); 78 + 79 + const refresh = () => { 80 + if ( agent ) { 81 + fetchPostState( agent, postUri ) 82 + .then( ( next ) => mounted.current && next && setState( next ) ) 83 + .catch( () => {} ); 84 + } 85 + }; 86 + 87 + const onToggleLike = async () => { 88 + if ( ! agent || ! did || busy ) { 89 + return; 90 + } 91 + setBusy( 'like' ); 92 + setFlash( null ); 93 + try { 94 + if ( state?.viewerLikeUri ) { 95 + await unlike( agent, did, state.viewerLikeUri ); 96 + } else { 97 + await like( agent, did, subject ); 98 + } 99 + refresh(); 100 + } catch ( err ) { 101 + setFlash( { kind: 'error', message: errorText( err, 'Could not update your like.' ) } ); 102 + } finally { 103 + setBusy( null ); 104 + } 105 + }; 106 + 107 + const onToggleRepost = async () => { 108 + if ( ! agent || ! did || busy ) { 109 + return; 110 + } 111 + setBusy( 'repost' ); 112 + setFlash( null ); 113 + try { 114 + if ( state?.viewerRepostUri ) { 115 + await unrepost( agent, did, state.viewerRepostUri ); 116 + } else { 117 + await repost( agent, did, subject ); 118 + } 119 + refresh(); 120 + } catch ( err ) { 121 + setFlash( { kind: 'error', message: errorText( err, 'Could not update your repost.' ) } ); 122 + } finally { 123 + setBusy( null ); 124 + } 125 + }; 126 + 127 + const openComposer = ( mode: Composer ) => { 128 + setComposer( mode ); 129 + setText( '' ); 130 + setFlash( null ); 131 + }; 132 + 133 + const onSubmitComposer = async () => { 134 + if ( ! agent || ! did || ! composer || submitting ) { 135 + return; 136 + } 137 + if ( ! validateReplyText( text ).ok ) { 138 + return; 139 + } 140 + setSubmitting( true ); 141 + try { 142 + const action = composer === 'reply' ? postReply : postQuote; 143 + const uri = await action( agent, did, { text, subject } ); 144 + setComposer( null ); 145 + setText( '' ); 146 + setFlash( { 147 + kind: 'success', 148 + message: composer === 'reply' ? 'Reply posted to Bluesky.' : 'Quote posted to Bluesky.', 149 + href: bskyPostWebUrl( uri ), 150 + } ); 151 + refresh(); 152 + } catch ( err ) { 153 + setFlash( { kind: 'error', message: errorText( err, 'Could not post to Bluesky.' ) } ); 154 + } finally { 155 + setSubmitting( false ); 156 + } 157 + }; 158 + 159 + if ( status === 'loading' ) { 160 + return <div className="post-actions post-actions--loading">Loading actions…</div>; 161 + } 162 + 163 + const threadUrl = bskyPostWebUrl( postUri ); 164 + 165 + // Signed-out: prompt sign-in (same OAuth flow as the editor) before any action. 166 + if ( status !== 'signed-in' || ! agent || ! did ) { 167 + return ( 168 + <div className="post-actions"> 169 + <p className="post-actions__note"> 170 + Sign in with your AT Protocol account to like, repost, or reply. Actions are 171 + public and happen on Bluesky. 172 + </p> 173 + <form 174 + className="post-actions__signin" 175 + onSubmit={ ( event ) => { 176 + event.preventDefault(); 177 + void signIn( handle ); 178 + } } 179 + > 180 + <input 181 + className="post-actions__signin-input" 182 + aria-label="Your handle, DID, or PDS URL" 183 + autoComplete="username" 184 + autoCapitalize="none" 185 + autoCorrect="off" 186 + spellCheck={ false } 187 + placeholder="alice.bsky.social" 188 + value={ handle } 189 + onChange={ ( event ) => setHandle( event.target.value ) } 190 + /> 191 + <button className="post-actions__signin-btn" type="submit"> 192 + Sign in to react 193 + </button> 194 + </form> 195 + { status === 'error' && authError && ( 196 + <p className="post-actions__error" role="alert"> 197 + { authError } 198 + </p> 199 + ) } 200 + <a className="post-actions__thread" href={ threadUrl } target="_blank" rel="noopener noreferrer"> 201 + View this post on Bluesky 202 + </a> 203 + </div> 204 + ); 205 + } 206 + 207 + const liked = Boolean( state?.viewerLikeUri ); 208 + const reposted = Boolean( state?.viewerRepostUri ); 209 + const validity = validateReplyText( text ); 210 + const graphemes = graphemeLength( text ); 211 + 212 + return ( 213 + <div className="post-actions"> 214 + <div className="post-actions__bar" role="group" aria-label="Post actions"> 215 + <button 216 + type="button" 217 + className={ `post-actions__btn${ liked ? ' is-active' : '' }` } 218 + aria-pressed={ liked } 219 + disabled={ busy === 'like' } 220 + onClick={ onToggleLike } 221 + > 222 + { liked ? '♥' : '♡' } Like{ countLabel( state?.likeCount ) } 223 + </button> 224 + <button 225 + type="button" 226 + className={ `post-actions__btn${ reposted ? ' is-active' : '' }` } 227 + aria-pressed={ reposted } 228 + disabled={ busy === 'repost' } 229 + onClick={ onToggleRepost } 230 + > 231 + ⇄ Repost{ countLabel( state?.repostCount ) } 232 + </button> 233 + <button type="button" className="post-actions__btn" onClick={ () => openComposer( 'quote' ) }> 234 + ❝ Quote 235 + </button> 236 + <button type="button" className="post-actions__btn" onClick={ () => openComposer( 'reply' ) }> 237 + ↩ Reply{ countLabel( state?.replyCount ) } 238 + </button> 239 + </div> 240 + 241 + { composer && ( 242 + <div className="post-actions__composer"> 243 + <label className="post-actions__composer-label" htmlFor="post-actions-text"> 244 + { composer === 'reply' ? 'Your reply' : 'Add a comment to your quote' } 245 + </label> 246 + <textarea 247 + id="post-actions-text" 248 + className="post-actions__textarea" 249 + rows={ 4 } 250 + placeholder={ composer === 'reply' ? 'Write a reply…' : 'Optional comment…' } 251 + value={ text } 252 + onChange={ ( event ) => setText( event.target.value ) } 253 + /> 254 + <div className="post-actions__composer-foot"> 255 + <span 256 + className={ `post-actions__count${ 257 + graphemes > MAX_REPLY_GRAPHEMES ? ' is-over' : '' 258 + }` } 259 + > 260 + { graphemes } / { MAX_REPLY_GRAPHEMES } 261 + </span> 262 + <div className="post-actions__composer-btns"> 263 + <button 264 + type="button" 265 + className="post-actions__btn" 266 + onClick={ () => setComposer( null ) } 267 + disabled={ submitting } 268 + > 269 + Cancel 270 + </button> 271 + <button 272 + type="button" 273 + className="post-actions__submit" 274 + onClick={ onSubmitComposer } 275 + disabled={ submitting || ! validity.ok } 276 + > 277 + { submitting 278 + ? 'Posting…' 279 + : composer === 'reply' 280 + ? 'Post reply' 281 + : 'Post quote' } 282 + </button> 283 + </div> 284 + </div> 285 + </div> 286 + ) } 287 + 288 + { flash && ( 289 + <p 290 + className={ `post-actions__flash post-actions__flash--${ flash.kind }` } 291 + role={ flash.kind === 'error' ? 'alert' : 'status' } 292 + > 293 + { flash.message }{ ' ' } 294 + { flash.href && ( 295 + <a href={ flash.href } target="_blank" rel="noopener noreferrer"> 296 + View on Bluesky 297 + </a> 298 + ) } 299 + </p> 300 + ) } 301 + 302 + <p className="post-actions__note"> 303 + Likes, reposts, quotes, and replies are public and happen on Bluesky. 304 + </p> 305 + <a className="post-actions__thread" href={ threadUrl } target="_blank" rel="noopener noreferrer"> 306 + View the full thread on Bluesky 307 + </a> 308 + </div> 309 + ); 310 + } 311 + 312 + /** A `(n)` suffix for a button label, omitted when the count is zero/unknown. */ 313 + function countLabel( count: number | undefined ): string { 314 + return count && count > 0 ? ` (${ count })` : ''; 315 + } 316 + 317 + function errorText( err: unknown, fallback: string ): string { 318 + const detail = err instanceof Error ? err.message : ''; 319 + return detail ? `${ fallback } ${ detail }` : fallback; 320 + } 321 + 322 + export default function PostActions( props: PostActionsProps ) { 323 + return ( 324 + <AuthProvider> 325 + <ActionsGate { ...props } /> 326 + </AuthProvider> 327 + ); 328 + }
+151
src/lib/social/interactions.test.ts
··· 1 + import { describe, expect, it, vi } from 'vitest'; 2 + import { 3 + like, 4 + unlike, 5 + repost, 6 + unrepost, 7 + postReply, 8 + postQuote, 9 + fetchPostState, 10 + } from './interactions'; 11 + import type { StrongRef } from './records'; 12 + 13 + const DID = 'did:plc:reader'; 14 + const SUBJECT: StrongRef = { 15 + uri: 'at://did:plc:writer/app.bsky.feed.post/3kpost', 16 + cid: 'bafypost', 17 + }; 18 + 19 + /** A minimal mock of the `@atproto/api` Agent surface these helpers touch. */ 20 + function mockAgent( overrides: Record< string, unknown > = {} ) { 21 + const createRecord = vi.fn( async () => ( { 22 + data: { uri: 'at://did:plc:reader/app.bsky.feed.like/3knew', cid: 'bafynew' }, 23 + } ) ); 24 + const deleteRecord = vi.fn( async () => ( {} ) ); 25 + const getPosts = vi.fn( async () => ( { 26 + data: { 27 + posts: [ 28 + { 29 + uri: SUBJECT.uri, 30 + cid: SUBJECT.cid, 31 + likeCount: 3, 32 + repostCount: 1, 33 + replyCount: 2, 34 + viewer: { 35 + like: 'at://did:plc:reader/app.bsky.feed.like/3kexisting', 36 + repost: undefined, 37 + }, 38 + }, 39 + ], 40 + }, 41 + } ) ); 42 + return { 43 + com: { atproto: { repo: { createRecord, deleteRecord } } }, 44 + app: { bsky: { feed: { getPosts } } }, 45 + ...overrides, 46 + // expose the spies for assertions 47 + _spies: { createRecord, deleteRecord, getPosts }, 48 + } as never; 49 + } 50 + 51 + describe( 'like', () => { 52 + it( 'creates an app.bsky.feed.like in the reader repo and returns its uri', async () => { 53 + const agent = mockAgent(); 54 + const uri = await like( agent, DID, SUBJECT ); 55 + const { createRecord } = ( agent as never as { _spies: { createRecord: ReturnType< typeof vi.fn > } } )._spies; 56 + expect( createRecord ).toHaveBeenCalledTimes( 1 ); 57 + const arg = createRecord.mock.calls[ 0 ][ 0 ]; 58 + expect( arg.repo ).toBe( DID ); 59 + expect( arg.collection ).toBe( 'app.bsky.feed.like' ); 60 + expect( arg.record.$type ).toBe( 'app.bsky.feed.like' ); 61 + expect( arg.record.subject ).toEqual( SUBJECT ); 62 + expect( typeof arg.record.createdAt ).toBe( 'string' ); 63 + expect( uri ).toBe( 'at://did:plc:reader/app.bsky.feed.like/3knew' ); 64 + } ); 65 + } ); 66 + 67 + describe( 'unlike', () => { 68 + it( 'deletes the like record by the rkey parsed from its uri', async () => { 69 + const agent = mockAgent(); 70 + await unlike( agent, DID, 'at://did:plc:reader/app.bsky.feed.like/3kexisting' ); 71 + const { deleteRecord } = ( agent as never as { _spies: { deleteRecord: ReturnType< typeof vi.fn > } } )._spies; 72 + expect( deleteRecord ).toHaveBeenCalledWith( { 73 + repo: DID, 74 + collection: 'app.bsky.feed.like', 75 + rkey: '3kexisting', 76 + } ); 77 + } ); 78 + } ); 79 + 80 + describe( 'repost / unrepost', () => { 81 + it( 'creates an app.bsky.feed.repost', async () => { 82 + const agent = mockAgent(); 83 + await repost( agent, DID, SUBJECT ); 84 + const { createRecord } = ( agent as never as { _spies: { createRecord: ReturnType< typeof vi.fn > } } )._spies; 85 + const arg = createRecord.mock.calls[ 0 ][ 0 ]; 86 + expect( arg.collection ).toBe( 'app.bsky.feed.repost' ); 87 + expect( arg.record.$type ).toBe( 'app.bsky.feed.repost' ); 88 + expect( arg.record.subject ).toEqual( SUBJECT ); 89 + } ); 90 + 91 + it( 'deletes the repost by rkey', async () => { 92 + const agent = mockAgent(); 93 + await unrepost( agent, DID, 'at://did:plc:reader/app.bsky.feed.repost/3krp' ); 94 + const { deleteRecord } = ( agent as never as { _spies: { deleteRecord: ReturnType< typeof vi.fn > } } )._spies; 95 + expect( deleteRecord ).toHaveBeenCalledWith( { 96 + repo: DID, 97 + collection: 'app.bsky.feed.repost', 98 + rkey: '3krp', 99 + } ); 100 + } ); 101 + } ); 102 + 103 + describe( 'postReply', () => { 104 + it( 'creates a post threaded under the companion post', async () => { 105 + const agent = mockAgent(); 106 + await postReply( agent, DID, { text: 'great read', subject: SUBJECT } ); 107 + const { createRecord } = ( agent as never as { _spies: { createRecord: ReturnType< typeof vi.fn > } } )._spies; 108 + const arg = createRecord.mock.calls[ 0 ][ 0 ]; 109 + expect( arg.collection ).toBe( 'app.bsky.feed.post' ); 110 + expect( arg.record.text ).toBe( 'great read' ); 111 + expect( arg.record.reply ).toEqual( { root: SUBJECT, parent: SUBJECT } ); 112 + } ); 113 + } ); 114 + 115 + describe( 'postQuote', () => { 116 + it( 'creates a standalone post embedding the companion post', async () => { 117 + const agent = mockAgent(); 118 + await postQuote( agent, DID, { text: 'worth a read', subject: SUBJECT } ); 119 + const { createRecord } = ( agent as never as { _spies: { createRecord: ReturnType< typeof vi.fn > } } )._spies; 120 + const arg = createRecord.mock.calls[ 0 ][ 0 ]; 121 + expect( arg.collection ).toBe( 'app.bsky.feed.post' ); 122 + expect( arg.record.embed ).toEqual( { 123 + $type: 'app.bsky.embed.record', 124 + record: SUBJECT, 125 + } ); 126 + expect( arg.record.reply ).toBeUndefined(); 127 + } ); 128 + } ); 129 + 130 + describe( 'fetchPostState', () => { 131 + it( 'returns counts and the viewer like/repost rkey uris', async () => { 132 + const agent = mockAgent(); 133 + const state = await fetchPostState( agent, SUBJECT.uri ); 134 + const { getPosts } = ( agent as never as { _spies: { getPosts: ReturnType< typeof vi.fn > } } )._spies; 135 + expect( getPosts ).toHaveBeenCalledWith( { uris: [ SUBJECT.uri ] } ); 136 + expect( state ).toEqual( { 137 + likeCount: 3, 138 + repostCount: 1, 139 + replyCount: 2, 140 + viewerLikeUri: 'at://did:plc:reader/app.bsky.feed.like/3kexisting', 141 + viewerRepostUri: null, 142 + } ); 143 + } ); 144 + 145 + it( 'returns null when the post is not found', async () => { 146 + const agent = mockAgent( { 147 + app: { bsky: { feed: { getPosts: vi.fn( async () => ( { data: { posts: [] } } ) ) } } }, 148 + } ); 149 + expect( await fetchPostState( agent, SUBJECT.uri ) ).toBeNull(); 150 + } ); 151 + } );
+130
src/lib/social/interactions.ts
··· 1 + /** 2 + * Agent orchestration for reader social actions: create/delete the like, repost, reply, 3 + * and quote records on the reader's own PDS, and read the companion post's counts + 4 + * viewer state from the Bluesky AppView. 5 + * 6 + * All writes go browser → the reader's PDS over the authenticated OAuth agent (Decision 7 + * 0004). Record-shaping is delegated to the pure builders in `records.ts`. 8 + */ 9 + import type { Agent } from '@atproto/api'; 10 + import { 11 + buildLike, 12 + buildRepost, 13 + buildReply, 14 + buildQuote, 15 + type StrongRef, 16 + } from './records'; 17 + 18 + const LIKE_COLLECTION = 'app.bsky.feed.like'; 19 + const REPOST_COLLECTION = 'app.bsky.feed.repost'; 20 + const POST_COLLECTION = 'app.bsky.feed.post'; 21 + 22 + /** createRecord types `record` as an open index signature; our records are precise. */ 23 + function asRecord( value: object ): Record< string, unknown > { 24 + return value as Record< string, unknown >; 25 + } 26 + 27 + /** rkey is the last segment of an AT-URI. */ 28 + function rkeyFromUri( uri: string ): string { 29 + return uri.split( '/' ).pop() ?? ''; 30 + } 31 + 32 + /** Like the companion post; returns the created like record's AT-URI (for unliking). */ 33 + export async function like( agent: Agent, did: string, subject: StrongRef ): Promise< string > { 34 + const res = await agent.com.atproto.repo.createRecord( { 35 + repo: did, 36 + collection: LIKE_COLLECTION, 37 + record: asRecord( buildLike( subject, new Date().toISOString() ) ), 38 + } ); 39 + return res.data.uri; 40 + } 41 + 42 + /** Remove a like by deleting its record (atproto has no native "unlike"). */ 43 + export async function unlike( agent: Agent, did: string, likeUri: string ): Promise< void > { 44 + await agent.com.atproto.repo.deleteRecord( { 45 + repo: did, 46 + collection: LIKE_COLLECTION, 47 + rkey: rkeyFromUri( likeUri ), 48 + } ); 49 + } 50 + 51 + /** Repost the companion post; returns the created repost record's AT-URI. */ 52 + export async function repost( agent: Agent, did: string, subject: StrongRef ): Promise< string > { 53 + const res = await agent.com.atproto.repo.createRecord( { 54 + repo: did, 55 + collection: REPOST_COLLECTION, 56 + record: asRecord( buildRepost( subject, new Date().toISOString() ) ), 57 + } ); 58 + return res.data.uri; 59 + } 60 + 61 + /** Undo a repost by deleting its record. */ 62 + export async function unrepost( agent: Agent, did: string, repostUri: string ): Promise< void > { 63 + await agent.com.atproto.repo.deleteRecord( { 64 + repo: did, 65 + collection: REPOST_COLLECTION, 66 + rkey: rkeyFromUri( repostUri ), 67 + } ); 68 + } 69 + 70 + /** Reply to the companion post; returns the created post's AT-URI. */ 71 + export async function postReply( 72 + agent: Agent, 73 + did: string, 74 + input: { text: string; subject: StrongRef } 75 + ): Promise< string > { 76 + const res = await agent.com.atproto.repo.createRecord( { 77 + repo: did, 78 + collection: POST_COLLECTION, 79 + record: asRecord( 80 + buildReply( { text: input.text, subject: input.subject, createdAt: new Date().toISOString() } ) 81 + ), 82 + } ); 83 + return res.data.uri; 84 + } 85 + 86 + /** Quote-repost the companion post; returns the created post's AT-URI. */ 87 + export async function postQuote( 88 + agent: Agent, 89 + did: string, 90 + input: { text: string; subject: StrongRef } 91 + ): Promise< string > { 92 + const res = await agent.com.atproto.repo.createRecord( { 93 + repo: did, 94 + collection: POST_COLLECTION, 95 + record: asRecord( 96 + buildQuote( { text: input.text, subject: input.subject, createdAt: new Date().toISOString() } ) 97 + ), 98 + } ); 99 + return res.data.uri; 100 + } 101 + 102 + export interface PostState { 103 + likeCount: number; 104 + repostCount: number; 105 + replyCount: number; 106 + /** The viewer's own like record URI, or null if they haven't liked it. */ 107 + viewerLikeUri: string | null; 108 + /** The viewer's own repost record URI, or null if they haven't reposted it. */ 109 + viewerRepostUri: string | null; 110 + } 111 + 112 + /** 113 + * Read the companion post's like/repost/reply counts and the viewer's own like/repost 114 + * record URIs (so the UI can show active state and toggle by deleting the right record). 115 + * Returns null if the post can't be found (e.g. deleted, or not yet indexed). 116 + */ 117 + export async function fetchPostState( agent: Agent, postUri: string ): Promise< PostState | null > { 118 + const res = await agent.app.bsky.feed.getPosts( { uris: [ postUri ] } ); 119 + const post = res.data.posts[ 0 ]; 120 + if ( ! post ) { 121 + return null; 122 + } 123 + return { 124 + likeCount: post.likeCount ?? 0, 125 + repostCount: post.repostCount ?? 0, 126 + replyCount: post.replyCount ?? 0, 127 + viewerLikeUri: post.viewer?.like ?? null, 128 + viewerRepostUri: post.viewer?.repost ?? null, 129 + }; 130 + }
+126
src/lib/social/records.test.ts
··· 1 + import { describe, expect, it } from 'vitest'; 2 + import { 3 + buildLike, 4 + buildRepost, 5 + buildReply, 6 + buildQuote, 7 + graphemeLength, 8 + validateReplyText, 9 + bskyPostWebUrl, 10 + MAX_REPLY_GRAPHEMES, 11 + type StrongRef, 12 + } from './records'; 13 + 14 + const SUBJECT: StrongRef = { 15 + uri: 'at://did:plc:writer/app.bsky.feed.post/3kpost', 16 + cid: 'bafypost', 17 + }; 18 + const CREATED_AT = '2026-06-09T12:00:00.000Z'; 19 + 20 + describe( 'buildLike', () => { 21 + it( 'emits an app.bsky.feed.like targeting the subject', () => { 22 + expect( buildLike( SUBJECT, CREATED_AT ) ).toEqual( { 23 + $type: 'app.bsky.feed.like', 24 + subject: { uri: SUBJECT.uri, cid: SUBJECT.cid }, 25 + createdAt: CREATED_AT, 26 + } ); 27 + } ); 28 + } ); 29 + 30 + describe( 'buildRepost', () => { 31 + it( 'emits an app.bsky.feed.repost targeting the subject', () => { 32 + expect( buildRepost( SUBJECT, CREATED_AT ) ).toEqual( { 33 + $type: 'app.bsky.feed.repost', 34 + subject: { uri: SUBJECT.uri, cid: SUBJECT.cid }, 35 + createdAt: CREATED_AT, 36 + } ); 37 + } ); 38 + } ); 39 + 40 + describe( 'buildReply', () => { 41 + it( 'emits a post with reply.root and reply.parent both the companion post', () => { 42 + // The companion post is the thread origin, so root === parent === subject. 43 + expect( 44 + buildReply( { text: 'Nice piece!', subject: SUBJECT, createdAt: CREATED_AT } ) 45 + ).toEqual( { 46 + $type: 'app.bsky.feed.post', 47 + text: 'Nice piece!', 48 + createdAt: CREATED_AT, 49 + reply: { 50 + root: { uri: SUBJECT.uri, cid: SUBJECT.cid }, 51 + parent: { uri: SUBJECT.uri, cid: SUBJECT.cid }, 52 + }, 53 + } ); 54 + } ); 55 + 56 + it( 'does not carry an embed', () => { 57 + const record = buildReply( { text: 'hi', subject: SUBJECT, createdAt: CREATED_AT } ); 58 + expect( record ).not.toHaveProperty( 'embed' ); 59 + } ); 60 + } ); 61 + 62 + describe( 'buildQuote', () => { 63 + it( 'emits a standalone post embedding the subject via app.bsky.embed.record', () => { 64 + expect( 65 + buildQuote( { text: 'Worth a read:', subject: SUBJECT, createdAt: CREATED_AT } ) 66 + ).toEqual( { 67 + $type: 'app.bsky.feed.post', 68 + text: 'Worth a read:', 69 + createdAt: CREATED_AT, 70 + embed: { 71 + $type: 'app.bsky.embed.record', 72 + record: { uri: SUBJECT.uri, cid: SUBJECT.cid }, 73 + }, 74 + } ); 75 + } ); 76 + 77 + it( 'is not a reply (no reply field) — a quote is a standalone post', () => { 78 + const record = buildQuote( { text: 'hi', subject: SUBJECT, createdAt: CREATED_AT } ); 79 + expect( record ).not.toHaveProperty( 'reply' ); 80 + } ); 81 + 82 + it( 'allows empty commentary text', () => { 83 + expect( buildQuote( { text: '', subject: SUBJECT, createdAt: CREATED_AT } ).text ).toBe( '' ); 84 + } ); 85 + } ); 86 + 87 + describe( 'graphemeLength', () => { 88 + it( 'counts plain ASCII by character', () => { 89 + expect( graphemeLength( 'hello' ) ).toBe( 5 ); 90 + expect( graphemeLength( '' ) ).toBe( 0 ); 91 + } ); 92 + 93 + it( 'counts a multi-codepoint emoji as one grapheme', () => { 94 + // Family emoji = several codepoints joined with ZWJ, but one user-perceived character. 95 + expect( graphemeLength( '👨‍👩‍👧‍👦' ) ).toBe( 1 ); 96 + // Flag = two regional-indicator codepoints, one grapheme. 97 + expect( graphemeLength( '🇫🇷' ) ).toBe( 1 ); 98 + } ); 99 + } ); 100 + 101 + describe( 'bskyPostWebUrl', () => { 102 + it( 'maps an at:// post uri to its bsky.app profile/post URL', () => { 103 + expect( bskyPostWebUrl( SUBJECT.uri ) ).toBe( 104 + 'https://bsky.app/profile/did:plc:writer/post/3kpost' 105 + ); 106 + } ); 107 + 108 + it( 'falls back to bsky.app for an unparseable uri', () => { 109 + expect( bskyPostWebUrl( 'not-an-at-uri' ) ).toBe( 'https://bsky.app' ); 110 + } ); 111 + } ); 112 + 113 + describe( 'validateReplyText', () => { 114 + it( 'rejects empty / whitespace-only text', () => { 115 + expect( validateReplyText( '' ).ok ).toBe( false ); 116 + expect( validateReplyText( ' \n ' ).ok ).toBe( false ); 117 + } ); 118 + 119 + it( 'accepts text up to the grapheme cap', () => { 120 + expect( validateReplyText( 'a'.repeat( MAX_REPLY_GRAPHEMES ) ).ok ).toBe( true ); 121 + } ); 122 + 123 + it( 'rejects text over the grapheme cap', () => { 124 + expect( validateReplyText( 'a'.repeat( MAX_REPLY_GRAPHEMES + 1 ) ).ok ).toBe( false ); 125 + } ); 126 + } );
+131
src/lib/social/records.ts
··· 1 + /** 2 + * Pure builders for the records a reader's social action writes: like, repost, reply, 3 + * and quote-repost (design 2026-06-09-social-actions-on-posts). 4 + * 5 + * No `@atproto/*` or network here — just record-shaping, so it's fully unit-testable. 6 + * Orchestration (createRecord/deleteRecord via the Agent) lives in `interactions.ts`. 7 + * 8 + * All four actions target the article's companion `app.bsky.feed.post` (Decision 0013) by 9 + * its `com.atproto.repo.strongRef`. Only `app.bsky.*` records thread/federate on Bluesky, 10 + * which is why replies are posts (not a `site.standard.*` record) and are capped at 300 11 + * graphemes — there is no threaded longform on Bluesky. 12 + */ 13 + 14 + /** A `com.atproto.repo.strongRef` — a URI plus its content-hash fingerprint. */ 15 + export interface StrongRef { 16 + uri: string; 17 + cid: string; 18 + } 19 + 20 + /** Bluesky's user-facing post-text limit is 300 graphemes (not bytes/codepoints). */ 21 + export const MAX_REPLY_GRAPHEMES = 300; 22 + 23 + export interface LikeRecord { 24 + $type: 'app.bsky.feed.like'; 25 + subject: StrongRef; 26 + createdAt: string; 27 + } 28 + 29 + export interface RepostRecord { 30 + $type: 'app.bsky.feed.repost'; 31 + subject: StrongRef; 32 + createdAt: string; 33 + } 34 + 35 + export interface ReplyRecord { 36 + $type: 'app.bsky.feed.post'; 37 + text: string; 38 + createdAt: string; 39 + reply: { root: StrongRef; parent: StrongRef }; 40 + } 41 + 42 + export interface QuoteRecord { 43 + $type: 'app.bsky.feed.post'; 44 + text: string; 45 + createdAt: string; 46 + embed: { $type: 'app.bsky.embed.record'; record: StrongRef }; 47 + } 48 + 49 + /** Strip a strongRef down to the exact `{ uri, cid }` shape the lexicon expects. */ 50 + function ref( subject: StrongRef ): StrongRef { 51 + return { uri: subject.uri, cid: subject.cid }; 52 + } 53 + 54 + /** `app.bsky.feed.like` over the companion post. */ 55 + export function buildLike( subject: StrongRef, createdAt: string ): LikeRecord { 56 + return { $type: 'app.bsky.feed.like', subject: ref( subject ), createdAt }; 57 + } 58 + 59 + /** `app.bsky.feed.repost` over the companion post. */ 60 + export function buildRepost( subject: StrongRef, createdAt: string ): RepostRecord { 61 + return { $type: 'app.bsky.feed.repost', subject: ref( subject ), createdAt }; 62 + } 63 + 64 + /** 65 + * A reply `app.bsky.feed.post`. The companion post is the thread origin, so both `root` 66 + * and `parent` are that post — a reader replies directly to the article's post. 67 + */ 68 + export function buildReply( input: { 69 + text: string; 70 + subject: StrongRef; 71 + createdAt: string; 72 + } ): ReplyRecord { 73 + return { 74 + $type: 'app.bsky.feed.post', 75 + text: input.text, 76 + createdAt: input.createdAt, 77 + reply: { root: ref( input.subject ), parent: ref( input.subject ) }, 78 + }; 79 + } 80 + 81 + /** 82 + * A quote-repost: a standalone `app.bsky.feed.post` embedding the companion post via 83 + * `app.bsky.embed.record`. Not a reply — it does not thread under the post. 84 + */ 85 + export function buildQuote( input: { 86 + text: string; 87 + subject: StrongRef; 88 + createdAt: string; 89 + } ): QuoteRecord { 90 + return { 91 + $type: 'app.bsky.feed.post', 92 + text: input.text, 93 + createdAt: input.createdAt, 94 + embed: { $type: 'app.bsky.embed.record', record: ref( input.subject ) }, 95 + }; 96 + } 97 + 98 + /** 99 + * The bsky.app web URL for a post AT-URI, for "view thread on Bluesky" links. 100 + * `at://<did>/app.bsky.feed.post/<rkey>` → `https://bsky.app/profile/<did>/post/<rkey>`. 101 + * Falls back to the bsky.app home for an unparseable URI. 102 + */ 103 + export function bskyPostWebUrl( postUri: string ): string { 104 + const match = postUri.match( /^at:\/\/([^/]+)\/app\.bsky\.feed\.post\/(.+)$/ ); 105 + return match ? `https://bsky.app/profile/${ match[ 1 ] }/post/${ match[ 2 ] }` : 'https://bsky.app'; 106 + } 107 + 108 + /** 109 + * Count user-perceived characters (grapheme clusters), matching Bluesky's 300-grapheme 110 + * rule. `Intl.Segmenter` is browser-native and counts ZWJ emoji / flags as one each, so 111 + * a family emoji costs one toward the cap — not the number of underlying codepoints. 112 + */ 113 + export function graphemeLength( text: string ): number { 114 + const segmenter = new Intl.Segmenter( undefined, { granularity: 'grapheme' } ); 115 + let count = 0; 116 + for ( const _ of segmenter.segment( text ) ) { 117 + count++; 118 + } 119 + return count; 120 + } 121 + 122 + export interface ReplyValidation { 123 + ok: boolean; 124 + graphemes: number; 125 + } 126 + 127 + /** A reply/quote body must be non-empty and within the grapheme cap. */ 128 + export function validateReplyText( text: string ): ReplyValidation { 129 + const graphemes = graphemeLength( text ); 130 + return { ok: text.trim().length > 0 && graphemes <= MAX_REPLY_GRAPHEMES, graphemes }; 131 + }
+27
src/pages/[author]/[slug]/[rkey].astro
··· 13 13 import ErrorScene from '../../../components/ErrorScene.astro'; 14 14 import { errorScene } from '../../../lib/reader/errors'; 15 15 import type { ErrorSceneCopy } from '../../../lib/reader/errors'; 16 + import PostActions from '../../../components/PostActions.tsx'; 16 17 17 18 // Frontend block styles only — no editor chrome, no JS. 18 19 import '@wordpress/block-library/build-style/common.css'; 19 20 import '@wordpress/block-library/build-style/style.css'; 20 21 import '@wordpress/block-library/build-style/theme.css'; 22 + // Social action bar (a client:only island; its chrome is styled globally). 23 + import '../../../styles/post-actions.css'; 21 24 22 25 // Read-through renderer: resolve + fetch at request time (Decision 0007). 23 26 export const prerender = false; ··· 30 33 updatedAt?: string; 31 34 site?: string; 32 35 content?: { blocks?: BlockNode[] }; 36 + /** strongRef to the companion `app.bsky.feed.post` — the target of reader actions. */ 37 + bskyPostRef?: { uri: string; cid: string }; 33 38 } 34 39 35 40 const { author, slug, rkey } = Astro.params; ··· 52 57 let updatedLabel: string | null = null; 53 58 let themeStyle = ''; 54 59 let ogImage = ''; 60 + let bskyPostRef: { uri: string; cid: string } | null = null; 55 61 56 62 if ( ! author || ! author.startsWith( '@' ) || ! slug || ! rkey ) { 57 63 error = errorScene( 'not-found' ); ··· 97 103 publishedLabel = doc.publishedAt ? doc.publishedAt.slice( 0, 10 ) : null; 98 104 updatedLabel = doc.updatedAt ? doc.updatedAt.slice( 0, 10 ) : null; 99 105 themeStyle = themeStyleBlock( publication.basicTheme ); 106 + // The companion Bluesky post is the target of every reader action; absent it 107 + // (legacy docs) there's no thread to act on, so the action bar is omitted. 108 + bskyPostRef = 109 + doc.bskyPostRef?.uri && doc.bskyPostRef?.cid ? doc.bskyPostRef : null; 100 110 101 111 // Share image: the publication logo, else the shared default. Only the 102 112 // default image has known 1200x630 dimensions; a square logo omits them. ··· 158 168 </p> 159 169 <h1 class="reader__title">{title}</h1> 160 170 <article class="reader__article" set:html={html} /> 171 + {bskyPostRef && ( 172 + <PostActions 173 + client:only="react" 174 + postUri={bskyPostRef.uri} 175 + postCid={bskyPostRef.cid} 176 + > 177 + <p slot="fallback" class="reader__actions-loading">Loading actions…</p> 178 + </PostActions> 179 + )} 161 180 <footer class="reader__foot"> 162 181 <a class="reader__author" href={pubUrl}>More from {publication!.name}</a> 163 182 </footer> ··· 245 264 margin-top: 3.5rem; 246 265 padding-top: 1.5rem; 247 266 border-top: 1px solid var(--line); 267 + } 268 + .reader__actions-loading { 269 + max-width: 40rem; 270 + margin: 2.5rem auto 0; 271 + padding: 1.5rem 1.5rem 0; 272 + border-top: 1px solid var(--line); 273 + color: var(--muted); 274 + font-size: 0.95rem; 248 275 } 249 276 </style>
+143
src/styles/post-actions.css
··· 1 + /** 2 + * Reader social-action bar (`PostActions`). The component is a `client:only` React 3 + * island mounted on the article page, so Astro's component-scoped styles never reach 4 + * its DOM — these rules must be global (imported from `[rkey].astro`). 5 + */ 6 + .post-actions { 7 + max-width: 40rem; 8 + margin: 2.5rem auto 0; 9 + padding: 1.5rem 1.5rem 0; 10 + border-top: 1px solid var(--line); 11 + } 12 + .post-actions--loading { 13 + color: var(--muted); 14 + font-size: 0.95rem; 15 + } 16 + .post-actions__bar { 17 + display: flex; 18 + flex-wrap: wrap; 19 + gap: 0.5rem; 20 + } 21 + .post-actions__btn { 22 + display: inline-flex; 23 + align-items: center; 24 + gap: 0.35rem; 25 + padding: 0.5rem 0.85rem; 26 + border: 1px solid var(--line-strong, var(--line)); 27 + border-radius: 999px; 28 + background: transparent; 29 + color: var(--ink); 30 + font: inherit; 31 + font-size: 0.95rem; 32 + cursor: pointer; 33 + } 34 + .post-actions__btn:hover { 35 + border-color: var(--sun); 36 + } 37 + .post-actions__btn:disabled { 38 + opacity: 0.55; 39 + cursor: default; 40 + } 41 + .post-actions__btn.is-active { 42 + border-color: var(--sun); 43 + color: var(--sun); 44 + } 45 + .post-actions__composer { 46 + margin-top: 1rem; 47 + } 48 + .post-actions__composer-label { 49 + display: block; 50 + font-size: 0.85rem; 51 + font-weight: 600; 52 + margin-bottom: 0.4rem; 53 + } 54 + .post-actions__textarea { 55 + width: 100%; 56 + box-sizing: border-box; 57 + padding: 0.7rem 0.8rem; 58 + border: 1px solid var(--line-strong, var(--line)); 59 + border-radius: 10px; 60 + font: inherit; 61 + resize: vertical; 62 + } 63 + .post-actions__composer-foot { 64 + display: flex; 65 + align-items: center; 66 + justify-content: space-between; 67 + margin-top: 0.6rem; 68 + gap: 0.75rem; 69 + } 70 + .post-actions__count { 71 + font-size: 0.85rem; 72 + color: var(--muted); 73 + font-variant-numeric: tabular-nums; 74 + } 75 + .post-actions__count.is-over { 76 + color: var(--ember, #c0392b); 77 + font-weight: 600; 78 + } 79 + .post-actions__composer-btns { 80 + display: flex; 81 + gap: 0.5rem; 82 + } 83 + .post-actions__submit { 84 + padding: 0.5rem 1rem; 85 + border: none; 86 + border-radius: 999px; 87 + background: var(--sun); 88 + color: var(--paper, #fff); 89 + font: inherit; 90 + font-weight: 600; 91 + cursor: pointer; 92 + } 93 + .post-actions__submit:disabled { 94 + opacity: 0.5; 95 + cursor: default; 96 + } 97 + .post-actions__signin { 98 + display: flex; 99 + flex-wrap: wrap; 100 + gap: 0.5rem; 101 + margin: 0.5rem 0; 102 + } 103 + .post-actions__signin-input { 104 + flex: 1 1 14rem; 105 + box-sizing: border-box; 106 + padding: 0.55rem 0.7rem; 107 + border: 1px solid var(--line-strong, var(--line)); 108 + border-radius: 8px; 109 + font: inherit; 110 + } 111 + .post-actions__signin-btn { 112 + padding: 0.55rem 1.1rem; 113 + border: none; 114 + border-radius: 999px; 115 + background: var(--sun); 116 + color: var(--paper, #fff); 117 + font: inherit; 118 + font-weight: 600; 119 + cursor: pointer; 120 + } 121 + .post-actions__note { 122 + margin: 0.9rem 0 0; 123 + font-size: 0.82rem; 124 + color: var(--muted); 125 + } 126 + .post-actions__error, 127 + .post-actions__flash--error { 128 + color: var(--ember, #c0392b); 129 + font-size: 0.9rem; 130 + } 131 + .post-actions__flash { 132 + margin: 0.9rem 0 0; 133 + font-size: 0.9rem; 134 + } 135 + .post-actions__flash--success { 136 + color: var(--ink-soft, var(--ink)); 137 + } 138 + .post-actions__thread { 139 + display: inline-block; 140 + margin-top: 0.75rem; 141 + color: var(--sun); 142 + font-size: 0.9rem; 143 + }