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.

Stamp $type on basicTheme colours so Bluesky renders the enhanced card

site.standard.theme.basic types each colour (background/foreground/accent/
accentForeground) as a union of site.standard.theme.color#rgb, and atproto
requires a $type discriminator on every union member. SkyPress was writing
bare { r, g, b } triples, so the site.standard.publication record was invalid
against the lexicon.

Bluesky's AppView validates the document + publication records it resolves
from a post's app.bsky.embed.external.associatedRefs; on an invalid record it
silently drops the enhanced standard.site link card (no source/theme/avatar/
reading-time) and falls back to a bare external embed.

Stamp COLOR_TYPE on every colour built (rgb()) or re-validated (parseBasicTheme)
so the single write boundary always emits lexicon-valid records. Existing
publications stay invalid until re-saved.

Regression tests cover both the parse boundary and the stored record; doc the
constraint in Decision 0012.

+76 -5
+11
docs/decisions/0012-publication-theme-presets.md
··· 29 29 untrusted PDS data: `parseBasicTheme` validates every channel is an integer 0–255 and the 30 30 renderer emits only app-built `rgb()` strings (AGENTS.md #6). 31 31 32 + **Each colour is a union member, not a bare RGB triple.** The canonical 33 + `site.standard.theme.basic` lexicon types `background`/`foreground`/`accent`/`accentForeground` 34 + as a `union` of `site.standard.theme.color#rgb` (the colour lexicon also defines `#rgba`), so 35 + atproto requires a `$type: "site.standard.theme.color#rgb"` discriminator on **every** colour 36 + object. Omitting it makes the publication record invalid against the lexicon — and Bluesky's 37 + AppView, which validates these records when hydrating the post embed's `associatedRefs`, then 38 + silently drops the **enhanced standard.site link card** and falls back to a bare external embed 39 + (no avatar, theme, or reading time). `themes.ts` stamps `COLOR_TYPE` on every colour it builds 40 + (`rgb()`) or re-validates (`parseBasicTheme`), so the single write boundary always emits valid 41 + records. (Originally shipped without the discriminator; see the fix that added it.) 42 + 32 43 ### 2. Curated presets only — eight "sky phases" 33 44 34 45 Rather than a free-form colour picker, SkyPress ships eight presets derived from the Twenty
+19
src/lib/publish/records.test.ts
··· 174 174 expect( 'basicTheme' in bare ).toBe( false ); 175 175 } ); 176 176 177 + it( 'stamps the colour union $type so the stored basicTheme validates against the lexicon', () => { 178 + // `site.standard.theme.basic` colours are a union of `site.standard.theme.color#rgb`; without 179 + // the `$type` discriminator the record is invalid and Bluesky drops the enhanced link card. 180 + const themed = buildPublicationRecord( { 181 + handle: 'a.b', 182 + slug: 's', 183 + name: 'N', 184 + basicTheme: THEME_PRESETS[ 0 ].colors, 185 + } ); 186 + for ( const color of [ 187 + themed.basicTheme!.background, 188 + themed.basicTheme!.foreground, 189 + themed.basicTheme!.accent, 190 + themed.basicTheme!.accentForeground, 191 + ] ) { 192 + expect( color.$type ).toBe( 'site.standard.theme.color#rgb' ); 193 + } 194 + } ); 195 + 177 196 it( 'drops an invalid basicTheme rather than persisting it (write-boundary validation)', () => { 178 197 const record = buildPublicationRecord( { 179 198 handle: 'a.b',
+23
src/lib/publish/themes.test.ts
··· 47 47 } 48 48 } ); 49 49 50 + it( 'every preset colour carries the site.standard.theme.color#rgb union $type', () => { 51 + // `site.standard.theme.basic` types each colour as a union of `site.standard.theme.color#rgb`, 52 + // and atproto requires a `$type` discriminator on union members. Without it the publication 53 + // record is invalid and Bluesky's AppView can't hydrate the enhanced standard.site link card. 54 + for ( const preset of THEME_PRESETS ) { 55 + for ( const color of [ 56 + preset.colors.background, 57 + preset.colors.foreground, 58 + preset.colors.accent, 59 + preset.colors.accentForeground, 60 + ] ) { 61 + expect( color.$type ).toBe( 'site.standard.theme.color#rgb' ); 62 + } 63 + } 64 + } ); 65 + 50 66 it( 'every preset color channel is an integer 0–255', () => { 51 67 for ( const preset of THEME_PRESETS ) { 52 68 for ( const color of [ ··· 71 87 expect( parsed ).not.toBeNull(); 72 88 expect( parsed!.$type ).toBe( 'site.standard.theme.basic' ); 73 89 expect( parsed!.accent ).toEqual( THEME_PRESETS[ 0 ].colors.accent ); 90 + } ); 91 + 92 + it( 'stamps the union $type discriminator on every colour (lexicon requires it)', () => { 93 + const parsed = parseBasicTheme( THEME_PRESETS[ 0 ].colors )!; 94 + for ( const color of [ parsed.background, parsed.foreground, parsed.accent, parsed.accentForeground ] ) { 95 + expect( color.$type ).toBe( 'site.standard.theme.color#rgb' ); 96 + } 74 97 } ); 75 98 76 99 it( 'rejects undefined, non-objects, and out-of-range / non-integer channels', () => {
+23 -5
src/lib/publish/themes.ts
··· 7 7 * (Decision 0012.) 8 8 */ 9 9 10 + /** 11 + * The lexicon type id for an RGB colour. `site.standard.theme.basic` types each colour as a 12 + * UNION of `site.standard.theme.color#rgb`, and atproto requires a `$type` discriminator on every 13 + * union member. Omitting it makes the publication record invalid, so Bluesky's AppView can't 14 + * hydrate it and silently drops the enhanced standard.site link card — falling back to a bare 15 + * external embed (no avatar / theme / reading time). Every stored colour must carry this. */ 16 + export const COLOR_TYPE = 'site.standard.theme.color#rgb'; 17 + 10 18 export interface Rgb { 19 + /** Union discriminator required by the lexicon; always set on stored/built colours. */ 20 + $type?: typeof COLOR_TYPE; 11 21 r: number; 12 22 g: number; 13 23 b: number; ··· 31 41 colors: BasicTheme; 32 42 } 33 43 34 - const rgb = ( r: number, g: number, b: number ): Rgb => ( { r, g, b } ); 44 + const rgb = ( r: number, g: number, b: number ): Rgb => ( { $type: COLOR_TYPE, r, g, b } ); 35 45 36 46 const preset = ( 37 47 slug: string, ··· 90 100 ) { 91 101 return null; 92 102 } 103 + // Re-stamp the colour union `$type` on the way out: the lexicon requires it, and PDS-sourced 104 + // input may omit it (e.g. records written before this was fixed). This is the single boundary 105 + // both the write path (buildPublicationRecord) and read path (reader) funnel through. 93 106 return { 94 107 $type: 'site.standard.theme.basic', 95 - background: { r: v.background.r, g: v.background.g, b: v.background.b }, 96 - foreground: { r: v.foreground.r, g: v.foreground.g, b: v.foreground.b }, 97 - accent: { r: v.accent.r, g: v.accent.g, b: v.accent.b }, 98 - accentForeground: { r: v.accentForeground.r, g: v.accentForeground.g, b: v.accentForeground.b }, 108 + background: { $type: COLOR_TYPE, r: v.background.r, g: v.background.g, b: v.background.b }, 109 + foreground: { $type: COLOR_TYPE, r: v.foreground.r, g: v.foreground.g, b: v.foreground.b }, 110 + accent: { $type: COLOR_TYPE, r: v.accent.r, g: v.accent.g, b: v.accent.b }, 111 + accentForeground: { 112 + $type: COLOR_TYPE, 113 + r: v.accentForeground.r, 114 + g: v.accentForeground.g, 115 + b: v.accentForeground.b, 116 + }, 99 117 }; 100 118 } 101 119