# NEO: guide for coding agents and contributors NEO is the web and native client for [UNA CMS](https://unacms.com). Shared code in `packages/app` runs as a Next.js site (`apps/next`) and as an Expo app for iOS and Android (`apps/expo`). This file covers how to change NEO; [README.md](README.md) covers setup, environment variables and deployment. Paths are relative to the NEO root: `neo/` in the UNA repo, or the repository root in a fork. ## Upstream or fork: check before you change anything | | Upstream NEO | Client fork | |---|---|---| | Repository | `neo/` in [unacms/UNA](https://github.com/unacms/UNA) | a fork of [unacms/neo](https://github.com/unacms/neo) | | How to tell | the folder above `neo/` holds UNA's `api.php`, `inc/` and `modules/` | `apps/` and `packages/` are at the repository root | | What you change | anything under `neo/` | only `packages/app/customization/` (see [Forks](#forks-and-the-customization-layer)) | | Where it lands | a PR to unacms/UNA from a branch off `master` | the fork's own branches | If a fork needs a change outside `customization/`, make it upstream as a PR to unacms/UNA, then sync it into the fork. Patching shared code in a fork makes every later sync conflict, and other clients never get the fix. ## Upstream workflow - NEO is a git subtree of the UNA repo since October 2026. The rest of that repo is the UNA backend (PHP), so one PR can change an API and the NEO code that calls it. UNA's own agent notes are in the root `AGENTS.md`; the [`una-api` skill](.agents/skills/una-api/SKILL.md) covers what NEO needs from UNA services. - Branch from UNA `master` and open the PR against unacms/UNA. Start the title of a NEO-only PR with `Neo:`, for example "Neo: allow only http(s) URLs in inline post embeds" ([#5787](https://github.com/unacms/UNA/pull/5787)). - unacms/neo is a mirror: on every push to UNA `master`, [`.github/workflows/sync-neo.yml`](https://github.com/unacms/UNA/blob/master/.github/workflows/sync-neo.yml) runs `git subtree push --prefix neo` to unacms/neo `main`. Never commit, merge or open PRs there; one commit on the mirror makes the next push non-fast-forward and stops the sync. There is nothing to `git subtree pull` from it either: it only holds what UNA already has, and pulling an outdated copy reverts newer UNA changes. - Run `yarn typecheck` and `yarn lint` from `neo/` before you push. UNA CI has no NEO job and the Next build ignores type errors. Neither command is clean on `master` yet, so compare with `master` and add no new errors. Upstream changes to `packages/app/customization/` reach every fork, so treat that folder as an interface: - Keep each customization file a short re-export of a core default. New behaviour and new defaults go into core (`default/`, `settings/`, components), where forks can override them. - Don't rename, move or delete a customization file, a default it re-exports (`settingsDefault`, `resourcesDefault`, `componentsMapDefault`, `skeletonsMapDefault`, `staticDefault`, `animatedIcons`, ...) or a function in `default/functions.js` without a fallback. Forks override them by name, and a rename silently orphans the override: renaming `config/images_allowlist.js` to `images-allowlist.js` did that to one fork in August 2026. - Never add a file that forks create for themselves: `customization/config/app.config.js`, `apps/expo/eas.json`, `apps/next/public/.well-known/*`. Each one would conflict in every fork that has its own. ## Forks and the customization layer Core code imports fixed paths under `app/customization/...`. Upstream, each of those files re-exports a default from core. A fork changes behaviour by changing what the file exports; nothing in the build swaps files. Rules for forks: 1. Edit only `packages/app/customization/**`. Files that upstream doesn't have are yours too: `apps/expo/eas.json`, `.github/workflows/*`, `apps/next/public/.well-known/*`. Leave `.gitignore`, `.easignore`, `apps/expo/app.config.js`, `apps/*/package.json` and all core code alone; put local ignores in `.git/info/exclude`. 2. Extend defaults instead of copying them: mutate or spread the default export, so later upstream additions still reach you. 3. Keep secrets out of git. Use `.env.local` locally and Vercel or EAS environment variables for builds. Never put `UNA_API_KEY` in `config/app.config.js` `extra`: everything in `extra` ships inside the app binary, and the native app doesn't use the key. 4. Take upstream changes by syncing from unacms/neo `main` (GitHub "Sync fork", or `gh repo sync / --branch main`). After a sync, check whether a seam you override was renamed or removed: `git diff --name-status --diff-filter=DR -- packages/app/customization packages/app/default`. 5. Write fork-specific notes for agents in `packages/app/customization/resources/agents.md`, below its "Per-project additions" line. Agents working in a fork must read that file too. | Seam | How to override | |---|---| | `settings.js` | Mutate nested keys of `settingsDefault` (`app/default/settings`, built from `app/settings/*.js`) in place, at module top level. Don't replace whole sections: some core code imports `settings/*` modules directly. | | `translation.js` | Add or change keys in `resourcesDefault[lang].translation`. | | `icons-svg.js` | Custom icons for web and native (inline SVG by name). They win over Lucide and over animated icons. | | `icons.js` | Native-only icon components. Web draws Lucide icons by name from `/lucide//.svg`. | | `animated-icons.js` | Spread the default registry and add keys; see [animated-icons.md](animated-icons.md). | | `static.js` | Static components. A `components_` key also replaces that page. | | `functions.js` | Re-exports `app/default/functions`. Declare a local export with the same name to replace one function. | | `loading.js`, `sounds.js`, `covers/`, `splash-mockups/`, `nav/footer.web.js` | Replace the export. | | `/_map.js` for elements, form-fields, forms, molecules, page-layout, menu-items, units, units/content-list, units/profile-list, skeletons, workers | Import `componentsMapDefault` from the core `_map.js`, add or replace keys, re-export it. Put new components under `customization//`. | | `design/styles/global.css`, `global.web.css` | CSS variable and `@theme` overrides (both platforms; the second is web only). Neutral fills follow your `--neutral-*` scale through `--ink`; set `--ink` in your `@variant light` / `@variant dark` blocks to tint them differently, or redefine `--color-bubble` / `--color-button` / `--color-button-hover` in an `@theme inline` block (as `color-mix()`, see Styling) to change the steps. `--button` / `--button-hover` keep their old opaque values; only `bg-button-solid` still reads `--button`. On web, `.light` / `.dark` colour-scheme scopes (the header on a band, `layout.header.band_scheme`) re-declare core's semantic tokens, so set a semantic token you override on `:root` (`--accent-foreground`, ...) on `.light` / `.dark` in `global.web.css` too. | | `design/fonts/fonts-web.js`, `design/fonts/fonts.js` | Web `next/font` variables and the native `useFonts` map. | | `config/fonts.js` | Font family per role and platform; see [Fonts](#fonts). | | `config/next.config.js` | Deep-merged into `apps/next/next.config.js`. Arrays are concatenated. Functions (`webpack`, `rewrites`, `headers`) replace upstream's, so define one only if you reimplement upstream's. This is the fork hook despite the "don't edit" comment at its top. | | `config/app.config.js` | Create it. It's deep-merged into the Expo config, but `ios.infoPlist`, `ios.associatedDomains`, `android.permissions`, `android.intentFilters` and `plugins` replace upstream's when you set them. If you set `plugins`, copy upstream's full list (it includes `expo-build-properties` with `ios.enableSceneSupport`, which iOS 27 requires) and re-check it after every sync. A syntax error in this file is swallowed and the app builds as stock NEO. | | `config/images-allowlist.js` | Extra image hosts, used by `next/image` and at runtime. | | `config/package.next.js`, `config/package.expo.js` | Extra npm dependencies. The root `preinstall` script merges them into `apps/next/package.json` and `apps/expo/package.json`; don't commit those rewritten files, and restore them before a sync. | | `resources/web/` | Flat files copied into `apps/next/public/static/` (gitignored) whenever the Next config loads: `favicon.svg`, `manifest.json` and every icon the manifest names. | | `resources/native/` | App icon, splash and other native assets. Reference them from `config/app.config.js` as `../../packages/app/customization/resources/native/...`. | | `resources/sounds/` | Native interface sounds: Cuelume cues rendered to WAV, in every sound set, by `scripts/render-sounds.mjs`. Point keys of `sounds.js` at your own files; see [Interface sounds](#interface-sounds). | ## Layout and stack ``` apps/next Next.js app: route shells, proxy.js, next.config.js apps/expo Expo app: expo-router routes, app.config.js, config plugins in plugins/ packages/app shared code, imported as app/... design/ View, Text, controls (NeoButton), theme CSS, fonts ui/ atoms, molecules, primitives (headless), workers components/ block renderers (elements), units, forms, form-fields, menu-items, page-layout, nav context/, lib/ state, fetcher, hooks and helpers default/, settings/ defaults that customization/ re-exports customization/ fork overrides scripts/ typecheck.js, merge-package-customization.js, check-translations.js, render-sounds.mjs patches/ patch-package patches, applied on postinstall ``` - Next.js 16 with React 19.2, built with webpack (`next dev --webpack`), not Turbopack. `@expo/next-adapter` aliases `react-native` to `react-native-web`. `typescript.ignoreBuildErrors` is on and `reactStrictMode` is off. `apps/next/proxy.js` is Next 16's middleware. Next ships its docs in `node_modules/next/dist/docs/`; `apps/next/AGENTS.md` (written by `next dev`) points there. - Expo SDK 57 with React Native 0.86 (New Architecture only), expo-router and Hermes. `apps/expo/ios` and `android` are generated by prebuild and gitignored. A change to `app.config.js`, a config plugin or a native dependency needs a prebuild and a native rebuild. - Styling: Tailwind CSS 4 on web and Uniwind (Tailwind 4 for React Native) on native. Tailwind is pinned in the root `resolutions` because 4.3.3 broke Uniwind breakpoints ([uni-stack/uniwind#668](https://github.com/uni-stack/uniwind/issues/668)). - TypeScript 6, Node 24, Yarn 1 workspaces. No local script runs Turborepo, but Vercel builds through it, and its strict env mode hides any variable that `turbo.json` `globalEnv` doesn't list. Add a new environment variable the web build reads to that list. - `yarn.lock` is gitignored, so every fresh `yarn install` re-resolves the ranges in `package.json`, native modules included. Don't change a dependency range in passing. To check what is installed, run `node -p "require('next/package.json').version"` from `neo/`; declared ranges differ from installed versions. - Run everything through the root yarn scripts (`yarn web2`, `yarn native`, `yarn ios`, `yarn android`, `yarn build`, `yarn typecheck`, `yarn lint`; see [README.md](README.md)). They load `.env.local` through dotenv. Running `next` or `expo` directly skips it, and Expo bakes the variables into `expo.extra` at native build time. ## How a page renders - UNA decides the routes. Each app has one catch-all route (`apps/next/app/[...path]/page.js`, `apps/expo/app/[...path].tsx`, plus the tabs in `apps/expo/app/(tabs)/tab0` to `tab4`). It asks UNA for the page with `system/get_page_by_request/TemplServicePages` and renders the returned blocks. - Blocks render through the component registry: `components[type][name]` from `app/components/registry`. It is a plain object on purpose; a `getComponent()` call trips `react-hooks/static-components`. `components/registry-init.js` registers each type from its customization `_map.js`. Default maps load components with `next/dynamic`; on native, `apps/expo/babel-inline-dynamic-imports.js` and the Metro shim turn those into `require` calls. - On web, `[...path]/page.js` is a Server Component. It fetches the page JSON with `fetch` (`cache: 'no-store'`), sending `Authorization: Bearer ` and the viewer's cookies. React `cache()` shares that request between `generateMetadata` and `Page`, and `apps/next/lib/una-page-cache.js` keeps results per URL, tenant and cookie for `appSetting('cache', 'una_page_ttl')` seconds (20; 30 for profiles). It renders `Root` from `app/root-client` inside `Suspense`. - `cacheComponents` is off, so `'use cache'`, `cacheLife` and PPR don't work here. Don't add them. - All other data is fetched on the client with `fetcher`, because the same code runs in Expo, which has no server. - The client boundary is `app/root-client.js`, `app/root.js` and `apps/next/app/providers.js`. Most of `packages/app` has no `'use client'` and needs none. Don't remove the directives that exist, and don't turn shared code into Server Components. ## Talking to UNA - Call UNA through `fetcher` from `app/lib/fetcher`: - `fetcher(path)` does a GET; `fetcher([path, token, body])` POSTs `body`. The full signature is `fetcher(req, useProxy = false, { signal, timeoutMs, maxAttempts, silent, onUploadProgress, keepalive })`. - It appends `&lang=`, so the path must already contain `?r=`. - It returns UNA's envelope `{status, module, method, params, data, hash}`; read `.data`. A body that isn't JSON comes back as `{}`. - GETs are retried three times on network errors, never on a timeout (`config.fetch_timeout_ms`, 30 s). - On web it calls same-origin `/api/...`, and `proxy.js` forwards that to UNA with the API key and the viewer's cookies. Native calls `UNA_URL` directly with `Origin: APP_ORIGIN` and the session cookie. - Routes are `/api.php?r={module}/{method}/{class}` with `params[]=...` or `params=[...]`; the class defaults to `Module`, system services use `Templ*` classes. UNA only allows services marked safe or public. Many endpoints come from UNA data (`'/api.php?r=' + data.request_url`); fixed ones live in `appSetting('urls', ...)`. - `fetcher` sends `X-Neo-Platform` (`web`, `ios`, `android`; web only through the same-origin proxy), so UNA's analytics records site actions with the right client; when the site keeps persistent visitor ids it also sends `X-Analytics-Vid`, so those actions join the device's visit. Pageviews and screen views go to UNA's analytics module from `AnalyticsTracker` (`app/lib/analytics`, mounted in `apps/next/app/providers.js` and the Expo tabs layout); send custom events with `trackEvent(name, props)` from the same module. - Three places bypass `fetcher` on purpose: the web page shell above, AI chat streaming (`ui/molecules/ai-agent/helper.js`) and direct uploads (`lib/util/upload.ts`). - If the page JSON can't be parsed (a PHP fatal or an HTML error page), web shows the maintenance screen for the whole page and home falls back to a static splash. Fix the UNA service; the [`una-api` skill](.agents/skills/una-api/SKILL.md) lists the rules, the usual cause being a block that fails for guests. - Auth: `useCurrentUser()` from `app/context/user` returns `{ currentUser, setCurrentUser }`. `currentUser` is `null` until known, `false` for a guest and an object when signed in. `Root` and the layouts hold rendering until it is known, so components rarely need to wait themselves. The guest sign-in prompt is `layout.show_login_modal`. - The UNA session cookie identifies the viewer. `UNA_API_KEY` identifies the NEO server to UNA and stays on the server (`proxy.js` and the page shell). Never expose it to client code or put it in `expo.extra`. - Real-time updates: `subscribe(channel, event, callback)` from `app/ui/atoms/socket` returns an unsubscribe function; return it from a `useEffect`. The server is Soketi (Pusher protocol), configured in `config.sockets`. Channels include `bx_timeline_0` (`added`, `deleted`, `edited`), `cmts__` (`comment_added`, `comment_edited`, `comment_deleted`), `profile_` and `sys_connections_` (`changed`), and `bx_messenger` (`convo_`, `profile_`). ### Images Never pass an original upload (`src_orig`) to `Image` or `next/image`, on web or native. UNA transcodes images; NEO only resizes the display URL further. 1. Pick the URL with `pickUnaDisplaySrc` / `toUnaDisplayImageItem` from `app/lib/image-helpers` (`src`, then `medium`, `small`, `thumb`, `file`). 2. Render through `app/ui/atoms/image` only. Web uses `next/image`; native requests the same optimizer at `${native_app_images_url}/_next/image`. 3. Spreading a UNA object (``) is safe because `getImageSrc` ignores `src_orig`. When you flatten a list, call `toUnaDisplayImageItem` so the original isn't copied into `src`. 4. `useOriginalImage` in the atom means "the optimizer request failed, use the UNA display URL", not `src_orig`. ## Code conventions - Write new files in TypeScript (`.ts` / `.tsx`) with typed props. When you substantially rework a `.js` file, `git mv` it to TypeScript first so history follows. - File names are kebab-case; shared prop types go in `.types.ts`. - Platform files: the base file is the native or shared version, `.web.*` replaces it on web, and `.ios.*` / `.android.*` hold platform-only native code. A platform pair shares props through `.types.ts`; don't import types from the base file, because `./video` inside `video.web.tsx` resolves to `video.web.tsx` itself. - `yarn typecheck` (`scripts/typecheck.js`) checks every `.ts` / `.tsx` file in `packages/app` and the apps once per platform, resolving `.web` / `.native` / `.ios` / `.android` the way webpack and Metro do. Plain `tsc` misses platform files, and JS isn't typechecked at all. The native run uses the root `tsconfig.json` with `strict`, `strictNullChecks` and `noUncheckedIndexedAccess`, so `items[0]` is possibly `undefined`. - Keep these as JavaScript: `packages/app/settings/**` (config data read by string path through `appSetting`; `settings/images-allowlist.js` is also required by `next.config.js`), `packages/app/customization/**` (the fork surface), and `lib/image/circle-png.js`. - Imports: shared code is `app/...`. Use `View`, `Pressable`, `ScrollView`, `Row` from `app/design/view`, `Text` and `H1`–`H6` from `app/design/typography`, controls from `app/design/controls`, `appSetting` and `isWeb` from `app/lib/util`. Don't import these primitives from `react-native` in shared code. - Check for an existing component before writing one, and wrap a new third-party UI library in `ui/atoms` or `ui/molecules` before features use it. - Lazy loading: `lib/lazy-component.tsx`, or `next/dynamic` (the Metro shim makes it work on native). ### React Compiler The React Compiler is on in both apps: `reactCompiler: true` in `apps/next/next.config.js` (client bundle only) and `experiments.reactCompiler: true` in `apps/expo/app.config.js`. Shared code in `packages/app` is compiled by both. - The compiler skips any component or hook that breaks the Rules of React and leaves it as written. `yarn lint` reports these as `react-hooks/*` errors (`refs`, `immutability`, `static-components`, `preserve-manual-memoization`, ...); fixing them is how a component gets compiled. - Don't mass-remove `useMemo`, `useCallback` or `memo`: in a skipped component they still do their job. Drop manual memoization only in files that lint clean, and only when it serves no other purpose (a stable callback for a subscription, for example). - `'use no memo'` at the top of a file or component body opts it out. A fork can turn the compiler off with `reactCompiler: false` in `customization/config/next.config.js` and `experiments: { reactCompiler: false }` in its `customization/config/app.config.js`. - Code that mutates an object from props or state in place and expects children to re-render shows stale UI: memoized children see the same reference. Create new objects. - Don't dispatch no-op state updates from effects (`setState(prev => prev)`). While a fiber still holds a low-priority update (idle hydration of the page `Suspense`, for example), React can't bail out early, and a no-op dispatch keeps a sync render loop going. See `MenuTop` in `components/nav/menu-top.js`. ### Icons Render icons with `` from `app/ui/atoms/icon`, using Lucide names. Web draws them as CSS-mask SVGs from `/lucide//.svg` (generated by `apps/next/lib/generate-lucide-icons.js` from `lucide-static`); native uses `lucide-react-native` through the icon set. Never import `lucide-react`; it isn't installed. Animated icons: [animated-icons.md](animated-icons.md). ## Styling - Design tokens (colours, radii, shadows) are the Tailwind `@theme inline` map in `packages/app/design/styles/theme.css`, with light and dark values in `palette.css`. `apps/next/tailwind.config.js` and `design/tailwind/theme.js` are not loaded by any build; a token added there does nothing. - Settings hold class presets per component (`settings/theme/*.js`), read with `appSetting`. Use tokens and existing presets, not raw colours or arbitrary values. - Neutral fills are translucent "ink", so they stack instead of blending: `bg-bubble` (comment bubbles), `bg-button` and `bg-button-hover` are the scheme's `--ink` at 4.5%, 7% and 10%. A button on a bubble on a card is one even step darker than each layer below, and text keeps AA on every stack. `--ink` is `--neutral-950` in light and `--neutral-50` in dark (`palette-schemes.css`); the alphas are `color-mix()` in `theme.css`, one value for both schemes. - Write an alpha only as `color-mix()` or an opacity modifier (`bg-button/50`). Uniwind compiles `rgb(var(--x) / 0.07)` and a variable holding `r g b / a` into `NaN` on native, so the colour silently disappears there. - Native buttons read the same tokens through `theme.expo_ui.button` (`settings/theme/expo_ui.js`): iOS bordered (`tint`) and Android tonal take the translucent `bg-button`. Android's elevated button (the `glass` style) draws its shadow under the container, so it uses the opaque `bg-button-solid` (the legacy `--button`) instead. - Edit the style partials in `design/styles/` (`theme.css`, `palette.css`, `utilities.css`, `global.css`), not `apps/expo/global.combined.css`, which only imports them for Uniwind. - Variants: `web:`, `native:`, `ios:` and `android:` are defined in `design/styles/global.css`, as is `dark:`. Native switches themes with `Uniwind.setTheme`. For a JS value per theme, use `useThemeValue(light, dark)` from `app/design/theme`. - Breakpoint tiers are mobile (no prefix), tablet (`sm:`) and desktop (`lg:`), as in `lib/responsive-classes.ts`; there is also `3xl:`. Lists size by container queries (`@container/list`, `@list-sm/list:`). - Cards, blocks, buttons and inputs draw their outline with a shadow (`shadow-card-outline`, `shadow-block-outline`, `shadow-btn-outline`, `shadow-input-outline`). Always pair it with the dark variant, `dark:shadow-*-deep`. - Class names must be static strings or come from static mapping objects; Tailwind and Uniwind only see classes written out in scanned files. - In files native scans, don't use CSS-variable colours inside `box-shadow`, and don't use `calc()` that mixes `%` with a length. Put those in web-only files. - `withUniwind` is only for third-party components; core `View`, `Text`, `Pressable`, `Image` and `TextInput` take `className` already. - Measure only when behaviour depends on rendered size, and then with `onLayout` (web emulates it in `design/view.web.tsx` with a `ResizeObserver` only for components that pass it). Avoid feature-level DOM measurement. ### Text styles on native On React Native, text styles don't cascade from a `View` or `Pressable` to the `Text` inside. Put text classes on the `Text` itself: ```tsx // Wrong: the text stays black on native Label // Right Label ``` Settings follow the same split, with separate container and text presets (for example `theme.accordion.trigger` and `trigger_text`). ### Overflow and clipping Outlines are shadows, and shadows paint outside the element box, so any clipping ancestor cuts them off (most visibly on the first and last grid columns). - Blocks never clip. `BlockWrapper`, `Block` and `BlockContent` don't set `overflow-hidden`; `BlockWrapper`'s `clip` prop only works together with `fill`. Don't turn it on to hide a layout bug. (Blocks with `overlayHeader` are the exception.) - Every web `ScrollView` clips both axes: `.neo-sv` is `overflow-y: auto; overflow-x: hidden`, and CSS can't scroll one axis and leave the other visible. Don't wrap block content in a `ScrollView`; page blocks already live inside the page scroller. - Put `overflow-*` or a `ScrollView` on the one element that really scrolls or clips (a message list, a table, a fixed-height pane). For modals, use `` instead of nesting a `ScrollView`. - Give shadows room inside a scroller: pad the content and cancel the padding outside, for example ``. - Clipping leaf elements is fine: rounded media (`rounded-xl overflow-hidden`), avatars, progress bars, `truncate`. ### Block design box per breakpoint A block's chrome (background, padding, title) comes from UNA's `designbox_id`, which is one setting for every screen size. The block's App config (Studio → Developer → Pages → block → "Config (for App in JSON format)", sent as `config_api`) can set it per tier: ```json { "designbox": { "bg": ["tablet", "desktop"], "padding": ["mobile", "tablet", "desktop"], "title": ["desktop"], "rounded": ["tablet", "desktop"] } } ``` - Each key lists the tiers where that part shows: `mobile` (no prefix), `tablet` (`sm:`), `desktop` (`lg:`). `true` and `false` mean every tier or none. - `solid` lists the tiers where the background is the opaque card fill (theme `u-block-bg-solid`) instead of the translucent `u-block-bg` one. Use it for a block among solid cards, such as the home "Add new..." block above the feed on phones (`"solid": ["mobile"]`). The classes come from `lib/block-bg-classes.ts`; run its tests with `node --test packages/app/lib/block-bg-classes.test.ts`. - A missing key keeps `designbox_id`. Props passed from code (`showBg`, `showPadding`, `showTitle`) win over both. - Keys sit under `designbox` because every `config_api` key is also spread into each element of the block as a prop. - `components/block-wrapper.js` reads them and `ui/molecules/page/page-block.js` turns tiers into classes through `responsiveClasses`. Prefixed classes must stay listed in `@source inline(...)` in `design/styles/utilities.css`; update that list if `theme.blocks` `u-block-bg` or the block padding changes. - List (browse) blocks skip `BlockWrapper`. The list endpoint carries their `designbox`, and `paddingForList` (`default/functions.js`) turns `bg` / `rounded` into a card around the list. This is web only: a native list scrolls under the overlaid page header. - Use this rather than per-page or per-block overrides in code: block ids and pages differ between UNA sites. ### Fonts Text uses two roles, `font-main` (the default in `design/typography.tsx`) and `font-title` (headings). Pick the family per role and platform in `customization/config/fonts.js` (defaults in `design/fonts/font-config.js`), for example `main: { web: 'inter', ios: 'system', android: 'inter' }`. - `'system'` is the platform UI font: SF Pro on iOS, the device sans-serif on Android, the OS font stack on web. - Any other value is a key of `design/fonts/font-families.js` (`'inter'`). Native needs one static file per weight (iOS and Android pick the face by `fontWeight`; variable fonts don't work). Web needs a `next/font` instance per role in `webFontFamilies` (`design/fonts/fonts-web.web.ts`). - iOS and Android values are build settings: `apps/expo/plugins/with-app-fonts.js` embeds only the selected families, so changing them needs a prebuild and a native rebuild. At runtime `design/fonts/native-fonts.ts` points the Uniwind font variables and the navigation header fonts at the family. - Don't set `fontFamily` inline; use `font-main` / `font-title`. Expo UI (SwiftUI / Compose) views are the exception and take `nativeFonts.main`. ## Components ### Buttons Use `NeoButton`, `NeoButtonLink` or `NeoButtonRef` from `app/design/controls`. The legacy `Button`, `ButtonRef` and `ButtonLink` are deprecated. - Axes: `style` (`plain`, `bordered`, `borderedProminent`, `borderless`, `outline`, `link`, `glass`, `glassProminent`), `role` (`default`, `cancel`, `close`, `confirm`, `destructive`), `controlSize` (`mini`, `small`, `regular`, `large`, `xlarge`), `borderShape` and `tint`. - Code lives in `design/controls/neo-button/`: props in `neo-button.types.ts`, scope and style precedence in `neo-button-resolver.tsx`, the legacy variant mapping in `control-scale.ts`. The theme is `neo_button` in `settings/theme/buttons.js`. - On iOS and Android, `native.expo_ui_buttons` (`settings/configs.js`) renders buttons with native Expo UI (SwiftUI / Compose) views; the `expoUI` prop controls it per button. - `NeoButtonGroup` joins buttons into one control with the same `style`, `controlSize`, `borderShape`, `role` / `tint` and `width` axes. The group paints the style's surface once (no border: outline and glass are shadow rings); each NeoButton inside becomes a segment through context, with the group's style and size, only the state washes, square inner corners and no hit area over its neighbours. Segments always use the JS surface; a `glass` group on iOS 26+ draws native liquid glass behind them. Recipes are `neo_button.group` in `settings/theme/buttons.js`. Object menus (reactions, likes, scores) build theirs through `ButtonsGroupMenu`, which uses `group.defaultStyle`. - `/pg` is a playground for every axis (`apps/next/app/pg/page.js`, `apps/expo/app/pg/`). It isn't gated, so production builds serve it too. ### Interface sounds Sounds are [Cuelume](https://cuelume.dev/agents.md)'s cues, one per interface job. Pick a cue by its job from Cuelume's "Which cue?" table: subtle emphasis for frequent actions, strong only for rare ones that matter. Never play one on hover, on page load, per item of a burst, or for a number that updates constantly. Everything is in `app/lib/sound`. - Web synthesizes cues with the `cuelume` package. `InterfaceSounds` (`ui/molecules/system/interface-sounds.tsx`, mounted once in `apps/next/app/providers.js` and `components/nav/tabs/index.js`) calls `bind()` and applies the settings. - Native plays WAV files rendered from the same package version, in each of Cuelume's four sets (themes): `Sounds` in `default/sounds.js`, keyed `[-][-][-back]` (the default set has no prefix). A cue without its own key falls back to a plainer key, then to the default set's, then to the pre-Cuelume key for its job (`click`, `tab`, `success`, `notif`). After bumping `cuelume` (pinned in `package.json`), or to give native a new variant, add it to `CUES` in `scripts/render-sounds.mjs`, run `node scripts/render-sounds.mjs` (needs Chrome; mono 32 kHz, every set) and add the keys to `default/sounds.js`. - A press: `cueAttributes(cue)` on the pressed element (web; `bind()` plays it as the pointer goes down) plus `playPressCue(cue)` in the handler (native). NeoButton has a `sound` prop: by default a button with haptics taps (subtle unless prominent), a menu trigger opens or closes, `close` / `cancel` roles close. `PressCueContext` changes the cue for the buttons inside (menu items select). Native buttons answer with haptics only; native plays cues for toggles, disclosures, the tab bar and outcomes. - An outcome: `playCue('success', { emphasis: 'subtle' })` once UNA has saved, `playCue('error')` when it refuses. `Form` already does this for every submit with a reply (not for filters or save-on-change forms). - Kinds of action: UNA's settings can give buttons, choices, reactions, menus, navigation, results and notifications their own set, or no sound. A cue's kind comes from the cue (`cueGroup` in `lib/sound/cues.ts`); a like, reaction, rating, favorite or follow plays `toggle` with `group: 'reactions'` (`cueAttributes` turns that into `data-bx-sound-group`). - Settings: UNA's Studio > Settings > Global > Sounds (`BxDolSounds`) picks where sounds play (`neo_web`, `ios`, `android`), the set, the volume, the level people start at and the set per kind of action. They come with every page as `data.sounds`; `Layouts` passes them to `applySoundConfig` (`lib/sound/config.ts`), which keeps them in memory and storage. Sounds only read that copy, so nothing waits on a request, and changes apply from the next page. On web, a capture listener (`gatePress` in `sound.web.ts`) gives each press its kind's set or silence before `bind()` plays it. - Users pick their own level, full, quiet (40%) or off (`useSoundsLevel`, in the footer and the dashboard settings), stored as `layoutSettings.sounds`; unset follows UNA's starting level. `layout.sounds` turns sounds off for the whole build. Native interface sounds follow the iOS silent switch. - `useSound` / `playSound` with the old names still work; they play the cue for that job. ### Page header The page header is mounted once per `Layout` (`ui/molecules/header/page-header.js` / `.web.js`). A page never writes header state from an effect; it declares what it needs in its render tree with `PageHeaderOptions` from `app/ui/molecules/header/options`: ```tsx // sub row on mobile only (feed switcher, tab bar) // item page } /> // replace the whole bar // header actions