Files
amethyst/.claude/CLAUDE.md
T
Claude 150177f5a0 fix(strings): move the cordn and backup strings to commonsUI, and write the rule down
The 2026-09-22 move left Android res/ with only the ~190 strings a platform
API reads synchronously. The cordn UI and the backup-conflict review then
added 447 keys back to it. All of them move with tools/strings-migrate
(1,131 elements across the locales that translate them), and the 18 files
that read them now use Res.string/Res.plurals.

The parts that were not a rename:
- stringRes(context, ...) in the suspend attachment/voice senders becomes
  loadStringRes; adminFailureText becomes suspend (its only caller is already
  inside scope.launch).
- The backup review held labels as @StringRes Int (DiffGroup.label, the
  ReviewRow *Res fields, eventTypeName/Explainer, the tile/hero/lane params).
  They are StringResource / PluralStringResource now.
- rememberPresentation resolved labels inside remember {} through
  LocalResources. Compose resources have no blocking accessor, so the 16
  labels presentationOf writes into item text are resolved in composition
  into a map, and that map is the remember key.
- androidx pluralStringResource -> the compose-resources one.

The rule is now in .claude/CLAUDE.md ("Strings"), commonsUI/ARCHITECTURE.md
and the android-expert and find-missing-translations skills: new strings go
in commonsUI composeResources, Android-only screens included.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjQQN9CgWHVtNCSWqnKoqY
2026-09-26 21:13:01 +00:00

448 lines
25 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Amethyst
## Project Overview
Amethyst is a Nostr Client for Android that was made for Android-only and has been slowly switching
over to a Kotlin Multiplatform project. The main modules are: `quartz`, `commons`, `commonsUI`, `amethyst`,
`desktopApp`, `cli`, plus the audio-rooms transport stack `quic` + `nestsClient`. Quartz should
contain implementations of Nostr specifications and utilities to help implement them — NIPs under
`nipXX` packages, and whole non-NIP protocol families beside them: `marmot/` (MLS over Nostr,
`mipXX`), `cordn/` (MLS over an MCP coordinator, `specXX`), `contextvm/` (MCP over Nostr, `cepXX`),
`concord/` (`cordXX`), `buzz/`, plus the binding-agnostic RFC 9420 engine in `mls/`. A new protocol
over Nostr belongs here as a package, not as a Gradle module; `quic`/`nestsClient`/`marmotQuic` are
modules because they are transports with no Nostr in them. Commons stores
shared code between Amethyst Android (`amethyst`) and Amethyst Desktop (`desktopApp`). The Desktop
App is designed to be mouse first and so uses a completely different screen and navigation
architecture while sharing the back end components with the android counterpart. `cli` ships `amy`,
a non-interactive JVM command-line client that drives the same `quartz` + `commons` code — used by
humans, agents, and interop tests. `quic` is a from-scratch pure-Kotlin QUIC v1 + HTTP/3 +
WebTransport client (no JNI, no BouncyCastle), built because no Android-compatible Java QUIC library
exists. `geode` is a standalone JVM Nostr relay (Ktor) built on quartz's
relay-server code; smaller modules are `benchmark` (Android macrobenchmarks),
`relayBench` (head-to-head relay benchmark — boots geode, strfry and other
relay binaries, replays a shared deterministic corpus, measures ingest/query/
NIP-77 sync; `./relayBench/run.sh`, see `relayBench/README.md`) and
`quic-interop` (QUIC interop runner, lives at `quic/interop`). `marmotQuic` is the Marmot raw-QUIC transport
binding for agent text stream previews (`transports/quic.md`) on top of
`:quic` — its own ALPNs and framing, not WebTransport. `nestsClient` runs
the audio-room protocol on top of `:quic` for the NIP-53 audio-rooms feature. It implements both IETF `draft-ietf-moq-transport-17` (under
`moq/`) and **moq-lite Lite-03** (kixelated's variant, under `moq/lite/`); the
production listener AND speaker paths both run on moq-lite to interop with the
nostrnests reference relay. The IETF code is kept as a reference + unit-test
implementation for any future IETF target; see
`nestsClient/plans/2026-04-26-moq-lite-gap.md`.
Canonical NIP specs live at <https://github.com/nostr-protocol/nips> — use
`/nip <number>` to pull a specific one (it fetches the spec file directly).
## Verify, Don't Guess
Don't assert a diagnosis you haven't reproduced. This repo gives you cheap
verification tools: `./gradlew test`, per-module test suites, and the `amy`
CLI (built partly to drive `quartz`/`commons` for interop checks). If a
claim is checkable in under a minute, check it before stating it — write
the failing case first, watch it fail, then explain.
## Architecture
```
amethyst/
├── quartz/ # Nostr KMP library (protocol only, no UI)
│ └── src/
│ ├── commonMain/ # Shared Nostr protocol, data models
│ ├── androidMain/ # Android-specific (crypto, storage)
│ ├── jvmMain/ # Desktop JVM-specific
│ └── iosMain/ # iOS-specific
├── commons/ # Shared HEADLESS layer (models, state, ViewModels, relay client) — CLI-safe
│ └── src/
│ ├── commonMain/ # Domain models, state holders, ViewModels, services
│ ├── jvmAndroid/ # JVM-bound services shared by Android + Desktop
│ ├── androidMain/ # Android-specific actuals (Keystore, DataStore)
│ └── jvmMain/ # Desktop-specific actuals (keyring, upload pipeline)
├── commonsUI/ # Shared Compose UI on top of commons (composables, icons, theme, Coil, resources)
│ └── src/
│ ├── commonMain/ # Shared composables, icons, theme, composeResources (strings/fonts)
│ ├── jvmAndroid/ # Markdown renderer, Coil OkHttp fetchers
│ ├── androidMain/ # Android Coil bridge
│ └── jvmMain/ # Desktop Coil bridge (+ skikoMain shared with iOS)
├── quic/ # Pure-Kotlin QUIC v1 + HTTP/3 + WebTransport (audio-rooms transport)
│ └── src/
│ ├── commonMain/ # Protocol, frame/packet codecs, TLS state machine
│ ├── jvmAndroid/ # JCA-backed AEAD + UDP socket actuals
│ └── commonTest/ # RFC vector + adversarial tests
├── nestsClient/ # Audio-room client (production runs on moq-lite; IETF MoQ kept as reference)
│ └── src/
│ ├── commonMain/ # MoQ session, NestsListener, audio glue
│ └── jvmAndroid/ # Opus encode/decode, AudioRecord/AudioTrack
├── geode/ # Standalone JVM Nostr relay (Ktor) on quartz's relay-server code
├── desktopApp/ # Desktop JVM application (layouts, navigation)
├── amethyst/ # Android app (layouts, navigation)
└── cli/ # Amy — non-interactive CLI (JVM only, no Compose)
```
**Sharing Philosophy:**
- `quartz/` = Nostr business logic, protocol, data (no UI)
- `commons/` = Shared **headless** code for every front end (Android, Desktop,
iOS, and the headless `cli`): domain models, state holders, ViewModels, the
relay client, shared services. It may use the Compose *runtime*
(`@Stable`/`@Immutable`, snapshot state) but never Compose UI, Coil or
Compose resources — the build enforces this: `commons` has no such deps.
- `commonsUI/` = Shared **Compose UI** that ≥1 GUI front end renders
(composables, `ui/theme`, icons, robohash, Coil fetchers, markdown, the
`composeResources` strings/fonts and the generated `Res` class). Depends on
`commons` (as `api`); `cli` never depends on it. Files keep their
`com.vitorpamplona.amethyst.commons.*` packages — the split is a module
boundary, not a package rename. The package taxonomy, the CLI-safe / UI
boundary, and a "where does my code go?" guide are documented in
**`commons/ARCHITECTURE.md`** (+ `commonsUI/ARCHITECTURE.md`) — read them
before adding a new package or dropping code into either module.
- `quic/` = Transport library (QUIC + HTTP/3 + WebTransport); reusable for any
KMP project that needs MoQ. Has no Android-framework dependencies.
- `nestsClient/` = MoQ + audio-rooms client; takes `:quic` as transport,
Quartz for crypto, `MediaCodec` / `AudioRecord` / `AudioTrack` for audio.
- `marmotQuic/` = Marmot's raw-QUIC binding for agent text stream previews.
Takes `:quic` for the connection and `:quartz` for the record/envelope
codecs. Not WebTransport — the binding has its own ALPNs and writes frames
straight onto QUIC streams, so it deliberately does not reuse
`nestsClient`'s `WebTransportSession`.
- `amethyst/` & `desktopApp/` = Platform-native layouts and navigation
- `cli/` = Thin assembly layer over `quartz/` + `commons/` (no new logic
allowed). May also depend on `:geode` (for `amy serve`, which embeds the
standalone relay); never on `:commonsUI`, `:amethyst` or `:desktopApp`.
**Plans per module:** design docs for new subsystems live in the owning
module's `plans/YYYY-MM-DD-<slug>.md` (e.g. `cli/plans/`, `commons/plans/`).
The global `docs/plans/` folder is frozen — don't add new plans there.
## Android Runtime Processes (IMPORTANT — the app runs in TWO processes)
The Android app is **not single-process**. Android instantiates the one `Amethyst`
Application class (there is no per-process Application in the manifest) in **both**:
- **main** — the normal app: UI, account, signer, `LocalCache`, relay client.
`Amethyst.instance` (`AppModules`) is built here.
- **`:napplet`** — the sandboxed WebView host for NIP-5D napplets / NIP-5A nSites
(`NappletHostActivity`, declared `android:process=":napplet"`). It holds **no**
account or keys; `Amethyst.onCreate()` early-returns here so `Amethyst.instance`
is **left unset** (touching it throws `UninitializedPropertyAccessException`).
The sandbox runtime lives in its own module **`:nappletHost`** (depends only on
`:commons` + `:quartz`, **never** `:amethyst`) so it *cannot* import
`Amethyst`/`LocalCache`/`Account` — the broker-side (signer, gateways, registry)
stays in `:amethyst` and the two halves talk over Messenger IPC.
Consequences — don't get caught assuming one process:
- **Processes don't share memory.** Every `object`/companion/`static` is a
*separate copy per process*: `LocalCache` (an `object`), `NappletLaunchRegistry`,
etc. The populated `LocalCache` lives only in **main**; the sandbox neither
builds nor should reference it (a stray reference would lazily create a second,
empty cache there).
- **Don't assume `Amethyst.instance` exists.** Any code reachable from `:napplet`
(the host activity, content server, or an Application lifecycle callback like
`onTrimMemory`) must guard on the process and never reach for `instance`.
- **Cross-process state goes over Messenger IPC**, never a shared singleton — this
is why the broker (main) owns `NappletLaunchRegistry` and the sandbox only relays
an opaque token. See `amethyst/plans/2026-06-22-napplet-nsite-security.md`.
## Tech Stack
Exact versions live in `gradle/libs.versions.toml` (the source of truth — check
there rather than trusting a number copied here).
| Layer | Technology |
|-------|------------|
| **Core** | Quartz (Nostr KMP) |
| **UI** | Compose Multiplatform |
| **Async** | kotlinx.coroutines + Flow |
| **Network** | OkHttp (JVM) |
| **Serialization** | Jackson |
| **DI** | Manual / Koin |
| **Build** | Gradle + Kotlin Multiplatform |
## Skills
The full list of available skills (with descriptions and triggers) is injected
into every session, so it isn't duplicated here. Two kinds exist and are meant
to be used together:
- **Codebase-oriented** skills (`nostr-expert`, `compose-expert`, `feed-patterns`,
`account-state`, `amy-expert`, …) answer "where is X in Amethyst, what pattern
do we use here."
- **Technique-oriented** skills (vendored from `chrisbanes/skills`, e.g.
`compose-slot-api-pattern`, `kotlin-flow-state-event-modeling`) answer "what is
the correct Compose/Kotlin design." They complement, not replace, the codebase
skills: `compose-expert` tells you where shared composables live;
`compose-slot-api-pattern` tells you how to shape their public API.
## Feature Workflow
**CRITICAL: Check existing implementations first — most logic already exists.**
Before writing code, survey all modules (use Grep/Explore) for managers, caches,
state systems, filters, ViewModels, and composables that already do the job. Your
job is usually to **reuse** (`quartz` protocol/business logic), **extract**
(Android UI/ViewModels → `commons`), and add **platform-specific** layouts/nav —
not to duplicate existing managers, caches, or state.
Summarize the survey in your plan: for each component, note whether it's
reused as-is, extracted from `amethyst/` to `commons/`, genuinely new
(platform-specific only), or a duplicate of an existing pattern to avoid.
**Relay client ops already exist — don't hand-roll subscribe/REQ/publish loops.**
One-shot and high-level relay operations (fetch a set, fetch one, page past the
relay cap, publish-and-confirm, NIP-45 count, NIP-77 sync/reconcile) are
`INostrClient` **extension functions** in
`quartz/…/nip01Core/relay/client/accessories/` (+ `…/reqs/` for the flow/subscribe
helpers). Because they're extensions, they don't surface under "usages of
`NostrClient`" or in completion — grep that package (or read its `README.md`, which
catalogs them) before writing a new subscription/collect loop. Reuse `fetchAll`,
`fetchFirst`, `fetchAllPages`, `publishAndConfirm`, `count`, `negentropyReconcile`,
etc. instead of re-implementing them.
**Share vs keep platform-native:**
- **Share** → `quartz/commonMain/` (business logic, data models, protocol),
`commons/commonMain/` (**ViewModels** under `viewmodels/`, state holders,
relay client, services — headless) and `commonsUI/commonMain/` (major UI
components, icons, theme). ViewModels are platform-agnostic state + logic
(StateFlow/SharedFlow), so they belong in `commons`; anything that imports
`androidx.compose.ui`/`foundation`/`material3`, Coil, or `Res` belongs in
`commonsUI`.
- **Keep native** → screen composables/scaffolding (Desktop `Window` vs Android
`Activity`), navigation (sidebar vs bottom nav), platform interactions
(gestures, keyboard shortcuts), system integrations (notifications, file
pickers).
When extracting a composable: move it to `commonsUI/commonMain/` (see
`/compose-expert`), add expect/actual for any platform behavior (see
`/kotlin-multiplatform`), then point both Android and Desktop at the shared
version. `quartz/` is protocol-only — no composables.
## Build Commands
```bash
# Run desktop app
./gradlew :desktopApp:run
# Run Android app
./gradlew :amethyst:installDebug
# Build Quartz for all targets
./gradlew :quartz:build
# Run the JVM tests of every module, KMP ones included. KMP modules register no
# `test` task of their own; the root build aliases it onto their jvmTest.
./gradlew test
# A single module. For a KMP module name jvmTest directly:
./gradlew :quartz:jvmTest --tests "com.vitorpamplona.quartz.nip52Calendar.*"
# NOT covered by `test`: each KMP module's OTHER targets, notably
# :quartz:testAndroidHostTest (~3.9k tests, its own androidHostTest source set).
# Nothing runs it today - not `test`, not pre-push, not CI - and it has 4 known
# failures on main (NostrServerTest x3, LiveNegentropyIndexStoreTest x1), which
# is why the alias maps to jvmTest rather than allTests. Run it explicitly when
# touching relay-server or store code:
./gradlew :quartz:testAndroidHostTest
# Format code
./gradlew spotlessApply
```
## Dependency Licensing
**MANDATORY whenever you introduce a new third-party dependency** — in *any*
module (`quartz`, `commons`, `commonsUI`, `amethyst`, `desktopApp`, `cli`, `quic`,
`nestsClient`, …), whether you add it to `gradle/libs.versions.toml` or to a
module's `build.gradle.kts`: determine its license **before** wiring it in.
Amethyst ships under the **MIT** license, so a copyleft dependency linked into a
distributed artifact (APK, desktop binary) can force that artifact's
combined-work terms onto the whole project.
Verify against the dependency's actual `LICENSE`/`COPYING` file or its published
POM — **not from memory**. Then classify and act:
- **Permissive** (MIT, Apache-2.0, BSD, ISC, MPL-2.0, zlib, …) → **OK**,
proceed.
- **LGPL, or GPL/EPL with a linking / Classpath exception** → **WARN.**
Acceptable to link (the exception keeps our own code MIT), but call it out in
your summary so the human knows. Confirm the exception actually exists in the
LICENSE text — don't assume it does.
- **Stricter than LGPL** — GPL/AGPL **without** a linking exception, SSPL,
proprietary/commercial-only, or anything where the linking-exception check is
"no" → **STRONGLY WARN and STOP.** Do not add it silently. Surface it
prominently and **require an explicit call-out in the PR description** so a
maintainer makes the decision. Prefer a permissive alternative, a clean-room
implementation, or dropping the feature.
For any GPL-family hit the decisive question is always **"is there a linking
(LGPL/Classpath) exception?"** — that is what separates a WARN from a STOP.
(Example: TarsosDSP, GPLv3 with no exception, was removed from `amethyst` and
replaced with an in-house pitch shifter for exactly this reason.)
## Quartz KMP Structure
Quartz uses expect/actual for platform-specific implementations (e.g. crypto
backed by `secp256k1-kmp-jni-android` on Android and `secp256k1-kmp-jni-jvm` on
JVM). See `/kotlin-multiplatform` for the expect/actual and source-set patterns.
## Strings
**MANDATORY: every new user-visible string goes in `commonsUI` Compose resources**,
`commonsUI/src/commonMain/composeResources/values/strings.xml` — **never** in
`amethyst/src/main/res/values/strings.xml`, even for an Android-only screen. A
screen cannot move to `commonsUI` (or be rendered by Desktop) while its labels
live in Android `res/`, and every key added there has to be migrated back out
later. (The 2026-09-22 move emptied Android `res/` down to ~190 keys; the next two
features put 447 back.)
- Reference it as `Res.string.my_key` / `Res.plurals.my_key`, importing
`com.vitorpamplona.amethyst.commons.resources.Res` **and** the per-key accessor
`com.vitorpamplona.amethyst.commons.resources.my_key` (Compose resources generates
each key as an extension property).
- Read it with `stringRes(Res.string.x)` / `pluralStringRes(Res.plurals.x, n, …)`
in composition — the app-side `ui.stringRes` overloads delegate to the
commonsUI bridge (`commons/ui/StringRes.kt`), so one import serves both kinds.
- Outside composition there is **no blocking accessor**: use `loadStringRes(…)`
from a coroutine (or make the function `suspend`), or resolve the label in
composition and pass it down as a `String`. A `runBlocking` bridge is ruled out.
- Hold a string reference as `StringResource` / `PluralStringResource`, not a
`@StringRes Int`.
- Format arguments must be positional (`%1$s`, `%2$d`) — compose-resources does
not format bare `%s`/`%d`.
- Escaping is not Android's: write `'` and `"` bare (no `\'`), and never wrap a
value in quotes to keep its whitespace. `.claude/hooks/compose_escaping_check.py`
checks this before a push.
The **only** strings that stay in Android `res/` are the ones a platform API
reads synchronously under a deadline, where a `suspend` read cannot run: a
foreground service's `startForeground` notification, a notification channel
created during service startup, PiP `RemoteAction`s, napplet capability labels
read in the sandbox process, and `AndroidManifest.xml` / `res/xml` references.
Most of those are *copies* of a key that also lives in `commonsUI`. See
`commons/plans/2026-09-22-strings-to-compose-resources.md` ("the
synchronous-platform tier"). If you think a new string belongs in that tier,
say why in the PR.
To move keys that already landed in Android `res/`, use
`tools/strings-migrate/migrate.py <keys…>`. It moves every locale byte-for-byte,
so Crowdin sees a pure move. Then repoint `R.string.x` → `Res.string.x`.
## Icons
The Material Symbols font bundled at
`commonsUI/src/commonMain/composeResources/font/material_symbols_outlined.ttf`
is a **subset** that only contains the glyphs referenced from
`commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt`.
**MANDATORY:** Whenever you add a new icon — i.e. introduce a
`MaterialSymbol("\uXXXX")` codepoint that wasn't already referenced anywhere in
`MaterialSymbols.kt` — you MUST regenerate the subset font by running:
```bash
./tools/material-symbols-subset/subset.sh
```
Commit the regenerated `material_symbols_outlined.ttf` alongside your
`MaterialSymbols.kt` change. Without this step the new icon renders as tofu (□)
at runtime because the glyph is not in the bundled font.
Reusing a codepoint already present in `MaterialSymbols.kt` does NOT require
regenerating.
### Amethyst's own icons are also a font
The icons in `commonsUI/.../commons/icons/*.kt` (Like, Reply, Reposted, Zap, …) are
**also** compiled into a font, `composeResources/font/amethyst_icons.ttf`, and drawn
as glyphs via `AmethystIconGlyph`. Drawing an `ImageVector` rasterises its paths into
a per-instance cached layer, so a feed re-rasterised the same glyph once per card;
a glyph is a blit from the shared text atlas. Measured: frame P90 **-10.7%**,
overrun P90 **-17.4%** on the feed scroll benchmark.
**MANDATORY:** whenever you add or change an icon under `commonsUI/.../commons/icons/`,
regenerate the font *and* its codepoint table together:
```bash
python3 tools/icon-font/build_icon_font.py \
commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons \
commonsUI/src/commonMain/composeResources/font/amethyst_icons.ttf \
commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/AmethystIcons.kt
```
Both outputs must be committed together: codepoints are assigned in filename order,
so adding an icon renumbers the ones after it, and a stale `AmethystIcons.kt` then
points at the wrong glyph. Needs `fonttools` (`pip install fonttools`). The script
prints any icon it could not convert — an icon that is skipped must keep using its
`ImageVector`.
See `tools/material-symbols-subset/README.md` for details and
prerequisites (`pip install fonttools brotli`).
## Code Formatting
After completing any task that modifies Kotlin files, always run:
```
./gradlew spotlessApply
```
Do this before considering the task complete.
### Kotlin Style
- **Never write fully-qualified class names inline in function bodies.** Add an
`import` for the class and reference it by its simple name. Write
`Event` (with `import com.vitorpamplona.quartz...Event`), not
`com.vitorpamplona.quartz...Event` in the middle of code.
- The only acceptable inline fully-qualified names are: a genuine name
collision (prefer `import ... as Alias` instead), or where the language
requires it. Comments, KDoc, and string literals are exempt.
- **Prefer the `androidx.core` KTX extension over the raw platform Java call**
when one exists — this is what Android Lint's `UseKtx` flags. Common swaps:
`Bitmap.createBitmap(w, h, cfg)` → `createBitmap(w, h)`,
`Bitmap.createScaledBitmap(src, w, h, f)` → `src.scale(w, h, f)`,
`Uri.parse(s)` → `s.toUri()`, and `prefs.edit()…apply()` → `prefs.edit { }`.
Only adopt the KTX form when it's behaviour-preserving: keep any explicit
argument that differs from the extension's default (a non-`ARGB_8888`
`Bitmap.Config`, `scale(filter = false)`), and leave calls the KTX has no
equivalent for (e.g. the `createBitmap` pixels/matrix overloads, or a
conditional-`apply()` editor loop) untouched.
- **This "prefer the KTX sugar" rule does NOT extend to collection operators.**
The KTX preference is about platform wrappers (`Bitmap`/`Uri`/`SharedPreferences`),
which compile to the identical call. Collections are the opposite: in hot
event/parse paths Quartz deliberately uses raw JVM arrays (`TagArray =
Array<Array<String>>`) and the inline `fast*` operators (`fastForEach`,
`fastAny`, `fastFirstOrNull`, `fastFirstNotNullOfOrNull`, … in
`nip01Core/core/TagArray.kt`) instead of Kotlin `List` + stdlib
`forEach`/`map`/`filter`/`any` — the `fast*` variants allocate no iterator,
no intermediate list, and no lambda object. Don't "modernize" those into
stdlib collection calls; match the surrounding hot-path style.
- **Never put raw invisible/bidirectional Unicode characters in source files**
— write them as `\uXXXX` escapes instead (`'\u202E'`, `Regex("[\u200B-\u200D\uFEFF]")`).
This covers the bidi family Sonar's Trojan-Source rule (CVE-2021-42574)
flags — U+202A–U+202E, the isolates U+2066–U+2069, U+200E/U+200F, U+061C —
plus zero-width characters (U+200B–U+200D, U+FEFF, U+2060). The escape
compiles to the identical codepoint, so behaviour is unchanged; the point is
that the file on disk stays visually unambiguous. Applies even when the
character is *intentional* (sanitizer strip-lists, adversarial test
payloads) — that's data, and escapes express it just as well. Exceptions:
U+200D as part of a real emoji ZWJ sequence in test data (👩‍👧 — functional,
not a bidi control), and LRM/RLM inside Crowdin-managed `strings.xml`
translations (legitimate RTL typography; don't touch those files by hand
anyway). Note the tooling trap: the Edit tool may normalise a typed
`\uXXXX` back into the raw character — if that happens, do the replacement
at byte level (`perl -CSD -pe 's/\x{202E}/\\u202E/g'`).
### Navigation Shell
- **Desktop**: Sidebar + main content area
- **Android**: Bottom navigation
## Git Workflow
- Commits: Conventional commits (`feat:`, `fix:`, etc.)
- Never use `--no-verify`
### Remotes & pull requests
A PR can be published two ways, via two **kinds** of remote — identify them by **URL** (`git remote -v`), because the names vary per clone and **a collaborator may have only one**:
- a **GitHub** remote (`github.com/vitorpamplona/amethyst`) — the **canonical** `main`; moves constantly. Standard `gh` PR flow.
- a **git-over-nostr** remote (`nostr://…/relay.ngit.dev/amethyst`, via `ngit`) — pushing fans out to GitHub **and** the GRASP git servers; **PRs here are nostr proposals** (the `pr/feat/*` branches), reviewed on **gitworkshop.dev** — *not* GitHub PRs. (In the maintainer's checkout these happen to be named `upstream` and `origin` respectively, but don't rely on that.)
Before opening, revising, or merging a PR by **either** path, use the **`ngit-pr`** skill. It covers when to use which, identifying your remotes by URL, the `gh` and `ngit` commands, and — critically for the nostr path — the three-mains alignment gate (GitHub main vs the lagging nostr `main` vs local `main`) that its create/revise/merge flows depend on. Skipping it leads to rejected pushes and PRs that don't show up as revisions.