···11# Bluesky's "Application Layout Framework" AKA "ALF"
2233-No docs for u.
33+Bluesky's design system and styling framework for React Native. ALF provides utility-first, atomic style objects that work across web, iOS, and Android.
44+55+You use ALF by combining static atoms (frozen style objects) with dynamic theme atoms that adapt to the active color scheme. No runtime CSS generation, no style strings. You compose styles via arrays, the same way you already do in React Native.
66+77+## Install
88+99+```bash
1010+yarn add @bsky.app/alf
1111+```
1212+1313+Peer dependencies: `react@19`, `react-native@^0.81.1`.
1414+1515+## Quick start
1616+1717+Wrap your app in the `Provider` and pass it your themes:
1818+1919+```tsx
2020+import { Provider, themes } from '@bsky.app/alf'
2121+2222+function App() {
2323+ return (
2424+ <Provider activeTheme="light" themes={themes}>
2525+ <Root />
2626+ </Provider>
2727+ )
2828+}
2929+```
3030+3131+Then use `atoms` for static layout and `useTheme()` for color:
3232+3333+```tsx
3434+import { atoms as a, useTheme } from '@bsky.app/alf'
3535+3636+function Card() {
3737+ const t = useTheme()
3838+ return (
3939+ <View style={[a.flex_row, a.gap_md, a.p_lg, a.rounded_md, t.atoms.bg]}>
4040+ <Text style={[a.text_md, a.font_bold, t.atoms.text]}>Hello</Text>
4141+ </View>
4242+ )
4343+}
4444+```
4545+4646+## Exports
4747+4848+Everything ships from one entry point: `@bsky.app/alf`.
4949+5050+| Export | Description |
5151+|--------|-------------|
5252+| `atoms` | 300+ frozen style objects for layout, spacing, typography, borders |
5353+| `useTheme()` | Hook returning the active `Theme` with color-adaptive `atoms` |
5454+| `Provider` | React context provider for theme selection |
5555+| `tokens` | Namespaced design tokens (spacing, font sizes, line heights, radii, weights) |
5656+| `utils` | Namespaced utilities: `alpha`, `leading`, `flatten`, `select` |
5757+| `themes` | Pre-built light, dark, and dim theme objects |
5858+| `createTheme()` | Build a custom theme from a palette |
5959+| `createThemes()` | Build light/dark/dim variants from two palettes |
6060+| `Palette` | Type and defaults for color palettes |
6161+| `invertPalette()` | Flip a palette's color scale for dark mode |
6262+| `isWeb`, `isNative`, `isIOS`, `isAndroid` | Platform detection booleans |
6363+| `web()`, `native()`, `ios()`, `android()` | Platform-gated value selectors |
6464+| `platform()` | `Platform.select()` equivalent |
6565+6666+## Platform behavior
6767+6868+ALF uses React Native's file extension convention (`.native.ts`) to resolve platform-specific code at build time.
6969+7070+Notable differences on native:
7171+7272+- `fixed` resolves to `position: 'absolute'` (fixed positioning not supported)
7373+- `sticky` resolves to an empty object
7474+- Border widths use `StyleSheet.hairlineWidth` instead of 1px
7575+- Shadows use native shadow props with `elevation`. On Fabric (the new architecture), shadows resolve to empty objects.
7676+- Web-only atoms (`inline`, `block`, `pointer`) resolve to empty objects
7777+7878+## API reference
7979+8080+See [api-reference.md](./api-reference.md) for the complete API.
+770
api-reference.md
···11+# API Reference
22+33+Complete reference for `@bsky.app/alf`. All exports come from a single entry point:
44+55+```typescript
66+import {
77+ atoms,
88+ useTheme,
99+ Provider,
1010+ Context,
1111+ tokens,
1212+ utils,
1313+ themes,
1414+ createTheme,
1515+ createThemes,
1616+ invertPalette,
1717+ isWeb,
1818+ isNative,
1919+ isIOS,
2020+ isAndroid,
2121+ isFabric,
2222+ web,
2323+ native,
2424+ ios,
2525+ android,
2626+ platform,
2727+} from '@bsky.app/alf'
2828+```
2929+3030+---
3131+3232+## Provider and Hook
3333+3434+### `Provider`
3535+3636+React context provider that makes the active theme available to all children via `useTheme()`.
3737+3838+```tsx
3939+<Provider activeTheme="light" themes={themes}>
4040+ <App />
4141+</Provider>
4242+```
4343+4444+| Prop | Type | Description |
4545+|------|------|-------------|
4646+| `activeTheme` | `T extends string` | Key into the `themes` object |
4747+| `themes` | `Record<T, Theme>` | Map of theme name to `Theme` object |
4848+| `children` | `ReactNode` | Your app |
4949+5050+The provider memoizes the context value based on `activeTheme` and `themes`.
5151+5252+### `useTheme()`
5353+5454+Returns the active `Theme` object from context. You access theme-aware color atoms from `t.atoms`:
5555+5656+```tsx
5757+const t = useTheme()
5858+<View style={[t.atoms.bg, t.atoms.shadow_md]}>
5959+ <Text style={[t.atoms.text]}>Themed text</Text>
6060+</View>
6161+```
6262+6363+Returns: `Theme`
6464+6565+### `Context`
6666+6767+The raw React context (`React.Context<{ theme: Theme }>`). Defaults to `themes.light`. Display name: `'AlfContext'`.
6868+6969+You rarely need this directly. Use `useTheme()` instead.
7070+7171+---
7272+7373+## Atoms
7474+7575+All atoms live on the `atoms` object. They are frozen, static style objects that you combine via arrays.
7676+7777+```tsx
7878+import { atoms as a } from '@bsky.app/alf'
7979+8080+<View style={[a.flex_row, a.gap_md, a.p_lg, a.rounded_md]} />
8181+```
8282+8383+### Naming conventions
8484+8585+- Underscore prefix for sizes starting with a number: `_2xs`, `_2xl`, `_3xl`, etc.
8686+- Axis suffixes: `x` (left + right), `y` (top + bottom)
8787+- Side suffixes: `t` (top), `b` (bottom), `l` (left), `r` (right)
8888+8989+### Debug
9090+9191+| Atom | Style |
9292+|------|-------|
9393+| `debug` | `borderColor: 'red', borderWidth: 1` |
9494+9595+### Positioning
9696+9797+| Atom | Style |
9898+|------|-------|
9999+| `fixed` | `position: 'fixed'` (native: `'absolute'`) |
100100+| `absolute` | `position: 'absolute'` |
101101+| `relative` | `position: 'relative'` |
102102+| `static` | `position: 'static'` |
103103+| `sticky` | `position: 'sticky'` (native: empty) |
104104+| `inset_0` | `top/right/bottom/left: 0` |
105105+| `top_0` | `top: 0` |
106106+| `right_0` | `right: 0` |
107107+| `bottom_0` | `bottom: 0` |
108108+| `left_0` | `left: 0` |
109109+110110+### Z-index
111111+112112+| Atom | Value |
113113+|------|-------|
114114+| `z_10` | `zIndex: 10` |
115115+| `z_20` | `zIndex: 20` |
116116+| `z_30` | `zIndex: 30` |
117117+| `z_40` | `zIndex: 40` |
118118+| `z_50` | `zIndex: 50` |
119119+120120+### Overflow
121121+122122+| Atom | Style |
123123+|------|-------|
124124+| `overflow_visible` | `overflow: 'visible'` |
125125+| `overflow_hidden` | `overflow: 'hidden'` |
126126+| `overflow_auto` | `overflow: 'auto'` (native: empty) |
127127+| `overflow_x_visible` | `overflowX: 'visible'` |
128128+| `overflow_x_hidden` | `overflowX: 'hidden'` |
129129+| `overflow_y_visible` | `overflowY: 'visible'` |
130130+| `overflow_y_hidden` | `overflowY: 'hidden'` |
131131+132132+### Width and height
133133+134134+| Atom | Style |
135135+|------|-------|
136136+| `w_full` | `width: '100%'` |
137137+| `h_full` | `height: '100%'` |
138138+| `h_full_vh` | `height: '100vh'` |
139139+| `max_w_full` | `maxWidth: '100%'` |
140140+| `max_h_full` | `maxHeight: '100%'` |
141141+142142+### Border radius
143143+144144+All values come from `tokens.borderRadius`.
145145+146146+| Atom | Value (px) |
147147+|------|------------|
148148+| `rounded_0` | 0 |
149149+| `rounded_2xs` | 2 |
150150+| `rounded_xs` | 4 |
151151+| `rounded_sm` | 8 |
152152+| `rounded_md` | 12 |
153153+| `rounded_lg` | 16 |
154154+| `rounded_xl` | 20 |
155155+| `rounded_full` | 999 |
156156+157157+### Flexbox
158158+159159+| Atom | Style |
160160+|------|-------|
161161+| `flex` | `display: 'flex'` |
162162+| `flex_col` | `flexDirection: 'column'` |
163163+| `flex_row` | `flexDirection: 'row'` |
164164+| `flex_col_reverse` | `flexDirection: 'column-reverse'` |
165165+| `flex_row_reverse` | `flexDirection: 'row-reverse'` |
166166+| `flex_wrap` | `flexWrap: 'wrap'` |
167167+| `flex_nowrap` | `flexWrap: 'nowrap'` |
168168+| `flex_0` | `flex: '0 0 auto'` (native: `flex: 0`) |
169169+| `flex_1` | `flex: 1` |
170170+| `flex_grow` | `flexGrow: 1` |
171171+| `flex_grow_0` | `flexGrow: 0` |
172172+| `flex_shrink` | `flexShrink: 1` |
173173+| `flex_shrink_0` | `flexShrink: 0` |
174174+175175+### Alignment
176176+177177+| Atom | Style |
178178+|------|-------|
179179+| `justify_start` | `justifyContent: 'flex-start'` |
180180+| `justify_center` | `justifyContent: 'center'` |
181181+| `justify_between` | `justifyContent: 'space-between'` |
182182+| `justify_end` | `justifyContent: 'flex-end'` |
183183+| `align_start` | `alignItems: 'flex-start'` |
184184+| `align_center` | `alignItems: 'center'` |
185185+| `align_end` | `alignItems: 'flex-end'` |
186186+| `align_baseline` | `alignItems: 'baseline'` |
187187+| `align_stretch` | `alignItems: 'stretch'` |
188188+| `self_auto` | `alignSelf: 'auto'` |
189189+| `self_start` | `alignSelf: 'flex-start'` |
190190+| `self_end` | `alignSelf: 'flex-end'` |
191191+| `self_center` | `alignSelf: 'center'` |
192192+| `self_stretch` | `alignSelf: 'stretch'` |
193193+| `self_baseline` | `alignSelf: 'baseline'` |
194194+195195+### Gap
196196+197197+All values come from `tokens.space`.
198198+199199+| Atom | Value (px) |
200200+|------|------------|
201201+| `gap_0` | 0 |
202202+| `gap_2xs` | 2 |
203203+| `gap_xs` | 4 |
204204+| `gap_sm` | 8 |
205205+| `gap_md` | 12 |
206206+| `gap_lg` | 16 |
207207+| `gap_xl` | 20 |
208208+| `gap_2xl` | 24 |
209209+| `gap_3xl` | 28 |
210210+| `gap_4xl` | 32 |
211211+| `gap_5xl` | 40 |
212212+213213+### Typography
214214+215215+#### Font size
216216+217217+Each atom sets both `fontSize` and `letterSpacing: 0`. Values come from `tokens.fontSize`.
218218+219219+| Atom | Size (px) |
220220+|------|-----------|
221221+| `text_2xs` | 9.4 |
222222+| `text_xs` | 11.3 |
223223+| `text_sm` | 13.1 |
224224+| `text_md` | 15 |
225225+| `text_lg` | 16.9 |
226226+| `text_xl` | 18.8 |
227227+| `text_2xl` | 20.6 |
228228+| `text_3xl` | 24.3 |
229229+| `text_4xl` | 30 |
230230+| `text_5xl` | 37.5 |
231231+232232+#### Text alignment
233233+234234+| Atom | Style |
235235+|------|-------|
236236+| `text_left` | `textAlign: 'left'` |
237237+| `text_center` | `textAlign: 'center'` |
238238+| `text_right` | `textAlign: 'right'` |
239239+240240+#### Line height
241241+242242+Values are unitless multipliers from `tokens.lineHeight`. Use the `utils.leading()` function when you need computed pixel values on native.
243243+244244+| Atom | Multiplier |
245245+|------|------------|
246246+| `leading_tight` | 1.15 |
247247+| `leading_snug` | 1.3 |
248248+| `leading_relaxed` | 1.5 |
249249+| `leading_normal` | 1.5 (deprecated, use `leading_relaxed`) |
250250+251251+#### Letter spacing
252252+253253+| Atom | Style |
254254+|------|-------|
255255+| `tracking_normal` | `letterSpacing: 0` |
256256+257257+#### Font weight
258258+259259+| Atom | Weight |
260260+|------|--------|
261261+| `font_normal` | `'400'` |
262262+| `font_medium` | `'500'` |
263263+| `font_semi_bold` | `'600'` |
264264+| `font_bold` | `'700'` |
265265+266266+#### Font style
267267+268268+| Atom | Style |
269269+|------|-------|
270270+| `italic` | `fontStyle: 'italic'` |
271271+272272+### Borders
273273+274274+On native, all 1px border atoms use `StyleSheet.hairlineWidth` instead. See the [platform behavior](#native-overrides) section.
275275+276276+#### Border width
277277+278278+| Atom | Sides | Width |
279279+|------|-------|-------|
280280+| `border_0` | All | 0 |
281281+| `border` | All | 1 |
282282+| `border_t_0` / `border_t` | Top | 0 / 1 |
283283+| `border_b_0` / `border_b` | Bottom | 0 / 1 |
284284+| `border_l_0` / `border_l` | Left | 0 / 1 |
285285+| `border_r_0` / `border_r` | Right | 0 / 1 |
286286+| `border_x_0` / `border_x` | Left + Right | 0 / 1 |
287287+| `border_y_0` / `border_y` | Top + Bottom | 0 / 1 |
288288+289289+#### Border color
290290+291291+| Atom | Style |
292292+|------|-------|
293293+| `border_transparent` | `borderColor: 'transparent'` |
294294+295295+For theme-aware border colors, use `t.atoms.border_contrast_low`, `t.atoms.border_contrast_medium`, or `t.atoms.border_contrast_high`.
296296+297297+### Border curves (iOS only)
298298+299299+These resolve to empty objects on web and Android. On iOS, they set `borderCurve`.
300300+301301+| Atom | iOS Style |
302302+|------|-----------|
303303+| `curve_circular` | `borderCurve: 'circular'` |
304304+| `curve_continuous` | `borderCurve: 'continuous'` |
305305+306306+### Shadows
307307+308308+Static shadow atoms are empty objects on web and Fabric. Use theme shadow atoms (`t.atoms.shadow_sm`, etc.) for cross-platform shadows with proper theme colors.
309309+310310+| Atom | Description |
311311+|------|-------------|
312312+| `shadow_sm` | Small shadow (native only, disabled on Fabric) |
313313+| `shadow_md` | Medium shadow (native only, disabled on Fabric) |
314314+| `shadow_lg` | Large shadow (native only, disabled on Fabric) |
315315+316316+### Gutters
317317+318318+Semantic padding shortcuts. Each has `_x` (horizontal) and `_y` (vertical) variants.
319319+320320+| Atom | Padding (px) |
321321+|------|-------------|
322322+| `gutter_tight` / `gutter_x_tight` / `gutter_y_tight` | 8 |
323323+| `gutter_snug` / `gutter_x_snug` / `gutter_y_snug` | 12 |
324324+| `gutter_default` / `gutter_x_default` / `gutter_y_default` | 16 |
325325+| `gutter_wide` / `gutter_x_wide` / `gutter_y_wide` | 20 |
326326+| `gutter_extra_wide` / `gutter_x_extra_wide` / `gutter_y_extra_wide` | 24 |
327327+328328+### Padding
329329+330330+All values come from `tokens.space`. Each size has `p_`, `px_`, `py_`, `pt_`, `pb_`, `pl_`, `pr_` variants.
331331+332332+| Size | Value (px) |
333333+|------|------------|
334334+| `0` | 0 |
335335+| `2xs` | 2 |
336336+| `xs` | 4 |
337337+| `sm` | 8 |
338338+| `md` | 12 |
339339+| `lg` | 16 |
340340+| `xl` | 20 |
341341+| `2xl` | 24 |
342342+| `3xl` | 28 |
343343+| `4xl` | 32 |
344344+| `5xl` | 40 |
345345+346346+Full atom names follow the pattern: `p_md`, `px_lg`, `py_sm`, `pt_xl`, `pb_2xl`, `pl_xs`, `pr_3xl`.
347347+348348+### Margin
349349+350350+Same size scale as padding. Each size has `m_`, `mx_`, `my_`, `mt_`, `mb_`, `ml_`, `mr_` variants, plus `_auto` variants for centering.
351351+352352+Auto margins: `m_auto`, `mx_auto`, `my_auto`, `mt_auto`, `mb_auto`, `ml_auto`, `mr_auto`.
353353+354354+### Pointer events and user select
355355+356356+| Atom | Style |
357357+|------|-------|
358358+| `pointer_events_none` | `pointerEvents: 'none'` |
359359+| `pointer_events_auto` | `pointerEvents: 'auto'` |
360360+| `pointer_events_box_only` | `pointerEvents: 'box-only'` |
361361+| `pointer_events_box_none` | `pointerEvents: 'box-none'` |
362362+| `user_select_none` | `userSelect: 'none'` |
363363+| `user_select_text` | `userSelect: 'text'` |
364364+| `user_select_all` | `userSelect: 'all'` |
365365+| `outline_inset_1` | `outlineOffset: -1` |
366366+367367+### Text decoration
368368+369369+| Atom | Style |
370370+|------|-------|
371371+| `underline` | `textDecorationLine: 'underline'` |
372372+| `strike_through` | `textDecorationLine: 'line-through'` |
373373+374374+### Display
375375+376376+| Atom | Style | Platform |
377377+|------|-------|----------|
378378+| `hidden` | `display: 'none'` | All |
379379+| `contents` | `display: 'contents'` | All |
380380+| `inline` | `display: 'inline'` | Web only (native: empty) |
381381+| `block` | `display: 'block'` | Web only (native: empty) |
382382+383383+### Cursor
384384+385385+| Atom | Style | Platform |
386386+|------|-------|----------|
387387+| `pointer` | `cursor: 'pointer'` | Web only (native: empty) |
388388+389389+---
390390+391391+## Theme Atoms
392392+393393+These live on `t.atoms` (where `t = useTheme()`). They adapt to the active theme's color palette.
394394+395395+Theme atoms only cover the **contrast** color scale — the neutral grays used for text, backgrounds, and borders on nearly every component. Primary, positive, and negative colors are more situational, so they are accessed directly from the palette instead:
396396+397397+```tsx
398398+const t = useTheme()
399399+400400+// Contrast colors → theme atoms
401401+<View style={[t.atoms.bg, t.atoms.border_contrast_medium]}>
402402+ <Text style={[t.atoms.text]}>Neutral text</Text>
403403+</View>
404404+405405+// Primary / positive / negative → palette
406406+<View style={{ backgroundColor: t.palette.primary_500 }}>
407407+ <Text style={{ color: t.palette.positive_500 }}>Success</Text>
408408+</View>
409409+```
410410+411411+Palette values still adapt to the active theme automatically via `invertPalette()`, so `t.palette.primary_500` returns the correct shade in light, dark, and dim modes.
412412+413413+### Text
414414+415415+| Atom | Palette source |
416416+|------|---------------|
417417+| `t.atoms.text` | `contrast_1000` |
418418+| `t.atoms.text_contrast_low` | `contrast_400` |
419419+| `t.atoms.text_contrast_medium` | `contrast_700` |
420420+| `t.atoms.text_contrast_high` | `contrast_900` |
421421+| `t.atoms.text_inverted` | `contrast_0` |
422422+423423+### Background
424424+425425+| Atom | Palette source |
426426+|------|---------------|
427427+| `t.atoms.bg` | `contrast_0` |
428428+| `t.atoms.bg_contrast_25` | `contrast_25` |
429429+| `t.atoms.bg_contrast_50` | `contrast_50` |
430430+| `t.atoms.bg_contrast_100` | `contrast_100` |
431431+| `t.atoms.bg_contrast_200` | `contrast_200` |
432432+| `t.atoms.bg_contrast_300` | `contrast_300` |
433433+| `t.atoms.bg_contrast_400` | `contrast_400` |
434434+| `t.atoms.bg_contrast_500` | `contrast_500` |
435435+| `t.atoms.bg_contrast_600` | `contrast_600` |
436436+| `t.atoms.bg_contrast_700` | `contrast_700` |
437437+| `t.atoms.bg_contrast_800` | `contrast_800` |
438438+| `t.atoms.bg_contrast_900` | `contrast_900` |
439439+| `t.atoms.bg_contrast_950` | `contrast_950` |
440440+| `t.atoms.bg_contrast_975` | `contrast_975` |
441441+442442+### Border
443443+444444+| Atom | Palette source |
445445+|------|---------------|
446446+| `t.atoms.border_contrast_low` | `contrast_100` |
447447+| `t.atoms.border_contrast_medium` | `contrast_200` |
448448+| `t.atoms.border_contrast_high` | `contrast_300` |
449449+450450+### Shadow
451451+452452+| Atom | Description |
453453+|------|-------------|
454454+| `t.atoms.shadow_sm` | Small shadow with theme-appropriate opacity |
455455+| `t.atoms.shadow_md` | Medium shadow |
456456+| `t.atoms.shadow_lg` | Large shadow |
457457+458458+Shadow atoms include native shadow props and a `boxShadow` CSS string for web. Light themes use 0.1 shadow opacity, dark/dim themes use 0.4.
459459+460460+---
461461+462462+## Tokens
463463+464464+Imported as a namespace: `import { tokens } from '@bsky.app/alf'`. All values are pixel-based numbers (no rem/em).
465465+466466+### `tokens.space`
467467+468468+| Key | Value |
469469+|-----|-------|
470470+| `_2xs` | 2 |
471471+| `xs` | 4 |
472472+| `sm` | 8 |
473473+| `md` | 12 |
474474+| `lg` | 16 |
475475+| `xl` | 20 |
476476+| `_2xl` | 24 |
477477+| `_3xl` | 28 |
478478+| `_4xl` | 32 |
479479+| `_5xl` | 40 |
480480+481481+### `tokens.fontSize`
482482+483483+| Key | Value |
484484+|-----|-------|
485485+| `_2xs` | 9.4 |
486486+| `xs` | 11.3 |
487487+| `sm` | 13.1 |
488488+| `md` | 15 |
489489+| `lg` | 16.9 |
490490+| `xl` | 18.8 |
491491+| `_2xl` | 20.6 |
492492+| `_3xl` | 24.3 |
493493+| `_4xl` | 30 |
494494+| `_5xl` | 37.5 |
495495+496496+### `tokens.lineHeight`
497497+498498+| Key | Value |
499499+|-----|-------|
500500+| `tight` | 1.15 |
501501+| `snug` | 1.3 |
502502+| `relaxed` | 1.5 |
503503+504504+### `tokens.borderRadius`
505505+506506+| Key | Value |
507507+|-----|-------|
508508+| `_2xs` | 2 |
509509+| `xs` | 4 |
510510+| `sm` | 8 |
511511+| `md` | 12 |
512512+| `lg` | 16 |
513513+| `xl` | 20 |
514514+| `full` | 999 |
515515+516516+### `tokens.fontWeight`
517517+518518+| Key | Value |
519519+|-----|-------|
520520+| `normal` | `'400'` |
521521+| `medium` | `'500'` |
522522+| `semiBold` | `'600'` |
523523+| `bold` | `'700'` |
524524+525525+### `tokens.labelerColor`
526526+527527+| Key | Value |
528528+|-----|-------|
529529+| `purple` | `rgb(105 0 255)` |
530530+| `purple_dark` | `rgb(83 0 202)` |
531531+532532+### `tokens.TRACKING`
533533+534534+Letter-spacing constant. Value: `0`.
535535+536536+---
537537+538538+## Palette
539539+540540+### `Palette` type
541541+542542+Defines all color values for a theme. Four color families, each with shades:
543543+544544+- **`contrast_*`** (neutrals): 0, 25, 50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950, 975, 1000
545545+- **`primary_*`** (brand blue): 25, 50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950, 975
546546+- **`positive_*`** (green): same scale as primary
547547+- **`negative_*`** (red): same scale as primary
548548+549549+Plus `white`, `black`, and `like` (pink, `#EC4899`).
550550+551551+All values are CSS color strings.
552552+553553+### `DEFAULT_PALETTE`
554554+555555+The standard light-mode palette. Contrast ranges from `#FFFFFF` (0) to `#000000` (1000). Primary blues range from `#F5F9FF` (25) to `#001533` (975).
556556+557557+### `DEFAULT_SUBDUED_PALETTE`
558558+559559+A softer alternative used by the dim theme. Same structure, lower contrast. The darkest contrast value is `#151D28` instead of pure black.
560560+561561+### `invertPalette(palette)`
562562+563563+Flips a palette's color scales for dark mode. Swaps contrast_0 with contrast_1000, contrast_25 with contrast_975, and so on. Keeps `white`, `black`, and `like` unchanged.
564564+565565+```typescript
566566+const darkPalette = invertPalette(DEFAULT_PALETTE)
567567+```
568568+569569+Returns: `Palette`
570570+571571+---
572572+573573+## Themes
574574+575575+### `Theme` type
576576+577577+```typescript
578578+type Theme = {
579579+ scheme: ThemeScheme // 'light' | 'dark'
580580+ name: ThemeName // 'light' | 'dark' | 'dim'
581581+ palette: Palette
582582+ atoms: ThemeAtoms
583583+}
584584+```
585585+586586+### `ThemeScheme`
587587+588588+`'light' | 'dark'`
589589+590590+### `ThemeName`
591591+592592+`'light' | 'dark' | 'dim'`
593593+594594+### `themes`
595595+596596+Pre-built theme objects, ready to pass to `Provider`:
597597+598598+```typescript
599599+themes.light // Light scheme, DEFAULT_PALETTE
600600+themes.dark // Dark scheme, inverted DEFAULT_PALETTE, 0.4 shadow opacity
601601+themes.dim // Dark scheme, inverted DEFAULT_SUBDUED_PALETTE, 0.4 shadow opacity
602602+```
603603+604604+### `createTheme({ scheme, name, palette, options? })`
605605+606606+Builds a `Theme` from a palette.
607607+608608+| Param | Type | Description |
609609+|-------|------|-------------|
610610+| `scheme` | `ThemeScheme` | `'light'` or `'dark'` |
611611+| `name` | `ThemeName` | `'light'`, `'dark'`, or `'dim'` |
612612+| `palette` | `Palette` | Color palette to use |
613613+| `options.shadowOpacity` | `number` | Shadow opacity, defaults to `0.1` |
614614+615615+Returns: `Theme`
616616+617617+### `createThemes({ defaultPalette, subduedPalette })`
618618+619619+Builds all three theme variants at once. Inverts the palettes automatically for dark and dim.
620620+621621+| Param | Type | Description |
622622+|-------|------|-------------|
623623+| `defaultPalette` | `Palette` | Used for light and dark themes |
624624+| `subduedPalette` | `Palette` | Used for the dim theme |
625625+626626+Returns: `{ light: Theme, dark: Theme, dim: Theme }`
627627+628628+---
629629+630630+## Platform
631631+632632+### Detection booleans
633633+634634+These resolve at build time via platform-split files (`.native.ts` vs `.ts`).
635635+636636+| Export | Web | iOS | Android |
637637+|--------|-----|-----|---------|
638638+| `isWeb` | `true` | `false` | `false` |
639639+| `isNative` | `false` | `true` | `true` |
640640+| `isIOS` | `false` | `true` | `false` |
641641+| `isAndroid` | `false` | `false` | `true` |
642642+| `isFabric` | Runtime check: `Boolean(global?.nativeFabricUIManager)` |
643643+644644+### Platform selectors
645645+646646+Identity functions that return the value on the matching platform and `undefined` everywhere else.
647647+648648+```typescript
649649+web({ cursor: 'pointer' }) // returns the object on web, undefined on native
650650+native({ elevation: 4 }) // returns the object on native, undefined on web
651651+ios({ borderCurve: 'continuous' })
652652+android({ elevation: 8 })
653653+```
654654+655655+### `platform(specifics)`
656656+657657+Works like React Native's `Platform.select()`. On web, returns `specifics.web` or `specifics.default`.
658658+659659+```typescript
660660+platform({ web: 16, default: 12 })
661661+```
662662+663663+---
664664+665665+## Utils
666666+667667+Imported as a namespace: `import { utils } from '@bsky.app/alf'`.
668668+669669+### `utils.alpha(color, opacity)`
670670+671671+Converts a color string to a transparent variant at the given opacity (0 to 1).
672672+673673+```typescript
674674+utils.alpha('#FF0000', 0.5) // '#FF000080'
675675+utils.alpha('rgb(255, 0, 0)', 0.5) // 'rgba(255, 0, 0, 0.5)'
676676+utils.alpha('hsl(0, 100%, 50%)', 0.5) // 'hsla(0, 100%, 50%, 0.5)'
677677+```
678678+679679+Supported formats: `#RGB`, `#RRGGBB`, `rgb()`, `hsl()`. Returns the original color unchanged if the format is not recognized.
680680+681681+### `utils.leading(textStyle)`
682682+683683+Calculates a `lineHeight` value from a text style's `fontSize` and `lineHeight` multiplier.
684684+685685+```typescript
686686+utils.leading({ fontSize: 15, lineHeight: 1.5 })
687687+// Web: { lineHeight: '1.5' } (unitless string)
688688+// Native: { lineHeight: 23 } (rounded pixel value)
689689+```
690690+691691+Defaults to `tokens.fontSize.sm` and `tokens.lineHeight.snug` when values are missing.
692692+693693+Returns: `Pick<TextStyle, 'lineHeight'>`
694694+695695+### `utils.select(name, options)`
696696+697697+Theme-aware value selector. Pass a `ThemeName` and an object mapping theme names to values.
698698+699699+```typescript
700700+utils.select('dark', {
701701+ light: '#FFFFFF',
702702+ dark: '#000000',
703703+ dim: '#1A1A2E',
704704+})
705705+// Returns '#000000'
706706+```
707707+708708+You can use a `default` fallback instead of specifying every theme:
709709+710710+```typescript
711711+utils.select('dim', { light: 'white', default: 'black' })
712712+// Returns 'black'
713713+```
714714+715715+### `utils.flatten(style)`
716716+717717+Merges a style array (or nested arrays) into a single object. Filters out falsy values.
718718+719719+```typescript
720720+utils.flatten([a.flex_row, a.gap_md, false && a.p_lg])
721721+// { flexDirection: 'row', gap: 12 }
722722+```
723723+724724+On web, this uses a custom implementation. On native, it delegates to `StyleSheet.flatten`.
725725+726726+Returns: merged style object
727727+728728+---
729729+730730+## Types
731731+732732+### `TextStyleProp`
733733+734734+```typescript
735735+type TextStyleProp = { style?: StyleProp<TextStyle> }
736736+```
737737+738738+### `ViewStyleProp`
739739+740740+```typescript
741741+type ViewStyleProp = { style?: StyleProp<ViewStyle> }
742742+```
743743+744744+### `ShadowStyle`
745745+746746+```typescript
747747+type ShadowStyle = Pick<
748748+ ViewStyle,
749749+ 'shadowColor' | 'shadowOpacity' | 'shadowRadius' | 'elevation' | 'shadowOffset' | 'boxShadow'
750750+>
751751+```
752752+753753+---
754754+755755+## Native Overrides
756756+757757+When running on iOS or Android, these atoms behave differently from their web counterparts:
758758+759759+| Atom | Web | Native |
760760+|------|-----|--------|
761761+| `fixed` | `position: 'fixed'` | `position: 'absolute'` |
762762+| `sticky` | `position: 'sticky'` | Empty object |
763763+| `overflow_auto` | `overflow: 'auto'` | Empty object |
764764+| `flex_0` | `flex: '0 0 auto'` | `flex: 0` |
765765+| `border`, `border_t`, `border_b`, `border_l`, `border_r`, `border_x`, `border_y` | `borderWidth: 1` | `borderWidth: StyleSheet.hairlineWidth` |
766766+| `curve_circular` | Empty object | iOS: `borderCurve: 'circular'` |
767767+| `curve_continuous` | Empty object | iOS: `borderCurve: 'continuous'` |
768768+| `shadow_sm`, `shadow_md`, `shadow_lg` | Empty object | Shadow props with `elevation` (Fabric: empty) |
769769+| `inline`, `block` | `display: 'inline'` / `'block'` | Empty object |
770770+| `pointer` | `cursor: 'pointer'` | Empty object |