mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 11:18:24 +00:00
`:commons` is on the CLI classpath, yet it declared Compose UI, Coil, Compose resources, markdown and desktop Compose as dependencies, dragging ~40 MB of UI/Skiko jars into every `amy` distribution. This moves every Compose-dependent file into a new KMP module, `:commonsUI`, that `api`-depends on `:commons`; `:commons` keeps only the Compose runtime (stability annotations + snapshot state) and lifecycle-viewmodel. Files keep their `com.vitorpamplona.amethyst.commons.*` packages, so the split is a build-graph boundary and no consumer import changed. 236 files were `git mv`'d (composables, icons, robohash, theme, Coil fetchers, the `@Composable` relay-client entry points, `composeResources`, and the tests that exercise them). Two headless files needed surgery instead of a move: `GalleryParser` lost a vestigial foundation `@OptIn`, and the `LocalPrivacyLockState`/`lockStateFor` CompositionLocal accessor moved out of `PrivacyLockState` into its own commonsUI file. The feed DAL under `ui/feeds` and `ui/note/ParentNote`+`ReplyContext` stay in `commons` because ViewModels depend on them. `amethyst`, `desktopApp`, `nappletHost` (NappletWebContract serves the shell from composeResources) and `benchmark` now depend on `:commonsUI`; `cli`, `geode` and `marmotBench` do not. commons' androidMain gains an explicit androidx.core KTX dep it previously got transitively through Compose UI. CI, crowdin, the icon-font tools and the escaping hook point at the new composeResources location; CLAUDE.md, commons/ARCHITECTURE.md, a new commonsUI/ARCHITECTURE.md, CONTRIBUTING, BUILDING and the affected skills document the boundary. A plan doc under commons/plans records the classification method and follow-ups. Verified: JVM compiles for commons, commonsUI, cli, desktopApp; Android debug compiles for nappletHost and amethyst; commons/commonsUI/cli JVM test suites; both verifyKmpPurity gates; the cli runtime classpath no longer resolves Compose UI, material3, Skiko or Coil. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N56KzPSYiN5edMRamvKEgD
389 lines
21 KiB
Markdown
389 lines
21 KiB
Markdown
# 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. 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 tests
|
||
./gradlew test
|
||
|
||
# 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.
|
||
|
||
## 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.
|