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 sky-phase theme picker to the publication form

+206 -3
+62
src/components/PublicationForm.test.tsx
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { createElement } from 'react'; 3 + import { renderToStaticMarkup } from 'react-dom/server'; 4 + import type { Agent } from '@atproto/api'; 5 + import PublicationForm from './PublicationForm'; 6 + import { THEME_PRESETS } from '../lib/publish/themes'; 7 + import type { Publication } from '../lib/publish/publications'; 8 + 9 + const baseProps = { 10 + agent: {} as Agent, 11 + did: 'did:plc:alice', 12 + pdsUrl: null, 13 + handle: 'alice.test', 14 + onSaved: () => {}, 15 + onCancel: () => {}, 16 + }; 17 + 18 + function renderForm( existing?: Publication ): string { 19 + return renderToStaticMarkup( createElement( PublicationForm, { ...baseProps, existing } ) ); 20 + } 21 + 22 + /** Every `<input type="radio">` tag in the markup. */ 23 + function radios( markup: string ): string[] { 24 + return markup.match( /<input[^>]*type="radio"[^>]*>/g ) ?? []; 25 + } 26 + 27 + /** The `value=""` of the single checked radio, or null. */ 28 + function checkedValue( markup: string ): string | null { 29 + const checked = radios( markup ).find( ( tag ) => /\schecked\b/.test( tag ) ); 30 + if ( ! checked ) { 31 + return null; 32 + } 33 + return checked.match( /value="([^"]*)"/ )?.[ 1 ] ?? null; 34 + } 35 + 36 + describe( 'PublicationForm theme picker', () => { 37 + it( 'renders a "no theme" option plus every preset as radios in one group', () => { 38 + const markup = renderForm(); 39 + expect( markup ).toContain( 'role="radiogroup"' ); 40 + expect( markup ).toContain( 'aria-label="Theme"' ); 41 + expect( radios( markup ) ).toHaveLength( THEME_PRESETS.length + 1 ); 42 + for ( const preset of THEME_PRESETS ) { 43 + expect( markup ).toContain( `value="${ preset.slug }"` ); 44 + expect( markup ).toContain( preset.label ); 45 + } 46 + } ); 47 + 48 + it( 'defaults a new publication to "no theme" (empty value checked)', () => { 49 + expect( checkedValue( renderForm() ) ).toBe( '' ); 50 + } ); 51 + 52 + it( 'pre-selects the matching preset when editing a themed publication', () => { 53 + const existing: Publication = { 54 + uri: 'at://x', 55 + rkey: 'r', 56 + slug: 'blog', 57 + name: 'Blog', 58 + basicTheme: THEME_PRESETS[ 2 ].colors, // dusk 59 + }; 60 + expect( checkedValue( renderForm( existing ) ) ).toBe( 'dusk' ); 61 + } ); 62 + } );
+63
src/components/PublicationForm.tsx
··· 11 11 PUBLICATION_ICON_MAX_BYTES, 12 12 } from '../lib/media/uploadImage'; 13 13 import { buildGetBlobUrl, type BlobRefJson } from '../lib/media/blob'; 14 + import { THEME_PRESETS, findPresetByColors, type Rgb } from '../lib/publish/themes'; 14 15 15 16 interface Props { 16 17 agent: Agent; ··· 47 48 existing?.icon && pdsUrl 48 49 ? buildGetBlobUrl( pdsUrl, did, existing.icon.ref.$link ) 49 50 : null 51 + ); 52 + const [ themeSlug, setThemeSlug ] = useState< string | null >( 53 + () => findPresetByColors( existing?.basicTheme )?.slug ?? null 50 54 ); 51 55 const [ uploading, setUploading ] = useState( false ); 52 56 const [ saving, setSaving ] = useState( false ); ··· 102 106 setSaving( true ); 103 107 setError( null ); 104 108 try { 109 + const selectedPreset = THEME_PRESETS.find( ( preset ) => preset.slug === themeSlug ); 105 110 const input = { 106 111 name: name.trim(), 107 112 description: description.trim() || undefined, 108 113 icon: icon ?? undefined, 114 + basicTheme: selectedPreset?.colors, 109 115 }; 110 116 const saved = existing 111 117 ? await updatePublication( agent, did, handle, existing, input ) ··· 168 174 disabled={ saving } 169 175 /> 170 176 </label> 177 + 178 + <fieldset className="pubform__themes" role="radiogroup" aria-label="Theme"> 179 + <legend>Theme</legend> 180 + <div className="pubform__theme-grid"> 181 + <label className={ `pubform__theme${ themeSlug === null ? ' is-selected' : '' }` }> 182 + <input 183 + type="radio" 184 + name="pub-theme" 185 + value="" 186 + checked={ themeSlug === null } 187 + onChange={ () => setThemeSlug( null ) } 188 + disabled={ saving } 189 + /> 190 + <span 191 + className="pubform__theme-swatch pubform__theme-swatch--none" 192 + aria-hidden="true" 193 + /> 194 + <span className="pubform__theme-label">No theme</span> 195 + </label> 196 + { THEME_PRESETS.map( ( preset ) => { 197 + const swatch = ( color: Rgb ) => `rgb(${ color.r }, ${ color.g }, ${ color.b })`; 198 + return ( 199 + <label 200 + key={ preset.slug } 201 + className={ `pubform__theme${ 202 + themeSlug === preset.slug ? ' is-selected' : '' 203 + }` } 204 + > 205 + <input 206 + type="radio" 207 + name="pub-theme" 208 + value={ preset.slug } 209 + checked={ themeSlug === preset.slug } 210 + onChange={ () => setThemeSlug( preset.slug ) } 211 + disabled={ saving } 212 + /> 213 + <span 214 + className="pubform__theme-swatch" 215 + style={ { 216 + background: swatch( preset.colors.background ), 217 + color: swatch( preset.colors.foreground ), 218 + } } 219 + aria-hidden="true" 220 + > 221 + <span 222 + className="pubform__theme-dot" 223 + style={ { background: swatch( preset.colors.accent ) } } 224 + /> 225 + Aa 226 + </span> 227 + <span className="pubform__theme-label">{ preset.label }</span> 228 + </label> 229 + ); 230 + } ) } 231 + </div> 232 + <small>Sets the colours readers see on your publication.</small> 233 + </fieldset> 171 234 172 235 { isEditing && ( 173 236 <p className="pubform__note">
+76
src/pages/dashboard.astro
··· 351 351 color: var(--ember); 352 352 font-size: 0.9rem; 353 353 } 354 + .pubform__themes { 355 + border: 0; 356 + margin: 0 0 1rem; 357 + padding: 0; 358 + min-width: 0; 359 + } 360 + .pubform__themes legend { 361 + font-size: 0.85rem; 362 + font-weight: 600; 363 + padding: 0; 364 + margin-bottom: 0.4rem; 365 + } 366 + .pubform__themes small { 367 + display: block; 368 + margin-top: 0.5rem; 369 + font-size: 0.82rem; 370 + color: var(--muted); 371 + } 372 + .pubform__theme-grid { 373 + display: grid; 374 + grid-template-columns: repeat(auto-fill, minmax(84px, 1fr)); 375 + gap: 0.6rem; 376 + } 377 + .pubform__theme { 378 + display: flex; 379 + flex-direction: column; 380 + align-items: center; 381 + gap: 0.35rem; 382 + cursor: pointer; 383 + } 384 + .pubform__theme input { 385 + position: absolute; 386 + width: 1px; 387 + height: 1px; 388 + opacity: 0; 389 + pointer-events: none; 390 + } 391 + .pubform__theme-swatch { 392 + display: flex; 393 + align-items: center; 394 + justify-content: center; 395 + gap: 0.3rem; 396 + width: 100%; 397 + aspect-ratio: 16 / 10; 398 + border-radius: var(--radius-sm); 399 + border: 2px solid var(--line-strong); 400 + font-family: var(--font-display); 401 + font-weight: 600; 402 + font-size: 0.95rem; 403 + } 404 + .pubform__theme.is-selected .pubform__theme-swatch { 405 + border-color: var(--sun); 406 + box-shadow: 0 0 0 2px var(--sun); 407 + } 408 + .pubform__theme input:focus-visible + .pubform__theme-swatch { 409 + outline: 2px solid var(--sun); 410 + outline-offset: 2px; 411 + } 412 + .pubform__theme-swatch--none { 413 + background: repeating-linear-gradient( 414 + 45deg, 415 + var(--panel), 416 + var(--panel) 6px, 417 + var(--paper-raised) 6px, 418 + var(--paper-raised) 12px 419 + ); 420 + } 421 + .pubform__theme-dot { 422 + width: 0.7rem; 423 + height: 0.7rem; 424 + border-radius: 50%; 425 + } 426 + .pubform__theme-label { 427 + font-size: 0.8rem; 428 + color: var(--ink-soft); 429 + } 354 430 .pubform__actions { 355 431 display: flex; 356 432 gap: 0.75rem;
+4 -2
src/styles/global.css
··· 39 39 white text. Bare --sun is too light for white text (~2.5:1). */ 40 40 --btn-primary: #b85c12; 41 41 --btn-primary-hover: #9a4c0f; 42 + --btn-primary-fg: #fff; 42 43 --ember: #bb5a36; /* rare warm accent (the ink/press counterweight) */ 43 44 44 45 --radius: 12px; ··· 63 64 /* Dark mode's accents are light, so white text still needs a deeper fill. */ 64 65 --btn-primary: #aa6010; 65 66 --btn-primary-hover: #964f0c; 67 + --btn-primary-fg: #fff; 66 68 --ember: #e08a63; 67 69 --shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 18px 40px -16px rgba(0, 0, 0, 0.6); 68 70 } ··· 142 144 } 143 145 .btn--primary { 144 146 background: var(--btn-primary); 145 - color: #fff; 147 + color: var(--btn-primary-fg); 146 148 } 147 149 .btn--primary:hover { 148 150 background: var(--btn-primary-hover); 149 - color: #fff; 151 + color: var(--btn-primary-fg); 150 152 } 151 153 .btn--ghost { 152 154 background: var(--paper-raised);
+1 -1
vitest.config.ts
··· 5 5 globals: true, 6 6 // jsdom: @wordpress/block-library block registration touches browser globals. 7 7 environment: 'jsdom', 8 - include: [ 'src/**/*.test.ts' ], 8 + include: [ 'src/**/*.test.ts', 'src/**/*.test.tsx' ], 9 9 }, 10 10 resolve: { 11 11 dedupe: [ 'react', 'react-dom', '@wordpress/element' ],