Spelling: British -> American across in-app copy, docs, and code
Build Debug APK / build (push) Successful in 5m51s

Rewrites every British spelling found in the codebase to American: neighbour,
colour, catalogue, behaviour, honour, flavour, grey, judgement,
acknowledgement, licence, defence, organise/optimise/initialise/recognise/
analyse, artefact, cancelled/cancelling, labelled/labelling, modelled/
modelling, and centre/centred/centring all become their American forms —
consistently in prose (docs, ADRs, comments) and in identifiers, including
the FFI-shared CatalogueSync/Cancelled enum variants and catalogue_* fields
that cross the Rust/Kotlin boundary.

Left untouched: external API names that only look like a spelling variant
(kotlinx.coroutines.CancellationException/NonCancellable, Compose Material3's
labelLarge) and the EFF diceware wordlist used for key derivation.

Adds a Code Style rule to AGENTS.md/CLAUDE.md requiring American spelling in
user-facing copy and documentation going forward, with that same carve-out.

Verified: cargo build --workspace, all 1410 Rust tests, Kotlin compile,
fmt-rust/fmt-kotlin, lint-kotlin-fast (detekt), and the lint-gui-layout/
lint-sql-params/lint-p2p-admission custom checks all pass clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017fHWzCF6oNiCV4ue1bBf1t
This commit is contained in:
Greg Shuflin
2026-09-01 03:01:16 -07:00
co-authored by Claude Sonnet 5
parent 2104fc9e62
commit 15b0db7a9d
232 changed files with 1134 additions and 1135 deletions
+10 -2
View File
@@ -70,7 +70,7 @@ finished, whatever else it does.
- This holds for lists and collections on the form as much as for its plain
fields, however the store happens to keep them. "Each row is its own
artifact, so there's nothing to batch behind Save" is a fact about storage,
not a licence to write one — it makes Save mean two different things on one
not a license to write one — it makes Save mean two different things on one
form, and it leaves the user unable to back out of a removal. Stage the
edit and diff it against what is persisted when Save runs; the Humans
screen's `diff_staged`/`diffStaged` is the shape.
@@ -78,7 +78,7 @@ finished, whatever else it does.
they should be one code path. Watch for state that lives outside the
screen's own struct — the desktop's text fields are `TextInput` entities,
so a Cancel that only dropped the staged collections would leave a
cancelled rename on screen.
canceled rename on screen.
- Not everything on a screen is part of its form. A thing that is its own
document — Humans' interactions, shared with their other attendees — is
written when the user says to write it, and lives outside the mode. The
@@ -142,6 +142,14 @@ developers build.
carries its full reasoning and its escape hatch.
# Code Style
- Use American spelling in user-facing copy and documentation (color, not
colour; behavior, not behaviour; canceled, not cancelled; catalog, not
catalogue) — consistently, throughout the codebase. Leave alone any spelling
that comes from outside the project: a fixed external API/library name (e.g.
Kotlin's `kotlinx.coroutines.CancellationException`/`NonCancellable`,
Compose Material3's `labelLarge`), a URL, or a third-party wordlist
(`rust/lib/src/ensemble/eff_large_wordlist.txt`) — those aren't ours to
respell.
- Errors: Use typed errors with meaningful messages. In rust, prefer `thiserror`. Avoid using .unwrap() or .expect() in non-test code, and fix uses of these or other panicking functions when you encounter them.
- the justfile codifies recipes that run standard Rust and Kotlin formatters (just fmt-kotlin, just fmt-rust). Run these tools to keep code formatted on every commit.
- don't generate code that has fully-qualified type names (e.g. `documents::Artifacts::new()`); instead prefer to `use documents::Artifacts`, `Artifacts::new()`
+1 -1
View File
@@ -51,7 +51,7 @@ most widespread example of an overlay OS today.
<!-- note: I would eventually like to be able to say Synchronicity is a computing stack, but that is not the case yet -->
<!-- the overlay OS terminology comes from https://urbit.org/overview/urbit-explained among others -->
<!-- note: "Ra is a single entity distributed across the whole solar system. No part of Ra is slaved to any other part of it. No shell-Earth's core node has a noticeably differing personality from the others, or from the megastructure inside the Sun. Opinions and behaviour and available information are continuously synchronising. Eventually, there is only one Ra." - qntm's Ra, Abstract War -->
<!-- note: "Ra is a single entity distributed across the whole solar system. No part of Ra is slaved to any other part of it. No shell-Earth's core node has a noticeably differing personality from the others, or from the megastructure inside the Sun. Opinions and behavior and available information are continuously synchronising. Eventually, there is only one Ra." - qntm's Ra, Abstract War -->
What can someone do with Synchronicity? Some examples:
+12 -12
View File
@@ -129,7 +129,7 @@ since it isn't worth taking before it shows up in a profile.
- **Description**: The Android pass (ADR 0032 §10) landed the foundations, the whole
structural half, and all but one of the five gaps against the desktop
(`bgMediaCanvas`, the reading-surface story, the live-state slot's two wired
publishers, and selection icon/text colours). One remains, deliberately deferred:
publishers, and selection icon/text colors). One remains, deliberately deferred:
- **A `Presence` tab** (§7). Still the one to hold longest. A nodes × documents grid is a
desktop-shaped affordance that `design/visual-design-review/PRESENCE-AFFORDANCE.md`
treats as unsettled even on the desktop, and a phone may want a different answer rather
@@ -321,8 +321,8 @@ Model and dedup design: `docs/adr/0011-jobs-first-class-model.md`; product desig
- **Cancellation is still coarse for a single blocking P2P call.** Every surface is
runner-backed now, so a manual pull/push/eviction has a live token — but nothing reads it
mid-transfer: `asset_sync`'s functions take no cancellation signal, so the work runs to
the end and the row is only *recorded* as `Cancelled`. The imports and scheduler passes,
which check `is_cancelled` between units, do stop. Threading a signal into `asset_sync`
the end and the row is only *recorded* as `Canceled`. The imports and scheduler passes,
which check `is_canceled` between units, do stop. Threading a signal into `asset_sync`
(probably a `&dyn ProgressReporter`, since that is what the byte counters would want
anyway) is the fix, and it would improve desktop as much as the FFI/daemon.
- **The TUI has no import affordance at all.** When it gains one it should start a job from
@@ -358,7 +358,7 @@ Model and dedup design: `docs/adr/0011-jobs-first-class-model.md`; product desig
- **Description**: `scheduler::ensemble_peers_freshest_first` returns every node gossip has
ever heard from for this ensemble — rows are never pruned, so a machine retired months
ago is still in the list. Every pass walks it *in series* with a per-peer budget
(`PEER_CATALOGUE_TIMEOUT` is 180s), so an ensemble carrying a couple of dead nodes
(`PEER_CATALOG_TIMEOUT` is 180s), so an ensemble carrying a couple of dead nodes
spends most of a pass dialling them. Symptom: a trivially small sync — one podcast and
twenty episode documents — takes many minutes.
- **What's already done**: the list is ordered most-recently-seen first, so the live peer
@@ -428,12 +428,12 @@ How it works: `rust/lib/src/stores/lock.rs` and
button is durable. This is `docs/sync-management.md`'s "push-assisted delivery", moved
up; see `docs/content-placement-redesign.md` §5.
- **Questions the queue exists to let us answer** (experiment, not argument — the right
behaviour against a real sleeping phone isn't knowable from here):
behavior against a real sleeping phone isn't knowable from here):
- How long does a queued directive stay valid? A `Mirror` pressed a week ago may not be
what the user still wants.
- What happens if the target's own policy changes before it wakes — does the directive
still apply, or has it been superseded by a more recent local decision?
- Should a pending directive be visible, and cancellable, or is that clutter for
- Should a pending directive be visible, and cancelable, or is that clutter for
something that usually lands within seconds?
- Delivery: gossip carries small frames (4 KiB, see the presence-announcement item) and
reaches nodes as they appear; store-and-forward through a reachable third node is the
@@ -459,14 +459,14 @@ How it works: `rust/lib/src/stores/lock.rs` and
- **Per-artifact sync controls on the document detail page.** Pulling one artifact works
on every surface (`asset_sync::sync_document_asset`).
- ~~**Nothing asks peers whether a newer version exists.**~~ Closed: the whole-document
pull (`asset_sync::sync_document_assets_recursive`) now runs a scoped catalogue refresh
pull (`asset_sync::sync_document_assets_recursive`) now runs a scoped catalog refresh
against every ensemble peer before the content walk, so "up to date" is an answer about
the ensemble rather than about this node's last scheduler pass. See
`docs/sync-management.md`'s "On-demand catalogue refresh for one document".
`docs/sync-management.md`'s "On-demand catalog refresh for one document".
- **The per-row "Sync" control still doesn't.** `sync_document_asset` — the narrow
per-artifact affordance in the Artifacts section — deliberately skips the catalogue
per-artifact affordance in the Artifacts section — deliberately skips the catalog
phase: it fetches the one `ContentKey` the caller named, against whatever the local
catalogue says. That's right for "fetch this blob I can see," and wrong if what the user
catalog says. That's right for "fetch this blob I can see," and wrong if what the user
means is "get me the current version of this note." Decide whether the row control should
refresh that one artifact's row first, or whether the whole-document button is the only
place that question belongs.
@@ -500,7 +500,7 @@ How it works: `rust/lib/src/stores/lock.rs` and
### Service credentials: what's left after the store
The store is built (ADR 0051): a `ServiceCredential` document per service,
sealed with the vault key, synced on the catalogue plane. Podcast Index reads
sealed with the vault key, synced on the catalog plane. Podcast Index reads
from it; a Settings ▸ Credentials tab on the desktop, the same section on
Android, and `syn settings credentials{,-set,-clear}` enter and remove keys;
and the tests provision the published test key themselves. Each service's
@@ -592,7 +592,7 @@ Open:
entirely and drive Media3/ExoPlayer's `PlayerView` directly.
- No poster-frame thumbnails — needs a decoded frame, so it's blocked on the
above; `MediaLibrary` shows the placeholder glyph for every clip meanwhile.
- Only MP4/MOV/M4V are recognised (`re_mp4`); MKV/WebM would need a second
- Only MP4/MOV/M4V are recognized (`re_mp4`); MKV/WebM would need a second
container parser.
- Android has no folder import for video (desktop does, via
`import_directory_tree`) — single-file picker only.
+1 -1
View File
@@ -279,7 +279,7 @@ detekt {
config.setFrom("$projectDir/detekt.yml")
buildUponDefaultConfig = true
allRules = false
// Analyse files across threads. Off by default in detekt; the type-resolution
// Analyze files across threads. Off by default in detekt; the type-resolution
// pass (`detektDebug`, the one that has to pass — ADR 0022) is the slowest
// thing in the Kotlin lint path once the Rust build is out of the way.
parallel = true
@@ -23,7 +23,7 @@ class ChangesAgent internal constructor(private val nativeLib: NativeLib) {
* the cursor as of waking — pass it back as [since] next time so a change that lands while the
* caller is reloading isn't slept through.
*
* **Blocks the calling thread**, so call it off the main one. A cancelled coroutine can't
* **Blocks the calling thread**, so call it off the main one. A canceled coroutine can't
* interrupt the call, so [timeoutMs] is also what bounds how long a thread stays parked after
* whoever was waiting goes away.
*/
@@ -104,7 +104,7 @@ class DocumentsAgent internal constructor(private val nativeLib: NativeLib) {
nativeLib.node().documents().listChildren(parentId)
}
// A blob that isn't present locally yet (NotFound) is normal — e.g. catalogue
// A blob that isn't present locally yet (NotFound) is normal — e.g. catalog
// metadata synced ahead of the blob — so absence collapses to null.
fun getBlobBytes(documentId: String): ByteArray? =
ffiCatching { nativeLib.node().documents().getBlobBytes(documentId) }
@@ -49,7 +49,7 @@ class ImportAgent internal constructor(private val nativeLib: NativeLib) {
}
/**
* Open a batch import: one tracked, cancellable job covering [totalFiles] files holding roughly
* Open a batch import: one tracked, cancelable job covering [totalFiles] files holding roughly
* [totalBytes] bytes, fed one file at a time through the returned [BatchImport]. Both totals
* are counted by the caller before it starts see `DocumentDirectoryImport.kt`'s tree pass
* and become the Jobs screen's denominators.
@@ -87,11 +87,11 @@ class ImportAgent internal constructor(private val nativeLib: NativeLib) {
class BatchImport internal constructor(private val batch: uniffi.synchronicity.ImportBatch) {
/** Whether the Jobs screen's Cancel button has been pressed for this import. */
fun isCancelled(): Boolean = batch.isCancelled()
fun isCanceled(): Boolean = batch.isCanceled()
/**
* Import one file's bytes. Succeeds with the new document id, or with null when no supported
* type recognised the file (a skip, not a failure). [mimeType] is only a fallback for dispatch,
* type recognized the file (a skip, not a failure). [mimeType] is only a fallback for dispatch,
* used when [fileName] has no extension a supported type claims.
*/
fun importFile(
@@ -24,7 +24,7 @@ class JobsAgent internal constructor(private val nativeLib: NativeLib) {
/**
* Best-effort cancel: `true` only if `jobId` had a live cancellation token to flip. See
* `ffi.rs`'s `JobsFfi::cancel_job` doc comment -- only `BackgroundSync`, scheduler-originated
* `CatalogueSync`/`ContentFetch`, `ContentStream` (a reader opening something that lives on
* `CatalogSync`/`ContentFetch`, `ContentStream` (a reader opening something that lives on
* another node -- see `lib::content_fetch`), and a batch (folder) `Import` have one; a
* manually-triggered sync/fetch, a single-file `Import`, or any `Push`/`Eviction`/`Purge`,
* returns `false` with no error.
@@ -55,7 +55,7 @@ object NodeReset {
* Android settings is the way out.
*
* Runs under [NonCancellable], and picks its own dispatcher per step. The caller is a click
* handler on a screen this navigates away from, so a cancellable reset is one that could stop
* handler on a screen this navigates away from, so a cancelable reset is one that could stop
* between destroying the store and clearing what was decrypted out of it. The wipe blocks its
* thread for as long as it takes, so it goes to [Dispatchers.IO]; the audio transport is a
* Media3 `MediaController`, which may only be touched from the main thread.
@@ -89,7 +89,7 @@ class SettingsAgent internal constructor(private val nativeLib: NativeLib) {
nativeLib.node().settings().setSchedulerSettings(settings)
}
// Run one full scheduler tick (catalogue, catalogue-fetch, content passes) against
// Run one full scheduler tick (catalog, catalog-fetch, content passes) against
// every reachable ensemble peer. Backs scheduler/SchedulerWorker.kt; requires P2P to
// be running.
fun runSchedulerPasses(): Result<SchedulerRunSummary> = ffiCatching {
@@ -31,7 +31,7 @@ private const val RETRY_DELAY_MS = 3_000L
/**
* How long a wake lets the rest of a burst land before it is reported.
*
* A catalogue sync commits a chunk of documents per transaction and does it repeatedly, so a screen
* A catalog sync commits a chunk of documents per transaction and does it repeatedly, so a screen
* told about each one would spend the sync rebuilding itself. One report per window bounds that at
* a handful a second while a sync runs, and costs nothing while one isn't: this is a settle
* interval, not a poll nothing wakes unless something already happened.
@@ -14,7 +14,7 @@ import uniffi.synchronicity.recourse
* The `when` is exhaustive with no `else` because [SyncException] is a sealed hierarchy this is
* the part of Rust's `Result<T, SyncError>` that survives the trip through `kotlin.Result`, whose
* error slot is statically only `Throwable`. When the Rust `SyncError` enum grows a variant, uniffi
* regenerates the sealed class and this stops compiling, which is the intended behaviour: see
* regenerates the sealed class and this stops compiling, which is the intended behavior: see
* docs/adr/0022 and docs/adr/0023.
*/
val SyncException.detail: String
@@ -26,7 +26,7 @@ val SyncException.detail: String
is SyncException.Offline -> msg
is SyncException.InvalidInput -> msg
is SyncException.PreconditionUnmet -> msg
is SyncException.Cancelled -> msg
is SyncException.Canceled -> msg
is SyncException.Internal -> msg
}
@@ -256,7 +256,7 @@ private fun ComicPage(
produceState<ComicRepository.PageImage?>(initialValue = null, key1 = pageId) {
value = repository.loadPage(pageId, PAGE_MAX_EDGE)
}
// Armed only for the page the reader is actually on. The pager keeps neighbouring slots
// Armed only for the page the reader is actually on. The pager keeps neighboring slots
// composed, and those are the read-ahead's business — untracked by design, so watching them
// would poll for job rows that never exist.
val cancelScope = rememberCoroutineScope()
@@ -214,8 +214,8 @@ fun DocumentDetailScreen(documentId: String, onBack: () -> Unit, modifier: Modif
r.artifactsReceived,
r.documentsSynced,
r.peersTried,
r.cataloguePeersAvailable,
r.cataloguePeersRefreshed,
r.catalogPeersAvailable,
r.catalogPeersRefreshed,
)
},
onFailure = { e ->
@@ -1348,7 +1348,7 @@ private fun NodeStatusRow(status: DocumentNodeStatusItem) {
}
}
}
// The state takes the colour; the timestamp beside it stays dim, so
// The state takes the color; the timestamp beside it stays dim, so
// the row reads as one fact with a time on it rather than two.
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
@@ -23,7 +23,7 @@ private const val TAG = "DocumentDirectoryImport"
*
* The loop is here because only Kotlin can enumerate and read `content://` URIs; everything else is
* Rust's ([com.gregshuflin.synchronicity.BatchImport]). That is what makes the whole folder *one*
* `Import` job showing "12/40 files · 96.4 MB / 1.2 GB" as it goes, cancellable from the Jobs
* `Import` job showing "12/40 files · 96.4 MB / 1.2 GB" as it goes, cancelable from the Jobs
* screen, and deduplicated against picking the same folder twice where before it was one job per
* file, none of which could say anything about the folder they belonged to.
*
@@ -67,7 +67,7 @@ suspend fun importDirectory(
*/
private fun ImportBatchSummary.describe(): String {
val counts = "Imported $imported file(s), skipped ${skipped + failed}"
return if (cancelled) "Import cancelled — $counts" else counts
return if (canceled) "Import canceled — $counts" else counts
}
/** What a Storage Access Framework subtree holds, for the import job's progress denominators. */
@@ -188,9 +188,9 @@ private class DirectoryImporter(
private fun importTree(parentDocId: String, parentNode: String?) {
for (entry in scanner.children(parentDocId)) {
// Cancelling stops before the next entry; whatever already landed stays imported,
// Canceling stops before the next entry; whatever already landed stays imported,
// and re-running the import picks up the rest.
if (batch.isCancelled()) return
if (batch.isCanceled()) return
if (entry.isDirectory) {
importSubdirectory(entry, parentNode)
} else {
@@ -185,7 +185,7 @@ fun DocumentsScreen(
}
// Reset and reload the first page whenever the filters change. This effect is
// cancelled and restarted on each change, so the debounce coalesces rapid
// canceled and restarted on each change, so the debounce coalesces rapid
// typing and an interrupted first-page load is simply dropped.
LaunchedEffect(
searchQuery,
@@ -676,7 +676,7 @@ private fun DocumentSettingsRow(label: String, value: String) {
* A folder import is the one action on this screen that can run for minutes, and until it finishes
* the documents it is creating appear in the list with no explanation of where they are coming
* from. Each line carries the job's own progress ("12/40 files · 96.4 MB / 1.2 GB") and a way
* through to Jobs, where it can be cancelled.
* through to Jobs, where it can be canceled.
*/
@Composable
private fun ImportsInProgress(jobs: List<JobItem>) {
@@ -125,7 +125,7 @@ fun EnsembleScreen(
// Rust so it matches the GUI.
//
// Nothing is seeded here about a sync that is *running*: each row's
// Sync button watches its own peer's `CatalogueSync` job (see
// Sync button watches its own peer's `CatalogSync` job (see
// [SyncControl]), so one started elsewhere — the background scheduler,
// a remote `TriggerSync`, this screen before the app was backgrounded —
// shows up without this screen having to ask once on entry and then
@@ -154,8 +154,8 @@ fun EnsembleScreen(
LaunchedEffect(Unit) { load() }
// The roster moves without anyone here touching it: a member announces itself over gossip, a
// catalogue sync finishes and writes its timestamp, documents arrive and change every row's
// coverage. Not the whole picture — gossip neighbour count and live reachability are in-memory
// catalog sync finishes and writes its timestamp, documents arrive and change every row's
// coverage. Not the whole picture — gossip neighbor count and live reachability are in-memory
// P2P state that nothing announces, so pull-to-refresh stays the way to ask for those.
OnStoreChange(nativeLib, ChangeChannel.NODES, ChangeChannel.DOCUMENTS) { load(quiet = true) }
@@ -307,7 +307,7 @@ private fun EnsembleNodesScreen(
}
FactRow("DHT bootstrap") {
// Not truncated: this is a link meant to be read, compared, or handed
// to another node, not a bare id to recognise at a glance.
// to another node, not a bare id to recognize at a glance.
HashChip(
full = sd.dhtBootstrapLink,
clipLabel = "DHT bootstrap link",
@@ -373,17 +373,17 @@ private fun EnsembleNodesScreen(
ensembleRosterCopy(
total = counts.total,
other = counts.other,
gossipNeighbours = sd.gossipNeighborCount,
gossipNeighbors = sd.gossipNeighborCount,
)
SectionHeading("Nodes", trailing = rosterCopy.summary)
Text(
text = rosterCopy.gossipNeighbours,
text = rosterCopy.gossipNeighbors,
style = MaterialTheme.typography.labelSmall,
color =
if (rosterCopy.gossipIsQuiet) SynTheme.palette.colorWarning
else SynTheme.palette.accent,
)
// Offered only when there is something behind it, and labelled with
// Offered only when there is something behind it, and labeled with
// the count: "Show revoked" alone doesn't say whether that uncovers
// one row or forty.
if (counts.revoked > 0u) {
@@ -596,9 +596,9 @@ private enum class Ending {
* Retire and Reset, in the one shape they share.
*
* [com.gregshuflin.synchronicity.ui.FlatButton]'s footprint a full-width bordered rectangle, 56dp
* tall in the error colour ADR 0032 §4 reserves for destructive acts. Not FlatButton itself,
* which is neutral by design. It stays local until something else wants a danger button, at which
* point it belongs beside FlatButton.
* tall in the error color ADR 0032 §4 reserves for destructive acts. Not FlatButton itself, which
* is neutral by design. It stays local until something else wants a danger button, at which point
* it belongs beside FlatButton.
*/
@Composable
private fun EndingButton(
@@ -872,7 +872,7 @@ private fun MemberRow(
* How much of the library this node holds.
*
* A meter is one of the two things ADR 0032 §4 lets a gradient do encode a value as against
* filling a panel. The fill is one colour, matching the desktop's: the meter's *width* already
* filling a panel. The fill is one color, matching the desktop's: the meter's *width* already
* carries the value, and a red/amber/green judgment on top of it duplicated the encoding without
* ever being explained anywhere the reader could find it.
*
@@ -80,8 +80,8 @@ internal fun PingControl(state: PingState, onPing: () -> Unit) {
/**
* Sync button with its inline status (last-synced / "Not synced"), laid out button-first.
*
* The button binds to the `CatalogueSync` job for [nodeIdHex] rather than to a local "did I click
* it" flag: a sync with this peer can equally have been started by the background scheduler, by a
* The button binds to the `CatalogSync` job for [nodeIdHex] rather than to a local "did I click it"
* flag: a sync with this peer can equally have been started by the background scheduler, by a
* remote node's `TriggerSync`, or from this screen before the app was last backgrounded. While one
* is running the button is not a re-trigger it would only be refused by the store's own
* already-in-progress check and [JobActivity] beside it shows the job's progress and a way to get
@@ -91,7 +91,7 @@ internal fun PingControl(state: PingState, onPing: () -> Unit) {
internal fun SyncControl(nodeIdHex: String, state: SyncState, onSync: () -> Unit) {
val control =
rememberJobControl(
JobKinds.CATALOGUE_SYNC,
JobKinds.CATALOG_SYNC,
JobSubjectRef.Peer(nodeIdHex = nodeIdHex),
starting = state.starting,
)
@@ -115,7 +115,7 @@ internal fun SyncControl(nodeIdHex: String, state: SyncState, onSync: () -> Unit
modifier = Modifier.height(32.dp),
) {
// Matches the desktop's label, and for the same reason: this
// exchanges catalogues, pulls what *this* node's policy wants,
// exchanges catalogs, pulls what *this* node's policy wants,
// and tells the peer to do the same for its own.
Text("Sync both ways", style = MaterialTheme.typography.labelSmall)
}
@@ -92,8 +92,8 @@ private fun PeerHealthCard(copy: PeerHealthCopy) {
}
/**
* How far the severity colour is mixed into the notice's background. Light enough that the text on
* top keeps its contrast in both themes the colour is there to say which of two kinds of problem
* How far the severity color is mixed into the notice's background. Light enough that the text on
* top keeps its contrast in both themes the color is there to say which of two kinds of problem
* this is, and the words carry the rest.
*/
private const val SURFACE_TINT_ALPHA = 0.12f
@@ -206,7 +206,7 @@ fun HumanDetailScreen(
// The two "add a row" entry fields, hoisted so Save can commit them — see
// [FactEntryState]. Keyed on `editing` so half-typed text is discarded
// along with the rest of a cancelled edit.
// along with the rest of a canceled edit.
var factEntry by remember(editing) { mutableStateOf(FactEntryState()) }
var contactEntry by remember(editing) { mutableStateOf(ContactEntryState()) }
val protocols = remember { nativeLib.humans.contactProtocols().getOrDefault(emptyList()) }
@@ -39,7 +39,7 @@ private const val PERCENT_SCALE = 100f
*/
object JobKinds {
const val BACKGROUND_SYNC = "background_sync"
const val CATALOGUE_SYNC = "catalogue_sync"
const val CATALOG_SYNC = "catalog_sync"
const val CONTENT_FETCH = "content_fetch"
const val EVICTION = "eviction"
const val IMPORT = "import"
@@ -54,7 +54,7 @@ private enum class JobsTab(val label: String) {
}
/**
* The Jobs screen: cross-screen visibility into long-running background operations (catalogue syncs
* The Jobs screen: cross-screen visibility into long-running background operations (catalog syncs
* today; content fetches, imports, pushes, evictions, and purges as they migrate onto a `JobRunner`
* on the Android side -- see `docs/sync-management.md`'s "Jobs" section). Mirrors the desktop GUI's
* Active/History tabs (`rust/gui-app/src/agents/jobs.rs`), reading from the same `JobItem` FFI
@@ -67,12 +67,12 @@ private enum class JobsTab(val label: String) {
*
* Active rows show a Cancel button like desktop's, but unlike desktop it's best-effort per row:
* only jobs started through `SyncNode`'s live `JobRunner` have a cancellation token -- today
* `BackgroundSync`, scheduler-originated `CatalogueSync`/`ContentFetch`, and a folder import
* started through `ImportFfi::begin_batch`. A manually-triggered sync/fetch, a single-file
* `Import`, or a `Push`/`Eviction`/`Purge` job's Cancel click comes back `false` with nothing to
* show for it -- see `ffi.rs`'s `cancel_job` doc comment. `list_job_history`/`clear_job_history`
* are meaningful regardless of what's currently active, since `JobStore` isn't node-scoped -- this
* node can see history from the desktop GUI's `JobRunner` against the same store.
* `BackgroundSync`, scheduler-originated `CatalogSync`/`ContentFetch`, and a folder import started
* through `ImportFfi::begin_batch`. A manually-triggered sync/fetch, a single-file `Import`, or a
* `Push`/`Eviction`/`Purge` job's Cancel click comes back `false` with nothing to show for it --
* see `ffi.rs`'s `cancel_job` doc comment. `list_job_history`/`clear_job_history` are meaningful
* regardless of what's currently active, since `JobStore` isn't node-scoped -- this node can see
* history from the desktop GUI's `JobRunner` against the same store.
*/
@Composable
fun JobsScreen(nativeLib: NativeLib, onBack: () -> Unit, modifier: Modifier = Modifier) {
@@ -97,8 +97,8 @@ fun JobsScreen(nativeLib: NativeLib, onBack: () -> Unit, modifier: Modifier = Mo
fun cancel(job: JobItem) {
scope.launch {
withContext(Dispatchers.IO) { nativeLib.jobs.cancelJob(job.id) }
.onSuccess { cancelled ->
if (cancelled) {
.onSuccess { canceled ->
if (canceled) {
// The Active list follows the store's own change signal; only History
// has to be asked again.
reloadHistory()
@@ -106,7 +106,7 @@ fun JobsScreen(nativeLib: NativeLib, onBack: () -> Unit, modifier: Modifier = Mo
// Best-effort: no live cancellation token for this job (see
// `JobsScreen`'s doc comment) -- say so rather than pretending the
// click did nothing.
error = "\"${job.label}\" can't be cancelled."
error = "\"${job.label}\" can't be canceled."
}
}
.onFailure { error = it.message }
@@ -126,7 +126,7 @@ fun JobsScreen(nativeLib: NativeLib, onBack: () -> Unit, modifier: Modifier = Mo
title = { Text("Clear job history?") },
text = {
Text(
"This removes finished, failed, and cancelled jobs from the list. Active " +
"This removes finished, failed, and canceled jobs from the list. Active " +
"jobs are unaffected."
)
},
@@ -221,7 +221,7 @@ private fun JobRow(job: JobItem, showTimestamps: Boolean, timezone: String?, onC
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
JobLabelRow(job)
// CatalogueSync's peer chip. Push already renders its peer
// CatalogSync's peer chip. Push already renders its peer
// inline in the label row above, so it's excluded here to avoid
// showing it twice -- matches `rust/gui-app/src/agents/jobs.rs`'s
// `render_job_row`.
@@ -345,7 +345,7 @@ private fun JobStatusLine(job: JobItem) {
MaterialTheme.colorScheme.onSurfaceVariant
"done" -> jobStateLabel(job.state) to SynTheme.palette.colorSuccess
"failed" -> (job.error ?: jobStateLabel(job.state)) to MaterialTheme.colorScheme.error
"cancelled" -> jobStateLabel(job.state) to MaterialTheme.colorScheme.onSurfaceVariant
"canceled" -> jobStateLabel(job.state) to MaterialTheme.colorScheme.onSurfaceVariant
// An unknown tag comes back from Rust unchanged, so a job row written by a newer
// node still lists rather than blanking.
else -> jobStateLabel(job.state) to MaterialTheme.colorScheme.onSurfaceVariant
@@ -148,7 +148,7 @@ fun libraryLevelOf(path: List<Pair<String, String>>): MusicLibraryLevel =
* counterpart of the desktop header's own status line.
*/
fun buildSummaryLine(summary: LibraryBuildSummary): String {
if (summary.cancelled) return "Library build cancelled"
if (summary.canceled) return "Library build canceled"
val tracks = (summary.tracksScanned + summary.tracksReused).toInt()
val albums = summary.albums.toInt()
val artists = summary.artists.toInt()
@@ -130,7 +130,7 @@ fun MusicScreen(onBack: () -> Unit, modifier: Modifier = Modifier) {
var selectedTrack by remember { mutableStateOf<MusicTrack?>(null) }
// The tracks the current selection is surrounded by, in the order they were on screen when the
// user picked it — an album's tracks, a folder's contents, whatever level the tap came from.
// This is what the player's Previous/Next step through, and its emptiness is what greys them
// This is what the player's Previous/Next step through, and its emptiness is what grays them
// out: a track opened with nothing either side of it has no next.
//
// Captured at activation rather than read live, because it belongs to what is *playing*. The
@@ -364,7 +364,7 @@ fun MusicScreen(onBack: () -> Unit, modifier: Modifier = Modifier) {
}
/**
* Whether [track] has a song either side of it in [context]. Both false a greyed Previous and
* Whether [track] has a song either side of it in [context]. Both false a grayed Previous and
* Next for a track with no surrounding list, and at each end of one that has.
*/
internal fun adjacencyOf(track: MusicTrack?, context: List<MusicTrack>): Adjacency {
@@ -301,7 +301,7 @@ private fun NoteMetaRow(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
// Only while a preview is on screen: in Edit mode there is no page to recolour,
// Only while a preview is on screen: in Edit mode there is no page to recolor,
// and the control would be asking about something the user can't see.
if (viewMode != NotesViewMode.EDIT) {
ReadingSurfaceDropdown(readingSurface, onReadingSurfaceChange)
@@ -74,7 +74,7 @@ internal fun NoteTabSidebar(
modifier = Modifier.size(20.dp),
// onPrimaryContainer, matching the label below and the desktop's own
// row_text: the saturated selection block always carries
// text_on_accent, never the accent stroke colour itself (ADR 0032 §5).
// text_on_accent, never the accent stroke color itself (ADR 0032 §5).
tint =
if (isActive) MaterialTheme.colorScheme.onPrimaryContainer
else MaterialTheme.colorScheme.onSurfaceVariant,
@@ -334,7 +334,7 @@ private fun TreeItemRow(
modifier = Modifier.size(16.dp),
// onPrimaryContainer, matching the title below and the desktop's own
// row_text: the saturated selection block always carries
// text_on_accent, never the accent stroke colour itself (ADR 0032 §5).
// text_on_accent, never the accent stroke color itself (ADR 0032 §5).
tint =
if (isSelected) MaterialTheme.colorScheme.onPrimaryContainer
else MaterialTheme.colorScheme.onSurfaceVariant,
@@ -434,7 +434,7 @@ private fun TreeItemRow(
}
/**
* A row's title, written at the weight and colour its [emphasis] calls for, with the searched-for
* A row's title, written at the weight and color its [emphasis] calls for, with the searched-for
* run marked inside a match.
*
* The selected row is left unmarked: an accent run on the selection block is the one place the
@@ -49,7 +49,7 @@ sealed class NoteTreeItem {
/**
* One row of the notebook tree as drawn: the item, how deep it sits, and how prominently its name
* is written relative to its neighbours.
* is written relative to its neighbors.
*
* The tree is flattened into these before rendering rather than drawn recursively, so a filtered
* and an unfiltered listing are the same list of rows built two ways see [visibleNoteRows]. The
@@ -706,7 +706,7 @@ fun NotesScreen(nativeLib: NativeLib, onBack: () -> Unit, modifier: Modifier = M
/**
* Editor fills the full area; sidebars slide in as overlays on top of it so the editor never
* shrinks (Obsidian-style behaviour).
* shrinks (Obsidian-style behavior).
*
* [leftSidebarOverlay] and [rightSidebarOverlay] are plain `@Composable () -> Unit` slots NOT
* scoped extensions so `AnimatedVisibility` resolves to the correct top-level overload rather
@@ -31,7 +31,7 @@ class P2PService : Service() {
private val nativeLib = NativeLib()
// SupervisorJob alone is NOT a crash barrier: it stops a failing child from cancelling its
// SupervisorJob alone is NOT a crash barrier: it stops a failing child from canceling its
// siblings, then hands the exception to the thread's default uncaught handler, which kills the
// process. Only the CoroutineExceptionHandler actually stops it here. This service has no UI to
// report through, so it logs and lets the retry loop's own supervision continue — see
@@ -96,5 +96,5 @@ private fun HistoryArrow(
}
// Material's standard disabled-content opacity, applied to the history arrows
// since the explicit tint overrides IconButton's own disabled colouring.
// since the explicit tint overrides IconButton's own disabled coloring.
private const val DISABLED_ICON_ALPHA = 0.38f
@@ -21,7 +21,7 @@ private const val PER_PAGE_BUDGET_BYTES = 2048L * 2048L * 4L
// Fraction of the app's max heap the page cache is allowed to occupy.
private const val CACHE_HEAP_FRACTION = 0.25
// The cache always needs the current page plus its two pre-rendered neighbours;
// The cache always needs the current page plus its two pre-rendered neighbors;
// the upper bound just keeps a very large heap from over-caching.
private const val MIN_CACHED_PAGES = 3
private const val MAX_CACHED_PAGES_CAP = 8
@@ -35,7 +35,7 @@ class PdfViewerState {
// Back/forward page history, backed by the reusable Rust component. The
// can* flags mirror its state into Compose so the toolbar arrows recompose
// (and grey out) as the user navigates.
// (and gray out) as the user navigates.
private val history = NavigationHistory.withDefaultCapacity()
var canGoBack by mutableStateOf(false)
private set
@@ -529,7 +529,7 @@ private fun ImageLibrarySidebar(
modifier = Modifier.size(16.dp),
// onPrimaryContainer, matching the title below and the desktop's own
// row_text: the saturated selection block always carries
// text_on_accent, never the accent stroke colour itself (ADR 0032 §5).
// text_on_accent, never the accent stroke color itself (ADR 0032 §5).
tint =
if (isSelected) MaterialTheme.colorScheme.onPrimaryContainer
else MaterialTheme.colorScheme.onSurfaceVariant,
@@ -14,7 +14,7 @@ import kotlinx.coroutines.flow.stateIn
* The device plays one thing at a time, so there is exactly one [AudioPlayerManager] and one
* ExoPlayer behind it but *what is loaded* belongs to whichever agent loaded it, and no screen
* may render or drive another agent's playback. A shared handle made that far too easy: pausing a
* podcast and opening Music showed the Music bar sitting at the podcast's position, greyed out
* podcast and opening Music showed the Music bar sitting at the podcast's position, grayed out
* because Music had no track of its own but scrubbed to somebody else's.
*
* So each agent takes its own handle ([AudioPlayerProvider.forSource]) and sees only its own
@@ -30,7 +30,7 @@ class AgentAudioPlayer(
private val transport: AudioPlayerManager,
private val source: PlaybackSource,
) {
// Eagerly started and never cancelled: a handle is a per-source singleton with the same
// Eagerly started and never canceled: a handle is a per-source singleton with the same
// lifetime as the transport, so there is no scope to tie it to and nothing to leak into.
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
@@ -284,7 +284,7 @@ class AudioPlayerManager(private val context: Context) {
* Deliberately not queued behind the connection the way every other command here is: [release]
* tears that connection down, so a queued stop would never run. If the controller has not
* connected yet then nothing it was asked to load has reached the service either, and
* [release]'s cancelling of the connection is the stop.
* [release]'s canceling of the connection is the stop.
*/
fun stopAndRelease() {
player?.run {
@@ -88,7 +88,7 @@ fun PlaybackSpeedControl(
) {
var menuOpen by remember { mutableStateOf(false) }
var dialogOpen by remember { mutableStateOf(false) }
// The rate to restore if the dialog is cancelled, captured as it opens.
// The rate to restore if the dialog is canceled, captured as it opens.
var speedBeforeDialog by remember { mutableFloatStateOf(speed) }
Box(modifier = modifier) {
@@ -431,7 +431,7 @@ private fun BackgroundSyncSection(
SectionHeading("Background sync")
Text(
text =
"Periodically syncs the document catalogue with your ensemble and fetches " +
"Periodically syncs the document catalog with your ensemble and fetches " +
"content this node is configured to keep, without needing to press Sync " +
"manually.",
style = MaterialTheme.typography.bodyMedium,
@@ -64,9 +64,9 @@ private val LABELS: Map<DocumentAvailability, String?> =
fun availabilityWord(availability: DocumentAvailability): String? = LABELS[availability]
/**
* The colour a state's word and rule take.
* The color a state's word and rule take.
*
* `REMOTE` spends no colour: it is the ordinary condition in an ensemble, and painting the ordinary
* `REMOTE` spends no color: it is the ordinary condition in an ensemble, and painting the ordinary
* case would make a mostly-unsynced library shout. Only `UNAVAILABLE` takes the warning hue and
* `colorWarning`, not `error`, because "we have not been told" is not a fault (ADR 0032 §4) and
* CLAUDE.md's "absence is not an error" applies to how it is painted too.
@@ -118,11 +118,11 @@ fun AvailabilityWord(
availability: DocumentAvailability,
modifier: Modifier = Modifier,
/**
* Overrides the colour the state would otherwise take, for the one surface the ladder above
* Overrides the color the state would otherwise take, for the one surface the ladder above
* doesn't cover: a word sitting *on* the saturated selection block, which carries
* `textOnAccent` whatever the thing on it would be coloured elsewhere (ADR 0032 §5) the notes
* `textOnAccent` whatever the thing on it would be colored elsewhere (ADR 0032 §5) the notes
* tree's selected row is the case. Nothing is lost by it: the two states are told apart by the
* word, and the colour was never carrying that on its own.
* word, and the color was never carrying that on its own.
*/
color: Color = Color.Unspecified,
) {
@@ -57,7 +57,7 @@ private val CHECK_ICON_SIZE = 12.dp
* @param truncateChars show this many characters before the ellipsis instead of the default 8.
* Ignored if [short] is given.
* @param noTruncate show the full value, never truncated. Ignored if [short] is given.
* @param color overrides the label colour (normally `onSurfaceVariant`) for a chip that needs to
* @param color overrides the label color (normally `onSurfaceVariant`) for a chip that needs to
* read as more than a dim machine value.
*/
@Composable
@@ -607,8 +607,8 @@ private fun CoverArt(item: MediaItem, placeholderIcon: ImageVector, glyphSize: D
/**
* A centered note for the empty / placeholder states.
*
* `textAlign` is load-bearing, not decoration: the Box centres the text *block*, so a message short
* enough to fit one line looks centred without it and a message long enough to wrap does not the
* `textAlign` is load-bearing, not decoration: the Box centers the text *block*, so a message short
* enough to fit one line looks centered without it and a message long enough to wrap does not the
* second line hugs the left edge of the block. Comics is where that showed, its message being the
* longest of the four.
*/
@@ -11,7 +11,7 @@ import uniffi.synchronicity.placeholderCopy
*
* Three words, and they are not interchangeable: a nameless *person* does not read as "Untitled",
* and a note with no title is not "(unnamed)". Keeping them apart is most of the point the TUI
* had already drifted, labelling untitled notes as though they were nodes, which is exactly the
* had already drifted, labeling untitled notes as though they were nodes, which is exactly the
* drift a single definition prevents.
*
* Fetched once and held: these are constants, and the only reason they cross the FFI at all is so
@@ -50,7 +50,7 @@ import coil.compose.AsyncImage
* ```text
* Artwork <- optional
*
* Primary metadata <- centred
* Primary metadata <- centered
* Secondary metadata
*
* Optional visualization <- collapses to nothing when absent
@@ -72,9 +72,9 @@ import coil.compose.AsyncImage
* and trailing times either side of the scrubber and anchors the transport to the left of a wide
* row. Neither survives a phone: a ~340dp content width minus two time labels leaves the scrubber a
* stub, and a left-anchored transport puts the play button under nobody's thumb. So the times sit
* *below* the full-width scrubber and the transport is centred. This is the same width-driven split
* the app already makes between two-column and stacked label/value lists the thing that would
* change the answer is width, not tidiness.
* *below* the full-width scrubber and the transport is centered. This is the same width-driven
* split the app already makes between two-column and stacked label/value lists the thing that
* would change the answer is width, not tidiness.
*
* This widget is transport-agnostic: it renders the state it is handed and reports gestures back.
* It never touches [com.gregshuflin.synchronicity.podcast.AgentAudioPlayer], so the screen stays
@@ -103,7 +103,7 @@ fun PlayerControls(
secondaryActions: @Composable RowScope.() -> Unit = {},
) {
// Live scrub position while the user drags the thumb; null when not scrubbing. Owned here so
// every host gets the same behaviour — the time label follows the thumb, not the sink.
// every host gets the same behavior — the time label follows the thumb, not the sink.
var scrubPositionMs by remember { mutableStateOf<Long?>(null) }
Column(
@@ -219,7 +219,7 @@ sealed interface TransportStep {
* Whether the screen has a track either side of the one in the player, for a player whose primary
* cluster steps between tracks ([TransportStep.Adjacent]).
*
* Both false the default greys the pair out, which is the honest rendering for a track with no
* Both false the default grays the pair out, which is the honest rendering for a track with no
* surrounding ordering: one song opened on its own has no "next".
*/
data class Adjacency(val previous: Boolean = false, val next: Boolean = false)
@@ -297,9 +297,9 @@ private fun Artwork(uri: String?) {
}
/**
* **Metadata.** Title over subtitle, centred across the whole player which is to say centred over
* the scrubber below it, not over the transport cluster. Both lines truncate rather than wrap, so a
* long episode title can't push the timeline or the controls down the screen.
* **Metadata.** Title over subtitle, centered across the whole player which is to say centered
* over the scrubber below it, not over the transport cluster. Both lines truncate rather than wrap,
* so a long episode title can't push the timeline or the controls down the screen.
*/
@Composable
private fun Metadata(track: NowPlaying?, isLoading: Boolean) {
@@ -394,7 +394,7 @@ private fun Timeline(
}
/**
* **Primary transport.** Back, play/pause, forward centred, because a phone's reachable area is
* **Primary transport.** Back, play/pause, forward centered, because a phone's reachable area is
* the middle-bottom of the screen rather than either edge. Play/pause is filled and larger than
* what flanks it: "play/pause should be the strongest visual control", which doesn't survive being
* one more outlined glyph in a row of them.
@@ -84,7 +84,7 @@ enum class PresenceLook {
}
}
/** The colour this state's dot and label take. */
/** The color this state's dot and label take. */
@Composable
fun PresenceLook.color(): Color =
when (this) {
@@ -98,7 +98,7 @@ fun PresenceLook.color(): Color =
* A filled dot when the copy is here, an empty outlined one when it is not.
*
* The outline is what carries "nothing is wrong, there is simply nothing here" without spending any
* colour on it and spending no colour on the common case is what leaves the palette free to mark
* color on it and spending no color on the common case is what leaves the palette free to mark
* the exceptions.
*/
@Composable
@@ -73,10 +73,10 @@ data class RemoteFetch(val state: FetchDisplay, val jobId: String?) {
suspend fun cancel(nativeLib: NativeLib) {
val jobId = jobId ?: return
withContext(Dispatchers.IO) { nativeLib.jobs.cancelJob(jobId) }
.onSuccess { cancelled ->
if (!cancelled) Log.i(TAG, "Content-stream job $jobId had already settled")
.onSuccess { canceled ->
if (!canceled) Log.i(TAG, "Content-stream job $jobId had already settled")
}
.onFailure { Log.w(TAG, "Cancelling content-stream job $jobId failed", it) }
.onFailure { Log.w(TAG, "Canceling content-stream job $jobId failed", it) }
}
}
@@ -32,7 +32,7 @@ private val TITLE_TRACKING = 0.8.sp
*
* The parts, left to right: a short accent bar, the title, a hairline rule filling the remaining
* width, and an optional trailing value in dim mono. The bar is the only place a heading spends any
* colour; the rule is what makes a heading read as a division of the screen rather than as a bold
* color; the rule is what makes a heading read as a division of the screen rather than as a bold
* line of text.
*
* This is the one place the two modalities legitimately differ in execution rather than in
@@ -83,7 +83,7 @@ val LocalSnackbarController =
*
* [CancellationException] is deliberately not caught (rethrown immediately): coroutine cancellation
* depends on it propagating swallowing it would break structured concurrency (e.g.
* cancel-on-navigate-away no longer actually cancelling).
* cancel-on-navigate-away no longer actually canceling).
*/
@Suppress("TooGenericExceptionCaught")
fun CoroutineScope.launchReportingFailure(
@@ -12,8 +12,8 @@ import androidx.compose.ui.graphics.Color
import uniffi.synchronicity.ReadingSurface
/**
* The colours of a *reading* surface: the ground a document is rendered on, plus the ink and link
* colour that go with it. Kotlin counterpart of `rust/gui-app/src/theme.rs`'s `Page`.
* The colors of a *reading* surface: the ground a document is rendered on, plus the ink and link
* color that go with it. Kotlin counterpart of `rust/gui-app/src/theme.rs`'s `Page`.
*
* Separate from [SynPalette] on purpose ADR 0032 §5 makes the page a different choice from the
* chrome (the metaphor is a desk, dark tools and a light page), so a reader who wants white paper
@@ -42,7 +42,7 @@ data class Page(
*/
val panel: Color,
) {
/** Whether the page is a dark one — decides which base Material scheme it recolours. */
/** Whether the page is a dark one — decides which base Material scheme it recolors. */
val isDark: Boolean
get() = surface == ReadingSurface.DARK
@@ -93,7 +93,7 @@ private val Dark =
)
/**
* This palette recoloured for a reading surface the Compose counterpart of `Theme::on_page`.
* This palette recolored for a reading surface the Compose counterpart of `Theme::on_page`.
*
* Only the slots the note-body renderers actually read are overridden (background/surface for the
* ground, `surfaceVariant` for a code fence or table header, the `on*` text roles, the outline
@@ -115,15 +115,15 @@ private fun pageColorScheme(page: Page): ColorScheme =
)
/**
* Wraps [content] so it renders as a reading surface rather than chrome: [MaterialTheme]'s colour
* Wraps [content] so it renders as a reading surface rather than chrome: [MaterialTheme]'s color
* slots are re-pointed at [page] via [pageColorScheme], and the wrapper itself paints the page's
* ground mirroring `markup_view`'s own `.bg(theme.bg_window)` once its caller has handed it a
* page-recoloured `Theme`. Used by the notes preview (`notes/NoteEditorPane.kt`) for Markdown and
* page-recolored `Theme`. Used by the notes preview (`notes/NoteEditorPane.kt`) for Markdown and
* plain-text bodies; Typst notes render on `bgMediaCanvas` instead (ADR 0032 gap #1), not a page.
*
* [LocalContentColor] is re-pointed too, and that part is not optional. A `Text` with no explicit
* colour does not read the colour scheme at all it reads `LocalContentColor`, which the app's own
* `Surface` set to *chrome* ink several levels up. Recolouring the scheme alone therefore put a
* color does not read the color scheme at all it reads `LocalContentColor`, which the app's own
* `Surface` set to *chrome* ink several levels up. Recoloring the scheme alone therefore put a
* light page under body text still painted for Charcoal, which is the whole document unreadable.
* Providing it here is what a `Surface` would do for its own content, without the elevation overlay
* a `Surface` would also bring.
@@ -7,17 +7,17 @@ import androidx.compose.ui.graphics.Color
// modalities read the same names for the same jobs, so a value that moves there
// has an obvious counterpart here.
//
// These are the app's own colours. Material You (dynamic colour) is deliberately
// These are the app's own colors. Material You (dynamic color) is deliberately
// not used; ADR 0032 §10 has the reasoning, but the short form is that this
// palette assigns *meaning* by hue — cobalt is energised, green is present, amber
// is attention, red is a fault — and a wallpaper-derived `primary` reassigns the
// hue out from under the meaning. A green wallpaper would make "selected" and
// "present" the same colour.
// "present" the same color.
//
// Every ratio quoted below is measured against that palette's `bgWindow`.
/**
* One ground: every colour role the app names, for a single theme.
* One ground: every color role the app names, for a single theme.
*
* Adding a role here forces both [Charcoal] and [Slate] to answer for it, which is the point a
* role that only one ground defines is a role that will look wrong on the other.
@@ -44,7 +44,7 @@ data class SynPalette(
val borderDefault: Color,
/** The stronger rule — under a table header, around a card. */
val borderCard: Color,
/** Body text. Cool light grey on Charcoal, never white. */
/** Body text. Cool light gray on Charcoal, never white. */
val textPrimary: Color,
/** Secondary information, which is the main texture of the app. */
val textSecondary: Color,
@@ -16,7 +16,7 @@ import androidx.compose.ui.graphics.Color
* The palette in force, reachable by its ADR 0032 role name.
*
* Material's slots cover most of what screens need and should be preferred where they fit
* `colorScheme.onSurfaceVariant` says "secondary text on a card" more precisely than a raw colour
* `colorScheme.onSurfaceVariant` says "secondary text on a card" more precisely than a raw color
* does. This is for the roles Material has no slot for at all: success, warning, the third text
* tier, and the media canvas.
*/
@@ -29,17 +29,17 @@ object SynTheme {
}
/**
* Projects one of ADR 0032's grounds onto Material 3's colour slots.
* Projects one of ADR 0032's grounds onto Material 3's color slots.
*
* Two mappings here are deliberate and worth knowing about:
*
* The **tonal families collapse**. Material offers primary, secondary and tertiary as three
* distinguishable accents; ADR 0032 §4 says no colour ever encodes a category, so all three resolve
* distinguishable accents; ADR 0032 §4 says no color ever encodes a category, so all three resolve
* to the one accent. A screen reaching for `tertiary` to look different gets the accent, which is
* the intended answer.
*
* The **containers are not tinted**. `errorContainer` is a plain card with error-coloured text on
* it rather than a red-washed panel, because §5 asks for hairlines and gutters instead of tinted
* The **containers are not tinted**. `errorContainer` is a plain card with error-colored text on it
* rather than a red-washed panel, because §5 asks for hairlines and gutters instead of tinted
* regions. `primaryContainer` is the one exception, and is [SynPalette.accentFill] that is the
* selection block of §5, which is meant to be saturated.
*
@@ -106,9 +106,9 @@ private val CharcoalScheme = scheme(Charcoal, dark = true)
private val SlateScheme = scheme(Slate, dark = false)
/**
* The chrome scheme [Page.pageColorScheme] recolours for a reading surface chosen by the page's
* The chrome scheme [Page.pageColorScheme] recolors for a reading surface chosen by the page's
* own lightness rather than the app's active theme, mirroring `Theme::on_page`'s `source` (a light
* page keeps light-mode status/syntax colours even inside a Charcoal-chrome window, or they'd be
* page keeps light-mode status/syntax colors even inside a Charcoal-chrome window, or they'd be
* unreadable against it).
*/
internal fun materialBaseScheme(dark: Boolean): ColorScheme =
@@ -117,7 +117,7 @@ internal fun materialBaseScheme(dark: Boolean): ColorScheme =
/**
* The app's theme.
*
* There is no dynamic-colour path. See the note at the top of Color.kt and ADR 0032 §10 briefly,
* There is no dynamic-color path. See the note at the top of Color.kt and ADR 0032 §10 briefly,
* this palette encodes state in hue, and a wallpaper-derived accent reassigns the hue without
* reassigning the meaning.
*/
@@ -5,7 +5,7 @@ import org.junit.Assert.assertTrue
import org.junit.Test
/**
* What greys out the player's Previous/Next. The rule the buttons depend on is "there is a track
* What grays out the player's Previous/Next. The rule the buttons depend on is "there is a track
* that way", which has three distinct no-answers: no surrounding list at all, the top of one, and
* the bottom of one.
*/
@@ -15,7 +15,7 @@ class AdjacencyTest {
/** Playing one song with nothing around it — a folder of one, a track opened on its own. */
@Test
fun `a track with no surrounding list has no neighbours`() {
fun `a track with no surrounding list has no neighbors`() {
val alone = tracks("a").first()
val adjacency = adjacencyOf(alone, emptyList())
assertFalse(adjacency.previous)
@@ -44,7 +44,7 @@ class AdjacencyTest {
* screen's own selection gone must not offer a step it can't take.
*/
@Test
fun `a track that is not in the list has no neighbours`() {
fun `a track that is not in the list has no neighbors`() {
val adjacency = adjacencyOf(null, tracks("a", "b"))
assertFalse(adjacency.previous)
assertFalse(adjacency.next)
+1 -1
View File
@@ -75,7 +75,7 @@ check:
#
# The crop is measured, not eyeballed: the white mark occupies 412x451 at
# +180+148 of the 775x771 source (`magick ... -threshold 60% -format %@`), so a
# 578px square centred on it leaves the mark filling ~78% of the frame. The
# 578px square centered on it leaves the mark filling ~78% of the frame. The
# uncropped source has enough dead navy around the mark that a 16px toolbar
# icon rendered as a smudge with a dot in the middle.
[doc: "Re-render browser-extension/src/icons/ from assets/synchronicity-icon.png"]
+2 -2
View File
@@ -1,7 +1,7 @@
/*
* Popup styling.
*
* Colours are declared once as `light-dark()` pairs and the page opts into
* Colors are declared once as `light-dark()` pairs and the page opts into
* `color-scheme: light dark`, so the popup follows the browser's theme without
* a media query duplicating the palette.
*/
@@ -221,7 +221,7 @@ button {
color: var(--fg-muted);
line-height: 1.4;
/* Two lines of a clipping is enough to recognise it; the node has the rest. */
/* Two lines of a clipping is enough to recognize it; the node has the rest. */
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
+1 -1
View File
@@ -21,7 +21,7 @@ const PUBKEY_PREFIX_LENGTH = 12;
* How much of this browser's own id the collapsed form shows.
*
* Shorter than a node's, and deliberately: a uuid's first block is already a
* distinct-looking chunk, and this is here to be *recognised* against the row
* distinct-looking chunk, and this is here to be *recognized* against the row
* the node's Settings screen lists it under, not read out.
*/
const CLIENT_ID_PREFIX_LENGTH = 8;
+1 -1
View File
@@ -30,7 +30,7 @@ point somewhere else.
## Why it reads the upstream packages
Both candidate icon sets are extracted from the exact upstream artefacts the
Both candidate icon sets are extracted from the exact upstream artifacts the
app builds against — `lucide-static` at the version
`rust/gui-app/scripts/vendor-icons.sh` pins, and `@material-design-icons/svg`,
the SVG mirror of `androidx.compose.material:material-icons-extended`. A name
+2 -2
View File
@@ -424,7 +424,7 @@ def grid_panels(theme: dict, lucide: IconSet) -> str:
"The 8px <code>CARD_TEXT_GAP</code> between cover and title is already "
"empty. A 2px rule there costs no layout, touches no pixel of the artwork, "
"and carries the broken-outline idea at 168px instead of 8 &mdash; where "
"solid-vs-dashed is unmistakable rather than a judgement call.",
"solid-vs-dashed is unmistakable rather than a judgment call.",
card_row(theme, rule_t),
))
@@ -454,7 +454,7 @@ def grid_panels(theme: dict, lucide: IconSet) -> str:
theme, "5 &middot; Rule and word together", "recommended",
"The rule is what you see while scanning a grid at arm&rsquo;s length; the "
"word is what you read when one card has caught your eye. Neither is "
"load-bearing alone, so the colour-only worry goes away &mdash; and the same "
"load-bearing alone, so the color-only worry goes away &mdash; and the same "
"pairing degrades cleanly to just the word in a list row, and to just the "
"dot in the sidebar, where there is no room for either of the other two.",
card_row(theme, both_t),
+21 -21
View File
@@ -63,7 +63,7 @@ identity the rest of the app doesn't yet know it has.
4. **Danger changes hue between modes** — orange in light, mustard in dark —
while red means "Absent." Danger should be one idea.
5. **Two "selected" weights, not just two hues.** Nav selection is a loud
filled pill; the selected note in the Notes list is a barely-there grey.
filled pill; the selected note in the Notes list is a barely-there gray.
6. **No measure.** Detail's content is pinned to a ~530 px column with ~1400 px
of nothing beside it; Settings puts each label at far left and its segmented
control at far right of a 1900 px row. Both symptoms of one missing rule: a
@@ -71,7 +71,7 @@ identity the rest of the app doesn't yet know it has.
7. **Covers in boxes.** Every comic thumbnail gets a hairline border in both
modes; captions truncate hard with no ellipsis or fade. Art should float.
8. **The sidebar is an alphabetical list of features.** "Command Palette" (an
action) sits beside "Comics & Manga" (a place); "Media" is greyed with no
action) sits beside "Comics & Manga" (a place); "Media" is grayed with no
visible reason; no grouping, counts, or hierarchy. The single biggest
"made of features rather than of a point of view" signal.
9. **Redundant type signal** in Documents: the `MUS` badge (a nice small idea)
@@ -145,7 +145,7 @@ identity (§5).
Recorded verbatim in spirit so the reasoning survives the session.
- **Color:** likes charcoal greys and blues — particularly **deep, saturated
- **Color:** likes charcoal grays and blues — particularly **deep, saturated
cobalt** — for the way it evokes *lines of electricity energizing circuits*.
Doesn't hate the current lilac in isolation, but it isn't right for what
Synchronicity should be. Wants **more color, and more saturated color**, not
@@ -183,18 +183,18 @@ in the palette specimen, not yet a rule.
one: selected, focused, connected, in flight, playing. When nothing is
happening there is no cobalt — the panel is at rest. This is Direction A's
"accent only for state" with a reason a user can feel, and it is literally
what the app does (signal travelling between nodes). It also removes the lilac
what the app does (signal traveling between nodes). It also removes the lilac
at the root: a pastel *tint* of the accent used as a background is the accent
with the current turned off. **Selection is a saturated block with light text
on it, never a tint.**
**What LCARS does mechanically** (the transferable parts, no cosplay):
- Saturated color only ever sits on black/near-black — never on white or grey.
- Saturated color only ever sits on black/near-black — never on white or gray.
That is why the saturation works and never looks garish. Transfer: saturated
hues live on charcoal, full stop.
- Color is *structural*, not decorative: flat blocks are the layout, framing
and labelling regions. Modern equivalent: reversed selection, section
and labeling regions. Modern equivalent: reversed selection, section
headers as filled bars.
- Shape rule: round-ended bars for controls and frames, hard rectangles for
content — the same line Direction A drew (rounded controls, sharp
@@ -232,7 +232,7 @@ instrument-panel pairing — cockpits, VU meters), **red** for destructive /
absent, **green** for present. One hue per meaning replaces the
orange-vs-mustard drift.
**Text on charcoal is cool light grey, not white**; mono, tabular, small for
**Text on charcoal is cool light gray, not white**; mono, tabular, small for
every machine value. (Aviation-display discipline — see `REFERENCES.md`.)
**Structure by hairline and gutter, not by tinted panel.** Already the app's
@@ -330,7 +330,7 @@ placements earn it:
least not on the Ensemble screen, and the reason is a terminology collision
worth keeping on the record.
**Icons: fewer, bigger, labelled.** The most consistent thread across the whole
**Icons: fewer, bigger, labeled.** The most consistent thread across the whole
document — Sublime Merge praised specifically for icon size, clarity and
spacing ("many apps I use make the icons too small and too hard to guess at a
glance"); Blender, Zed, Linear and Obsidian each criticised for the opposite;
@@ -347,13 +347,13 @@ Bloomberg is too dense, and the stated reason is telling — *"I don't have an
immediate sense of what pieces of data different parts of the UI are for."*
That is not a row-height complaint, it is a grouping complaint. So: dense rows,
tight controls, and generous **dark gutters between regions**, each region
labelled. Exactly the LCARS negative-space mechanic from §5, now with an
labeled. Exactly the LCARS negative-space mechanic from §5, now with an
independent reason to believe it.
**Flat, and no shadows.** Sublime Merge's text-area drop shadow was the one
thing disliked in an otherwise-liked UI. Combined with the LCARS "no
skeuomorphism" note in §5: no shadows, no bevels, no glows-as-blur. The
"glowing" quality the founder likes in Bitwig comes from *saturated colour on a
"glowing" quality the founder likes in Bitwig comes from *saturated color on a
dark ground*, not from a blur — a blurred glow would read as the kitsch §5's
Tron entry warns about.
@@ -407,14 +407,14 @@ Three directions, same screens, same content, so the difference is the design
and not the data — see [`palette-specimen/README.md`](palette-specimen/README.md):
1. **Instrument** — the restrained reading of §5. One hue, hairlines, no
colour except state. The control: is this too quiet now that "more Bitwig"
color except state. The control: is this too quiet now that "more Bitwig"
is the calibration?
2. **Console** — the Bitwig reading. Same bones, plus a saturated per-type
colour family carried as flat blocks, the node lane at full strength, grid
color family carried as flat blocks, the node lane at full strength, grid
texture. Tests the hue ceiling of open question 3.
3. **Signal** — the LCARS reading. Colour as *structure*: filled section bars,
3. **Signal** — the LCARS reading. Color as *structure*: filled section bars,
round-ended controls against hard rectangles, larger type contrast, wide
black gutters. Tests whether "structural colour" survives contact with a
black gutters. Tests whether "structural color" survives contact with a
real file list, or tips into costume.
Each renders on both a charcoal ground and a cool-slate ground, which is what
@@ -480,16 +480,16 @@ and is an improvement on what ships today; **Console is the preferred reading
in dark mode**, and Signal crosses into costume on a charcoal documents list
("star trek fanart") while reading much straighter on slate.
**Per-type colour is out.** *"I don't want to use particular colors to indicate
**Per-type color is out.** *"I don't want to use particular colors to indicate
particular types of document — there are too many types of document for that to
be useful."* Decisive, and it removes the whole `--h-*` family. Document type
becomes one more column of plain text, and the coloured edge beside it goes
becomes one more column of plain text, and the colored edge beside it goes
too. This restates open question 3 rather than answering it: the hue ceiling is
now a question about **state and activity**, which is where Console's saturation
has been re-pointed — presence, transfer, job meters, the activity track.
**And colour follows the same rule inside the lane.** Filling the *present*
cell — the state 90% of cells are in — spends the entire lane's colour budget
**And color follows the same rule inside the lane.** Filling the *present*
cell — the state 90% of cells are in — spends the entire lane's color budget
saying nothing, which is the type-hue mistake in miniature. Present is now
quiet; the eye catches the exception: an empty cell where a copy is missing,
cobalt where one is in flight, amber where a node has gone quiet. This also
@@ -549,7 +549,7 @@ That reframing raises the stakes on the token architecture rather than lowering
them: a preset is only a preset if every one of these differences is expressible
as tokens, and some of them currently are not — icons on or off in the rail,
pill versus block selection, and the section-heading grammar are structural,
not colour. Either they become tokens too (a shape/role layer above
not color. Either they become tokens too (a shape/role layer above
`theme.rs`), or presets are limited to palette and the structural choices are
made once, for all of them. That is a decision for the ADR.
@@ -611,12 +611,12 @@ that got there. In brief:
explicit rule about what may be written in it.
**On the question §8.6 left hanging** — what "structural differences as tokens"
means: three of the things separating the directions are not colour and not
means: three of the things separating the directions are not color and not
size, so `theme.rs` cannot express them. Rail icons on or off, selection as a
full-bleed block or an inset pill, and the section-heading grammar are
component structure. So either a second token layer above `theme.rs` describes
shape and component style, or user-selectable presets are limited to palette.
ADR 0032 §9 takes the second: presets recolour, structure is decided once. The
ADR 0032 §9 takes the second: presets recolor, structure is decided once. The
cost is that Signal — whose entire argument is structural — cannot ship as a
preset without that layer, which is why it is deferred rather than rejected.
`sizing::shape` is still worth building now, because `CRITIQUE.md` finding 2
@@ -32,7 +32,7 @@ it harder than it looks:
red/green pairing most status UIs reach for first.
4. **Nodes have human-chosen names**, and abbreviating one automatically to fit
a column header invents a short name the user didn't pick. Any per-node
column has to solve labelling, not just cells.
column has to solve labeling, not just cells.
5. **The interesting question is usually not "where is it" but "is it safe"**
is there more than one copy, is the only copy on the node most likely to be
lost, is a copy on its way here.
@@ -100,7 +100,7 @@ this problem actually does.
- **Mechanism:** workload × node placement, usually as a table with node names
in cells rather than a grid of cells, precisely because node names are long
and the count varies.
- **Why it's here:** the labelling problem (constraint 4) solved by not trying
- **Why it's here:** the labeling problem (constraint 4) solved by not trying
to make node a column.
- **Reaction:**
@@ -137,7 +137,7 @@ Stated so the reading has something to argue with. None is chosen.
[`DIRECTION.md`](DIRECTION.md) §8.6): **B in ordinary lists** — one indicator
for this node, which is the bit the user can act on in place — **A on a
dedicated `Presence` view**, where a column per node has the room to be
labelled in full and the node set becomes a control rather than a layout
labeled in full and the node set becomes a control rather than a layout
constraint, **and C beside it** as a `Copies` fraction that turns amber at one.
The full per-node picture also stays on the Detail view, which has both the
room and the user's attention. Nothing here is settled; the point is that the
@@ -145,7 +145,7 @@ place a better answer would change is now one view and one column, not the
app's primary list.
E is the one most consistent with everything else the redesign decided —
colour and marks for the exception, silence for the ordinary case — and it is
color and marks for the exception, silence for the ordinary case — and it is
also the only one that survives a twelve-node ensemble. It costs the ability to
answer "where is this" by scanning, which C recovers cheaply.
+16 -16
View File
@@ -3,12 +3,12 @@
Software (and a few non-software things) worth spending time looking at before
the [`palette-specimen/`](palette-specimen/) exploration, tuned to the
preferences in [`DIRECTION.md`](DIRECTION.md) §4: charcoal + saturated cobalt,
dense, hairline-split, developer-flavoured, one dark-leaning style, paper for
dense, hairline-split, developer-flavored, one dark-leaning style, paper for
reading — and *not* pastel, not desaturated white-on-slate, not turquoise, not
libadwaita, not evocative of Discord.
How to look: **separate palette from bones.** Palette is the loudest thing in
any screenshot and the cheapest to swap. For each, ask "recoloured in my
any screenshot and the cheapest to swap. For each, ask "recolored in my
charcoal + cobalt, would I like the structure?" — row rhythm, how sections are
separated, where controls sit relative to labels, fill vs. hairline, how
selection is shown. Each entry says what to look at, what to ignore, and why
@@ -24,15 +24,15 @@ theme" / "screenshot" / "UI".
- **Look at:** the hairline area splits (already imitated in the app); every
editor area having its own thin header strip; the Properties editor's
vertical icon-tab rail; the Outliner as a dense tree with tiny icons; how a
*single* bright colour (orange in the viewport, blue in lists/buttons) does
*single* bright color (orange in the viewport, blue in lists/buttons) does
all selection work on uniform charcoal; tiny type at high density.
- **Ignore:** the viewport itself; the sheer number of panels.
- **Why:** the founder's stated positive reference; the closest existing
"dense instrument on charcoal with hairlines" and a dark-only precedent.
- **Reaction:** I've used blender for many years, although I'm only an amateur hobbyist 3d modeler; I'm definitely not
an expert in all areas of the program. In general I like the multiple-types-of-charcoal-grey color scheme of the program, along with
an expert in all areas of the program. In general I like the multiple-types-of-charcoal-gray color scheme of the program, along with
the relatively bright saturated colors of the small icons for various interactable widgets. I also have always liked the design of
node editors - grey boxes full of white numbers, with judicious use of relatively-satured bright colors for meter bars and titles,
node editors - gray boxes full of white numbers, with judicious use of relatively-satured bright colors for meter bars and titles,
white text on the background (as well as the nice curve in the lines that link nodes in the node editor). I'm not sure if anything in
Synchronicity needs something like a node editor, or if the general design of blender is necessarily appropriate for Synchronicity
given the very different functions of the two programs. But yeah on the whole I feel good about blender.
@@ -58,8 +58,8 @@ I tend to turn that on. The icons also strike me as too big. I would say that th
cobalt blue, it's a bit lighter than that. Certainly it's an effective color in that context though.
### Ableton Live (dark theme) — ableton.com
- **Look at:** flat, sharp, dense; *colour blocks as structure* (clips and
tracks are labelled by solid colour on charcoal — the most LCARS-like
- **Look at:** flat, sharp, dense; *color blocks as structure* (clips and
tracks are labeled by solid color on charcoal — the most LCARS-like
mechanism in a real tool); how many saturated hues coexist without chaos
because the ground is dark and the blocks are flat.
- **Ignore:** the specific pastel-ish clip palette in some themes.
@@ -85,7 +85,7 @@ synchronicity documents list.
### Bitwig Studio — bitwig.com
- **Look at:** same family as Ableton but more saturated and slightly rounder;
a good second data point on where "more colour" starts to feel like too
a good second data point on where "more color" starts to feel like too
much; its device panels as bordered regions.
- **Why:** calibrates saturation against Ableton.
- **Reaction:** also software I've never used before. Immediate gut reaction: I like it! I like it more than ableton!
@@ -161,7 +161,7 @@ interface.
### Roon and Plexamp — roonlabs.com, plex.tv/plexamp
- **Look at:** how cover art floats on near-black with *no borders*; type over
and beside images; chrome that recedes; Plexamp's use of a saturated colour
and beside images; chrome that recedes; Plexamp's use of a saturated color
pulled from the art as ambient accent.
- **Ignore:** Roon's occasional big-serif marketing pages.
- **Why:** the media grid (Comics, Music, Pictures) currently boxes every
@@ -177,7 +177,7 @@ interface.
### Zathura and Sioyek — pwmt.org/projects/zathura, sioyek.info
- **Look at:** "dark chrome, paper page" as the *entire* reading-surface
identity; how little chrome a PDF reader needs; Sioyek's dev-flavoured
identity; how little chrome a PDF reader needs; Sioyek's dev-flavored
overlays (dark, monospace, keyboard-driven).
- **Why:** the paper/reading half of the direction, and evidence the serif
question is independent of it.
@@ -189,12 +189,12 @@ This desideratum needs PDF-document-specific feature work to get right, but if a
trying to be particularly minimal with the PDF reader chrome.
### Aviation primary flight displays (Garmin G1000 / airliner PFD)
- **Look at:** the *semantic colour discipline*: magenta = active flight plan,
- **Look at:** the *semantic color discipline*: magenta = active flight plan,
cyan = data, green = active mode, white = armed, amber = caution, red =
warning — strict, never decorative, on black. Dense, small type, everything
labelled, tape and numeric texture.
labeled, tape and numeric texture.
- **Ignore:** the specific hues (magenta/cyan aren't the target).
- **Why:** the model for "one semantic set, one personality hue" — colour that
- **Why:** the model for "one semantic set, one personality hue" — color that
means something, every time.
- **Reaction:** I really dont' know enough about flying planes to judge how effective this UI is.
I guess consistent color discipline is a good principle, I'm not necessarily fond of their specific
@@ -203,7 +203,7 @@ design inspiration this plane UI should be, one way or the other.
### Bloomberg Terminal
- **Look at:** saturated amber/cobalt/white on black; extreme density; numbers
as texture; colour = data type. Beloved by its users precisely for density.
as texture; color = data type. Beloved by its users precisely for density.
- **Ignore:** the ugliness of the widgets themselves; the amber-first palette.
- **Why:** the far end of the density axis — useful for calibrating how far
the machine surfaces can go.
@@ -216,7 +216,7 @@ of what pieces of data different parts of the UI are for.
### LCARS, late-Voyager / DS9 era — trekcore.com screencaps
- **Look at:** *Voyager's astrometrics lab* (the blue-heavy LCARS set) and
late-series bridge/engineering displays vs. season-1 TNG. Note: saturated
colour only on black; flat blocks as layout; round-ended bars for controls,
color only on black; flat blocks as layout; round-ended bars for controls,
hard rectangles for content; small dense text and numbers; large black
gutters between blocks.
- **Ignore:** the elbows, the all-caps condensed face, anything that would
@@ -239,7 +239,7 @@ with actors wearing colored reflective tape on dark fabric bodysuits on a dimly-
honestly it looks to me to be pretty similar to any other 2000s video game that was trying to depict futuristic computers.
### Destiny 2 menus, No Man's Sky UI
- **Look at:** dense, saturated colour on dark; sharp geometric containers;
- **Look at:** dense, saturated color on dark; sharp geometric containers;
believable-future computer feel; how much text and numbers they carry.
- **Why:** game UIs are the richest source of "saturated, dense, dark,
believable" — the same brief LCARS answered.
@@ -34,12 +34,12 @@ falls back to system faces and the layout still reads; only the type does not.
| | What it pushes | What it is testing |
| --- | --- | --- |
| **1 — Instrument** | one hue, hairlines, no colour except state | whether the restrained reading is now too quiet, given "more Bitwig" as the saturation calibration |
| **1 — Instrument** | one hue, hairlines, no color except state | whether the restrained reading is now too quiet, given "more Bitwig" as the saturation calibration |
| **2 — Console** | saturation spent on state and activity, on a ruled ground | the hue ceiling — open question 3, restated now that per-type hues are out |
| **3 — Signal** | colour as structure: filled round-ended section bars, pill controls, hard-rectangle containers, wide gutters | whether structural colour survives a real file list, or tips into costume |
| **3 — Signal** | color as structure: filled round-ended section bars, pill controls, hard-rectangle containers, wide gutters | whether structural color survives a real file list, or tips into costume |
Document type is a plain text column in all three: there are far too many types
for a hue each to mean anything, and colour is reserved for state. The rails
for a hue each to mean anything, and color is reserved for state. The rails
differ per direction — a nav column identical across three directions would be
evidence that none of them is pushing anything — and the slot on the right of
each row carries live agent state rather than a count of everything the agent
@@ -51,13 +51,13 @@ Two further mechanisms appear in all three:
indicator per row, for this node only — here, arriving, not here — because
that is the bit you act on without leaving the row. The survey question,
*where is everything across the ensemble*, gets its own `Presence` tab on
Documents, where a column per node has room to be labelled in full, the node
Documents, where a column per node has room to be labeled in full, the node
set is a control rather than a layout constraint, and two filters (*missing
somewhere*, *only one copy left*) answer what people actually arrive with.
A quiet filled cell is present, an empty one is a missing copy, hatched
cobalt is in flight, an amber outline is a node gone quiet — present takes no
colour, because a saturated fill on the state most cells are in spends the
whole colour budget saying nothing. Whether cells are the right affordance at
color, because a saturated fill on the state most cells are in spends the
whole color budget saying nothing. Whether cells are the right affordance at
all is still open: see
[`../PRESENCE-AFFORDANCE.md`](../PRESENCE-AFFORDANCE.md). Home carries the
same cell grammar over time, as a 24-hour activity track.
@@ -5,8 +5,8 @@
<style>
/* ==========================================================================
Specimen shell — deliberately hue-neutral so it cannot contaminate the
judgement of the cobalt mockups it frames. Single visual world (a dark
gallery wall); every colour is painted explicitly.
judgment of the cobalt mockups it frames. Single visual world (a dark
gallery wall); every color is painted explicitly.
========================================================================== */
:root{
--wall:#0b0b0c; --wall-2:#151517; --wall-3:#1d1d20;
@@ -242,13 +242,13 @@ a{color:inherit}
[data-dir="signal"] .sec .cnt{background:var(--bg2);padding:3px 14px;height:19px;
border-radius:0 var(--radius-pill) var(--radius-pill) 0;line-height:13px}
/* ---- document type: a column, not a colour. There are far too many types
for a hue each to mean anything, and colour is reserved for state. ---- */
/* ---- document type: a column, not a color. There are far too many types
for a hue each to mean anything, and color is reserved for state. ---- */
.ty{color:var(--t1);flex:none;overflow:hidden;text-overflow:ellipsis}
/* ---- the node lane: an Ableton clip lane whose columns are ensemble nodes ---- */
/* ---- Presence: a whole view for the survey, so a column per node can be
labelled in full and given room. Not on the main list — see
labeled in full and given room. Not on the main list — see
../PRESENCE-AFFORDANCE.md. ---- */
.pgrid{display:grid;align-items:center;column-gap:0;row-gap:0;min-width:0}
.pgrid>*{padding:0 8px;height:var(--row-h);display:flex;align-items:center;
@@ -286,8 +286,8 @@ a{color:inherit}
.chips .lbl{font-size:var(--fs-small);color:var(--t2);margin-right:2px}
.lane{display:grid;grid-auto-flow:column;grid-auto-columns:26px;gap:var(--lane-gap);
align-items:center;flex:none}
/* Present is the ordinary case and takes no colour at all: a saturated fill on
the common state would spend the whole lane's colour budget saying nothing.
/* Present is the ordinary case and takes no color at all: a saturated fill on
the common state would spend the whole lane's color budget saying nothing.
What the eye should catch is the exception — an empty cell where a copy is
missing, cobalt where one is moving, amber where a node has gone quiet. */
.cell{height:13px;border:1px solid var(--line2);background:transparent;border-radius:1px}
@@ -617,13 +617,13 @@ const NAV = [
const DIRS = [
{key:'instrument',name:'1 — Instrument',
tag:'hairlines · colour only for state, and quietly',
note:'The restrained reading of DIRECTION §5. Presence reads in greys, cobalt appears only where something is in flight, section headings are a label and a rule. The control: with “more Bitwig” as the calibration, is this too quiet?'},
tag:'hairlines · color only for state, and quietly',
note:'The restrained reading of DIRECTION §5. Presence reads in grays, cobalt appears only where something is in flight, section headings are a label and a rule. The control: with “more Bitwig” as the calibration, is this too quiet?'},
{key:'console',name:'2 — Console',
tag:'Bitwig calibration · saturation on state and activity',
note:'Same bones, on a ruled charcoal ground, with the saturation spent on what is happening rather than on what things are: presence, transfer, job meters, the activity track. Tests how much saturated colour the app can carry before density reads as noise — open question 3, restated now that per-type hues are out.'},
note:'Same bones, on a ruled charcoal ground, with the saturation spent on what is happening rather than on what things are: presence, transfer, job meters, the activity track. Tests how much saturated color the app can carry before density reads as noise — open question 3, restated now that per-type hues are out.'},
{key:'signal',name:'3 — Signal',
tag:'colour as structure · round-ended bars · wide gutters',
tag:'color as structure · round-ended bars · wide gutters',
note:'The LCARS reading. Section headings become filled round-ended bars, controls take the pill, containers stay hard rectangles, gutters widen and type contrast rises. On charcoal this crosses into costume; on slate it reads much straighter. A candidate for a shipped optional theme rather than the default.'}
];
@@ -674,7 +674,7 @@ function lane(states){
}
/* In a list, presence reduces to the one bit the user can act on: is it on this
node. The whole ensemble's picture lives on the Presence view, where a column
per node has the room to be labelled properly. */
per node has the room to be labeled properly. */
function here(states,variant){
return `<div class="here${variant?' on-card':''}"><div class="cell" data-s="${states[0]}"></div></div>`;
}
@@ -1043,7 +1043,7 @@ SCREENS.parked = () => {
/* ---------- ensemble -----------------------------------------------------
Co-equal peers, so: a roster where every node is the same row, and a
reachability matrix that is symmetric by construction. No centre, no
reachability matrix that is symmetric by construction. No center, no
direction, no user-authored links — see DIRECTION.md 8.5.
------------------------------------------------------------------------ */
const LINKS = { // unordered pairs; the matrix reads each one twice
+9 -9
View File
@@ -71,7 +71,7 @@ Consequently any node that could dial one of ours could:
| ALPN | Capability granted to an arbitrary caller |
|---|---|
| `/syn/doc-sync/…` | Read the whole document catalogue — plaintext at rest, so plaintext on the wire — **and write to it**: phase 2 of `serve` opens a reverse stream, pulls documents/properties/edges/artifacts/tombstones from the caller, and commits them via `doc_sync::promote_session` |
| `/syn/doc-sync/…` | Read the whole document catalog — plaintext at rest, so plaintext on the wire — **and write to it**: phase 2 of `serve` opens a reverse stream, pulls documents/properties/edges/artifacts/tombstones from the caller, and commits them via `doc_sync::promote_session` |
| `/syn/artifact-sync/…` | `serve_push` applies arbitrary artifact values through `upsert_artifact` |
| `/syn/blob-sync/…` | Fetch any blob's ciphertext; push arbitrary blobs onto our disk |
| `/syn/node-admin/…` | `SetSyncPolicy`, `SetDocOverride`, `TriggerSync`, `ListJobs`, `CancelJob` |
@@ -422,14 +422,14 @@ strictly-better outcome for a much likelier failure — a phone that has been of
for a month should not be able to stop a laptop from retiring.
**Gossip is not delivery.** `GossipSender::broadcast` returns when iroh-gossip
has taken the payload, not when anyone applied it, so an acknowledgement needs
has taken the payload, not when anyone applied it, so an acknowledgment needs
a request/response: the retiring node dials peers and pushes the record on a
stream that replies once it is committed. Gossip still goes out — it is how the
*other* nodes hear promptly — but it cannot be what the wipe waits on.
**The delivery path must not be gated by the deny-list**, or it eats itself:
the peer applies the record, and from that instant refuses this node's next
connection — so a lost or truncated acknowledgement would be unrecoverable, and
connection — so a lost or truncated acknowledgment would be unrecoverable, and
the retiring node would conclude failure over a record that had in fact landed.
Revocation delivery is therefore served to a revoked caller. That is safe
because the protocol's only effect is to accept a signed revocation record,
@@ -438,7 +438,7 @@ most a revoked node can achieve on it is to prove itself revoked again. It also
makes the push idempotent, so "retry until acknowledged" terminates.
Within the peer, the order is **commit, then acknowledge**. The other way round
risks the retiring node wiping on an acknowledgement for a record the peer then
risks the retiring node wiping on an acknowledgment for a record the peer then
loses, which is precisely the failure this flow exists to prevent. Committing
first fails in the harmless direction — a wasted retry.
@@ -454,7 +454,7 @@ and no cancel.
The design first reached for the opposite — apply the record to this node's own
deny-list at signing time, which is what would stop it syncing, and let a user
who changed their mind back out before the first acknowledgement. Both halves
who changed their mind back out before the first acknowledgment. Both halves
were wrong. There is nothing to gain from the write: the store that would hold
the row is the one the wipe is about to destroy. And there is something to
lose — a retirement that never reached a peer would leave the row behind, and
@@ -471,9 +471,9 @@ did not retire — nothing was written and, if no peer acknowledged, nobody was
told. That is a coherent state to come back to; a durable half-retirement is
not.
**The first acknowledgement is still the point of no return** in the sense that
**The first acknowledgment is still the point of no return** in the sense that
matters: after it the ensemble knows, and the only coherent thing left is to
finish. A node that comes back up between the acknowledgement and the wipe
finish. A node that comes back up between the acknowledgment and the wipe
finds itself already refused by its peers, and should say so — the roster does,
from `this_node_is_revoked` — rather than presenting it as a fault.
@@ -914,7 +914,7 @@ computing it independently.
Both are confirmed the same way on both frontends: a modal stating what is
destroyed and, in its own paragraph, the half users get wrong — for retire,
that the next screen cannot be cancelled; for reset, that nobody else is
that the next screen cannot be canceled; for reset, that nobody else is
told and a delegation cert never expires, so the other nodes go on admitting
this one for ever.
@@ -948,7 +948,7 @@ computing it independently.
## Open Questions
- ~~**Where the acknowledgement protocol lives**~~ **Settled**: its own ALPN,
- ~~**Where the acknowledgment protocol lives**~~ **Settled**: its own ALPN,
`/syn/membership/…` (`p2p::revocation_delivery`). The deny-list exemption is
expressed as an associated const on `EnsembleProtocol`
(`REVOKED_PEERS: RevokedPeers`), defaulting to `Refuse` — so a protocol
@@ -1,4 +1,4 @@
# 0009: Two-plane sync — catalogue always replicates, content is policy-gated
# 0009: Two-plane sync — catalog always replicates, content is policy-gated
**Status**: Accepted
**Date**: 2026-07-22 (design), phased implementation through 2026-07-26
@@ -6,7 +6,7 @@
## Context
Before this design, sync controls were a pile of disconnected, mostly-unwired
pieces: manual per-peer catalogue sync, manual per-document blob/artifact
pieces: manual per-peer catalog sync, manual per-document blob/artifact
pull, a `SyncPolicy` (Stream/Sync) stored in Settings but consumed by
nothing, a `SyncDisposition` (per-peer eager push) store with no UI and no
consumer, an unwired "Sync continuously" toggle, and an inert "Delete
@@ -18,7 +18,7 @@ pressing buttons.
Split everything a node holds into two planes with different rules:
1. **Catalogue plane** — document rows, properties, artifact *values*
1. **Catalog plane** — document rows, properties, artifact *values*
(including `BlobRef` pointer values), tombstones, and presence records.
Always replicated to every node, continuously, with no user
configuration. This is what makes each node a complete,
@@ -28,12 +28,12 @@ Split everything a node holds into two planes with different rules:
where storage cost is real. See ADR 0010 for the policy itself.
The one deliberate upgrade hiding in the split: **artifact values move from
the content plane to the catalogue plane.** Note bodies, `ListenProgress`,
the content plane to the catalog plane.** Note bodies, `ListenProgress`,
`ReadingPosition`, and bookmarks are exactly the data a user wants
everywhere fast — small, high-value, cross-device — so the continuous sync
pass (ADR 0012) fetches all pending artifact stubs eagerly via the existing
`/syn/artifact-sync/…` protocol, rather than waiting for the same on-demand
trigger as blob bytes. `Thumbnail`-kind bytes ride the catalogue plane too
trigger as blob bytes. `Thumbnail`-kind bytes ride the catalog plane too
(size-capped, e.g. skip anything mislabeled that exceeds 1 MiB) since
browsing a library without cover art is a degraded experience.
@@ -41,7 +41,7 @@ browsing a library without cover art is a degraded experience.
reworked. Its job — "eagerly push type T to peer P" — is subsumed by a
cleaner rule matching the decentralized architecture: every node is
responsible for satisfying its own policy, by pulling. When a desktop
imports an album, the server learns about it via continuous catalogue sync
imports an album, the server learns about it via continuous catalog sync
and pulls the audio because *its own* policy says `Mirror(MusicTrack)`. No
node needs a table of opinions about what other nodes should receive.
Manual "Push now" stays as a separate, genuinely useful affordance (target
@@ -50,9 +50,9 @@ about to go offline, NAT-challenged peer) — it just isn't a policy.
The Artifact/Blob unification (retiring `Blob`/`BlobRole` as a separate
ontology in favor of `ArtifactValue` variants carrying a `BlobRef` pointer,
per `docs/data-model.md`) is what makes the plane assignment a type-system
fact rather than a convention: the catalogue plane is simply *all artifact
fact rather than a convention: the catalog plane is simply *all artifact
values*, and the content plane is the bytes those pointers address. Nothing
large can land on the catalogue plane without someone deliberately declaring
large can land on the catalog plane without someone deliberately declaring
a new inline kind with a large payload — a code-review-visible act.
## Consequences
+9 -9
View File
@@ -6,7 +6,7 @@ TUI/CLI 2026-07-30, every surface runner-backed 2026-08-23
## Context
Every long-running operation — imports, catalogue sync, content fetch, push,
Every long-running operation — imports, catalog sync, content fetch, push,
eviction, purge — had its own bespoke, per-widget async state
(`AsyncActionState`), with no shared place to see what was running, no
history of what finished or failed, and no way for one widget to know that
@@ -22,11 +22,11 @@ A single `Job` model, shared by every modality, lives in `lib`:
```rust
Job {
id: Uuid,
kind: JobKind, // Import | CatalogueSync | ContentFetch | Push
kind: JobKind, // Import | CatalogSync | ContentFetch | Push
// | Eviction | Purge
subject: JobSubject, // human label + optional document id / peer pubkey
origin: JobOrigin, // User | Scheduler | RemoteAdmin(NodePubkey)
state: JobState, // Queued | Running | Done | Failed(String) | Cancelled
state: JobState, // Queued | Running | Done | Failed(String) | Canceled
progress: Option<JobProgress>,
created_at / started_at / finished_at: Timestamp,
}
@@ -71,16 +71,16 @@ scheduler-triggered work specifically — a scheduler tick and a manual
`cancel_job` (added 2026-08-09) goes through that same scheduler-bound
`JobRunner`, so it's best-effort in the same lopsided way dedup is: a job
only has a live cancellation token if it was started through that runner —
`BackgroundSync`, and scheduler-originated `CatalogueSync`/`ContentFetch` (the
`BackgroundSync`, and scheduler-originated `CatalogSync`/`ContentFetch` (the
per-peer/per-pass sub-jobs `scheduler::run_all_passes` starts, each checking
`JobHandle::is_cancelled` at its own granularity). The same two kinds
`JobHandle::is_canceled` at its own granularity). The same two kinds
triggered manually (`sync_documents`/`sync_document_assets`, which use
`run_as_job`) aren't cancellable, nor is anything else `run_as_job` wraps
`run_as_job`) aren't cancelable, nor is anything else `run_as_job` wraps
(`Import`, `Push`, eviction, purge). The FFI method returns a plain `bool`
`true` if a token was found and flipped, `false` otherwise, with no attempt
to distinguish "unknown id" from "no live runner behind it" — and the Jobs
screen shows a Cancel button on every active row but surfaces a `false`
result as "can't be cancelled" rather than silently reloading, matching
result as "can't be canceled" rather than silently reloading, matching
desktop's row (which always has a token, since desktop's single `JobRunner`
backs every trigger).
@@ -125,11 +125,11 @@ Three things did *not* change, and are worth stating so the gap isn't
rediscovered as a bug:
- **How promptly a cancel lands is a property of the work, not of the job.**
Work that is handed a `ProgressReporter` and checks `is_cancelled` between
Work that is handed a `ProgressReporter` and checks `is_canceled` between
units stops there — the daemon's `ImportFiles` (between paths, and between
the files inside a directory), Android's batch import, the scheduler passes.
A single blocking P2P call — a manual pull, a push, an eviction — runs to
the end and is *then* recorded as `Cancelled`. That is the same granularity
the end and is *then* recorded as `Canceled`. That is the same granularity
desktop's own document-detail buttons have had since this ADR was written;
making it finer means threading cancellation into `asset_sync`, which is its
own change.
+6 -6
View File
@@ -6,9 +6,9 @@
## Context
Before this, every part of the two-plane promise (ADR 0009) required a
button press: catalogue sync, artifact/thumbnail fetch, and content fetch
button press: catalog sync, artifact/thumbnail fetch, and content fetch
all ran only when the user triggered them. Nothing made the promise "every
node has a complete catalogue view, and holds content per its own policy"
node has a complete catalog view, and holds content per its own policy"
true in the background.
## Decision
@@ -16,9 +16,9 @@ true in the background.
A background component in `lib`, driven differently per modality, runs
three passes:
1. **Catalogue pass**`sync_from_peer` (incremental, `since` = last
1. **Catalog pass**`sync_from_peer` (incremental, `since` = last
successful sync per peer) against each reachable ensemble peer.
2. **Catalogue-plane fetch pass** — after a catalogue pass, fetch all
2. **Catalog-plane fetch pass** — after a catalog pass, fetch all
pending artifact stubs (`/syn/artifact-sync/…`) and missing thumbnail
bytes (existing blob-sync) from whichever peers hold them.
3. **Content pass** — compute **wants**: blob hashes where the resolution
@@ -33,7 +33,7 @@ copy. Every peer sync and every content fetch goes through `JobRunner`
exactly like a manual trigger, so a scheduler tick that lands on work
already in flight defers to it (the payoff of ADR 0011's dedup key).
Of the five triggers originally named for the catalogue pass, **only
Of the five triggers originally named for the catalog pass, **only
startup, a periodic timer, and manual "Sync now" are implemented.**
Gossip-triggered "sync when a peer comes online" (needs hooking the gossip
module directly) and "after any local import or policy change" (needs every
@@ -73,7 +73,7 @@ target per `docs/sync-management.md`'s remote-administration section) had no
driver at all until now: no periodic timer, no startup trigger, and its
`/syn/node-admin/…` handler was never wired to a `JobRunner`, so even an
explicit remote `TriggerSync` had nothing live to act on. A node whose
policy said `Mirror` would sync catalogues (peers push/pull metadata
policy said `Mirror` would sync catalogs (peers push/pull metadata
directly) but never proactively fetch content — indistinguishable, from a
user setting `Mirror` on it, from the feature not working.
@@ -305,7 +305,7 @@ paired.
A scope model. The parked design on the `linkage-security` branch had
`LinkageScope::{Companion, Full}` chosen at pairing time (`syn linkage pair
--full`), stored per row, with a rule for reading an unrecognised scope from a
--full`), stored per row, with a rule for reading an unrecognized scope from a
newer node as the narrowest.
Nothing wanted `Full`. The only client's entire vocabulary is four commands —
@@ -11,7 +11,7 @@ and commit internally, per call. The natural, readable way to use them —
looping over a list of ids — silently opened a fresh SQLite connection (and
prepared a statement) per document. Listing N documents this way cost ~3N
connection opens; worse, a bulk write path like doc-sync ingesting a large
catalogue held the single SQLite writer lock across thousands of tiny
catalog held the single SQLite writer lock across thousands of tiny
commits in quick succession, which was enough to make a concurrent
interactive write (a Notes save on Android) exceed its busy timeout and
crash with `SQLITE_BUSY`. That crash is what started this work.
@@ -84,7 +84,7 @@ crash with `SQLITE_BUSY`. That crash is what started this work.
## Consequences
- A bulk write path (doc-sync ingest, catalogue import) now costs a handful
- A bulk write path (doc-sync ingest, catalog import) now costs a handful
of commits instead of thousands, closing the `SQLITE_BUSY`/writer-lock-
starvation crash this work started from.
- A batch of related reads gets a single consistent snapshot instead of N
@@ -93,7 +93,7 @@ Two of those matter directly to the never-crash goal rather than being style deb
**14 `UnsafeCallOnNullableType`** (`!!`, an unguarded crash each) and — see below — 55
discarded `Result`s.
## Verified behaviour (spikes, 2026-08-08)
## Verified behavior (spikes, 2026-08-08)
Everything below was run, not inferred from documentation — detekt 1.23.8 (this project's
pinned version), `detektMain` with type resolution, on a standalone Kotlin/JVM project at
@@ -110,7 +110,7 @@ at both versions.
| Does per-rule `excludes: ['**/glob/**']` work? | **Yes** |
| Does `IgnoredReturnValue` flag a discarded `Result` with **no annotation**? | **Yes**, via `returnValueTypes: ['kotlin.Result']`; and correctly does *not* flag a consumed one |
| Does detekt validate matcher strings? | **No.** It fails loudly on a misspelled *rule* name but a bad `value:` is silently inert — see the two rows above. Any change here needs a deliberately-failing test case. |
| Do `detektDebug`/`detektMain` exist in this project? | **Yes** (both `EXPERIMENTAL`-labelled by detekt) |
| Do `detektDebug`/`detektMain` exist in this project? | **Yes** (both `EXPERIMENTAL`-labeled by detekt) |
`returnValueTypes` accepts any fully-qualified type name, so a future custom result type
gets the same must-use enforcement as `kotlin.Result`. This design is not locked to
@@ -213,10 +213,10 @@ not determine an affordance.
- ADR 0022's Kotlin-side `SyncException.detail` and `reportFailure` keep working unchanged:
the failure slot still always holds a `SyncException`, it simply carries more variants.
Widening `SyncError` will break `detail`'s exhaustive `when` at compile time, which is
the intended behaviour and the reason it was written without an `else`.
the intended behavior and the reason it was written without an `else`.
- The prose-matching `From<Box<dyn Error>>` goes away — a silent-misclassification bug
removed, not merely relocated.
- The cost is ~25 hand-written `From` impls that must each make a judgement about what a
- The cost is ~25 hand-written `From` impls that must each make a judgment about what a
given failure means to a person. That work is unavoidable under any of the options
considered (the blanket macro destroys the information regardless), so it is not a
tiebreaker, but it is the bulk of the effort and it cannot be mechanical.
@@ -91,7 +91,7 @@ fallbacks of ADRs 0015 and 0016 are legible in the browser.
notebooks) rather than being deleted. Two components with overlapping
purpose is the accepted cost of not bending one to fit both.
- Because the component owns no tab or tree semantics, two agents *can* end
up with inconsistent tab sets or drill-in behaviour. That is deliberate —
up with inconsistent tab sets or drill-in behavior. That is deliberate —
cross-agent consistency here is a review concern, not something the type
system enforces.
- Known limits carried forward, none of which are blocking: keyboard
@@ -51,7 +51,7 @@ worth sharing (`short_id`, the placeholder resolvers) already are.
node id or document id in a list row is a handle: you are scanning for which
row, not verifying a value, and the full form is one hover or tap away. A
blob/content hash is presented precisely so it can be compared against
something — truncating it defeats its only purpose. This ADR is not licence to
something — truncating it defeats its only purpose. This ADR is not license to
start truncating blob hashes.
Where the full id *is* revealed, it is selectable/copyable text, matching
@@ -60,7 +60,7 @@ every other full-hash display site.
## Consequences
- Any new screen showing a node or document identity gets consistent
truncation, placeholder styling, and reveal behaviour by using the
truncation, placeholder styling, and reveal behavior by using the
component, instead of re-deriving all three.
- Truncation to 8 hex characters is a collision risk in principle. Accepted:
these ids are scanning handles within a personal ensemble of a handful of
@@ -67,7 +67,7 @@ single-blob stream; artifact-sync records a `None` response the same way, and
records a *push* as possession evidence about the pusher.
**Releases promulgate; peers don't have to discover evictions.** Eviction
leaves the catalogue intact by design, so nothing about an evicted document
leaves the catalog intact by design, so nothing about an evicted document
changes — its `modified_at` is untouched and an incremental doc-sync will
never mention it again. `execute_eviction` therefore logs each deleted blob to
`content_release_log`, and doc-sync ships those as a `ContentReleaseBatch`
@@ -76,7 +76,7 @@ receiver records each as `Absent` about the sender.
A release is the content-plane counterpart to an artifact tombstone and
deliberately weaker: *"I dropped my copy"*, not *"this is gone from the
ensemble"*. The catalogue, and every other node's copy, are untouched;
ensemble"*. The catalog, and every other node's copy, are untouched;
re-fetching restores it.
**The release log is never cleaned.** Re-acquiring a blob is not routed
@@ -95,7 +95,7 @@ never-fetched reads `"Ref"` — a distinction the doc-level
per artifact and overwritten by any later sync of that document. For a remote
node, `Absent` evidence reads `"Absent"`, and `"Ref"` narrows to its true
meaning: this node references the document and has never said either way.
Both frontends put `Removed`/`Absent` in the error colour.
Both frontends put `Removed`/`Absent` in the error color.
## Consequences
@@ -103,7 +103,7 @@ Both frontends put `Removed`/`Absent` in the error colour.
`nodes_holding`, which returns only `Present` rows, so a peer known to have
evicted stops counting toward "some other node still has this."
- Artifact-sync can rank peers the way blob-sync always could.
`peer_order_by_presence` takes `ContentKey`s, so the catalogue-fetch pass
`peer_order_by_presence` takes `ContentKey`s, so the catalog-fetch pass
ranks once over everything it wants — the thumbnails' bytes and the stubs'
values together.
- `DocumentAvailability` judges an unfulfilled inline stub exactly as it
+41 -41
View File
@@ -10,7 +10,7 @@ not had this pass yet.
The GUI's palette had never been chosen, only accumulated. `theme.rs` was a
sound role-based structure — `bg_nav_sidebar_selected`, `text_primary`,
`color_warning` — filled with Material 3's out-of-the-box seed purple
(`#6750a4`) and a set of neighbours picked one call site at a time. The result
(`#6750a4`) and a set of neighbors picked one call site at a time. The result
was the "good stack, default settings" look shared by a large number of
Tauri/Electron tools and by Obsidian's default theme: defensible everywhere,
identifiable nowhere.
@@ -30,14 +30,14 @@ outcome and the values.
Five facts from that process constrain everything below.
### Colour has to mean something, because there is too much of it to decorate with
### Color has to mean something, because there is too much of it to decorate with
The first specimen gave each document type its own hue. It failed on contact
with the real list: there are far more document types than there are hues that
can be told apart, so the colour was decorative from the first screen. The same
can be told apart, so the color was decorative from the first screen. The same
argument then applied one level down — filling the *present* cell of a presence
indicator spends the whole indicator's colour budget on the state 90% of rows
are in. Colour is affordable only where it marks an exception.
indicator spends the whole indicator's color budget on the state 90% of rows
are in. Color is affordable only where it marks an exception.
### Saturation is wanted, and the ground is what makes it affordable
@@ -79,7 +79,7 @@ later as presets.
Charcoal ground with a trace of the accent in it, saturated cobalt for state,
hairline separation rather than tinted panels, flat throughout — no shadows, no
bevels, no blurred glow. The glow quality wanted from Bitwig comes from
saturated colour on a dark ground, not from a blur.
saturated color on a dark ground, not from a blur.
The rejected siblings and why are in "Rejected alternatives" below.
@@ -89,7 +89,7 @@ Both are kept — seeing the light ground on a real document list reversed an
earlier inclination to ship dark-only. In the **UI** they are named *Charcoal*
and *Slate*, not Light and Dark, because neither is white and the names should
say what the user sees. Internal identifiers (`ThemeMode::Light` / `Dark` /
`System`, `Theme::light()` / `dark()`) do **not** change; this is a labelling
`System`, `Theme::light()` / `dark()`) do **not** change; this is a labeling
decision, not a refactor.
`ThemeMode`'s default moves from `System` to `Dark`.
@@ -110,7 +110,7 @@ not estimated.
| raised / groove | `#313d4c` | meter troughs, inert cells |
| `border_default` | `#2a3441` | hairline |
| `border_card` | `#3e4c5e` | the stronger rule under a table header |
| `text_primary` | `#dbe3ee` | 13.10:1 — cool light grey, never white |
| `text_primary` | `#dbe3ee` | 13.10:1 — cool light gray, never white |
| `text_secondary` | `#8794a6` | 5.50:1 |
| `text_tertiary` *(new)* | `#748193` | 4.28:1 — see §8 |
| `accent` | `#4585ff` | 4.88:1 — strokes, dots, underlines, focus rings; see Amendments |
@@ -130,7 +130,7 @@ the art or the page is what supplies the light there; a light chrome around a
light page loses the edge of it. This is the desk metaphor applied one level
down, and it is why the token is not simply "the darkest background". On
Charcoal it *is* the darkest background, sharing the rail's value: the floor
has room for one colour, and two roles landing on it is better than two values
has room for one color, and two roles landing on it is better than two values
1.01:1 apart. The reading surface's dark page (`#0f1217`, §5) sits just above
that floor for the same reason.
@@ -157,7 +157,7 @@ that floor for the same reason.
`selection` stays `accent` at ~25% alpha; `modal_overlay` stays a neutral black
at ~65%.
### 4. What each colour is allowed to mean
### 4. What each color is allowed to mean
- **Cobalt is energised** — selected, focused, in flight, playing, connected.
When nothing is happening there is no cobalt on screen. This is why *present*
@@ -167,7 +167,7 @@ at ~65%.
- **Absence is none of these.** A missing copy is an empty outlined cell and
the word "Not here", never red. This retires the shipped app's red "Absent"
and settles `CRITIQUE.md` finding 4's orange-vs-mustard drift in passing.
- **No colour ever encodes a category** — not document type, not agent, not
- **No color ever encodes a category** — not document type, not agent, not
collection. Categories get words and columns.
- **Amber is a fill, not an outline.** An amber hairline around an empty box
reads as damage rather than as attention.
@@ -182,7 +182,7 @@ at ~65%.
- **Selection is a saturated full-bleed block** with a brighter left edge and
`text_on_accent` on it — never a tint of the accent, and never an inset
rounded pill, which is the tell that reads as a generic web sidebar. One
selection colour everywhere, which settles `CRITIQUE.md` finding 1's two
selection color everywhere, which settles `CRITIQUE.md` finding 1's two
competing hues.
- **One section-heading grammar**, used in the content area *and* in the
navigation rail — the rail does not get a heading style of its own.
@@ -240,7 +240,7 @@ now changes one view and one column rather than the app's primary list.
uses `text_secondary`; anything interactive uses `text_primary` or the
accent, which is `CRITIQUE.md` finding 3 generalised into a rule.
- **`accent_fill` is added** for accent surfaces that carry text, per the
Context above. `accent` remains the stroke/mark colour.
Context above. `accent` remains the stroke/mark color.
- **`sizing::shape` is added**, as `CRITIQUE.md` finding 2 proposed: `CONTROL`,
`CARD`, `DIALOG`, `PILL`, each naming which `rounded_*()` it maps to, with
the existing call sites audited against it. Radius becomes a role, not a
@@ -254,8 +254,8 @@ A preset may change every value in §3. It may not change §5 — icons on or of
in the rail, block versus pill selection, or the section-heading grammar are
structural, and making them themeable means a second token layer above
`theme.rs` describing shape and component style. That layer is not being built
now. The practical consequence: Charcoal, Slate, and any further recolourings
are cheap and can ship with the `TODO.md` theming item, while the LCARS-flavoured
now. The practical consequence: Charcoal, Slate, and any further recolorings
are cheap and can ship with the `TODO.md` theming item, while the LCARS-flavored
"Signal" direction — whose whole argument is structural — is a larger piece of
work and is deferred, not rejected.
@@ -265,26 +265,26 @@ Android takes the same palette, the same meanings, and the same structure. The
decisions below are the ones the phone forces that the desktop never had to
answer.
**Dynamic colour is dropped.** The app shipped with `dynamicColor = true`, so on
Android 12+ every colour above was bypassed for a scheme derived from the user's
**Dynamic color is dropped.** The app shipped with `dynamicColor = true`, so on
Android 12+ every color above was bypassed for a scheme derived from the user's
wallpaper. That is not a compliance question — Material 3 treats a custom scheme
as equally first-class, `lightColorScheme`/`darkColorScheme` exist for exactly
this, and Spotify, Signal, Telegram and WhatsApp all take the same branch. It is
a question of what colour is *for*. Dynamic colour suits an app whose colour is
decoration; §4 makes colour carry meaning by hue, and a wallpaper-derived
a question of what color is *for*. Dynamic color suits an app whose color is
decoration; §4 makes color carry meaning by hue, and a wallpaper-derived
`primary` reassigns the hue without reassigning the meaning. A green wallpaper
makes "selected" and "present" the same colour; a red one collides "selected"
makes "selected" and "present" the same color; a red one collides "selected"
with "fault".
The app had already noticed this once: `LocalSuccessColor` existed with a comment
saying it was there to survive "the dynamic-color path (where the wallpaper-derived
scheme has no green)". Doing this pass on top of dynamic colour would have needed
scheme has no green)". Doing this pass on top of dynamic color would have needed
four more escape hatches — warning, `accent_fill`, `text_tertiary`, the media
canvas — at which point the dynamic scheme supplies backgrounds and text tiers
only, every semantic colour is overridden, and the result is neither coherent with
only, every semantic color is overridden, and the result is neither coherent with
the desktop nor honestly Material You.
The one thing genuinely given up is dynamic colour's guarantee that
The one thing genuinely given up is dynamic color's guarantee that
`primary`/`onPrimary` pairs are accessible whatever the wallpaper is. §3 measured
every pair, so that cost is already paid. A wallpaper-derived scheme remains
available later as one *preset* under §9, which is where a personalisation feature
@@ -294,11 +294,11 @@ belongs.
mappings are deliberate:
- *The tonal families collapse.* Material offers primary, secondary and tertiary
as three distinguishable accents. §4 says no colour ever encodes a category, so
as three distinguishable accents. §4 says no color ever encodes a category, so
all three resolve to the one accent — a screen reaching for `tertiary` to look
different gets the accent, which is the intended answer.
- *Containers are not tinted.* `errorContainer` is a plain card with
error-coloured text on it rather than a red-washed panel, per §5's hairlines and
error-colored text on it rather than a red-washed panel, per §5's hairlines and
gutters. `primaryContainer` is the single exception and is `accent_fill`,
because that is §5's selection block and is meant to be saturated.
- *`surfaceTint` is transparent*, so Material's elevation tinting cannot
@@ -345,11 +345,11 @@ headings and their order staying Rust-side so the two cannot drift. `AgentGroup`
## Rejected alternatives
- **Instrument** — the same bones with colour used only sparingly and no rail
- **Instrument** — the same bones with color used only sparingly and no rail
icons. Rejected narrowly and for taste: by the end of the specimen it
differed from Console mainly in saturation, and the more saturated reading
plus the rail icons won. Most of Instrument is in the decision above.
- **Signal** — colour as structure: filled round-ended section bars, pill
- **Signal** — color as structure: filled round-ended section bars, pill
controls against hard-rectangle containers, wide gutters. On a charcoal
document list this crossed into costume ("star trek fanart"); on slate it
read considerably straighter. Deferred as a candidate optional theme rather
@@ -357,9 +357,9 @@ headings and their order staying Rust-side so the two cannot drift. `AgentGroup`
- **Ship one dark-leaning theme and drop the light/dark split.** Attractive
until the slate ground was rendered on a real list, at which point it was
clearly worth keeping.
- **Per-type colour**, **a coloured edge per row**, and **a saturated fill on
- **Per-type color**, **a colored edge per row**, and **a saturated fill on
the "present" state** — all three are the same mistake at different scales:
colour spent on a category or on the common case. See §4.
color spent on a category or on the common case. See §4.
- **The Ensemble screen as a node-editor graph.** Drawn, and wrong: a node
editor asserts a topology the user authors, links with direction, and
per-link asymmetry, none of which is true of a set of co-equal peers. It is a
@@ -386,7 +386,7 @@ layer` onward. What is done, in the order the sections above appear:
`text_secondary`/`accent`.
- **§2, the default.** `ThemeMode::default` is `Dark`; the Settings control
reads Charcoal / Slate / Follow system.
- **§4, what colour means.** Presence is `PresenceLook` in
- **§4, what color means.** Presence is `PresenceLook` in
`agents/documents/mod.rs`: green here, amber not-yet-told, cobalt arriving,
and an empty outlined dot with the words "Not here" for a missing copy. Red
is gone from presence entirely and now marks faults and destructive actions
@@ -404,7 +404,7 @@ layer` onward. What is done, in the order the sections above appear:
- **§8, tokens.** Done, including `sizing::shape` and an audit of every
`rounded_*()` call site onto `CARD` / `CONTROL` / `DIALOG` / `PILL`.
- **The reading surface** is `ReadingSurface` in settings plus `theme::Page`,
with `Theme::on_page` recolouring a palette for a document rather than
with `Theme::on_page` recoloring a palette for a document rather than
threading a second value through the prose renderer. Its *control* is not on
the Settings screen: it is a dropdown in the Notes toolbar beside the
view-mode switcher, shown only while a preview is on screen. A setting whose
@@ -472,9 +472,9 @@ remote, and an active `TransportAddr` is `Ip` (direct) or `Relay` (relayed).
Building a gossip payload to obtain it would be reimplementing what the
transport already knows.
The other (N-1)² cells fail on their own terms too. What a gossiped neighbour
The other (N-1)² cells fail on their own terms too. What a gossiped neighbor
set describes is iroh-gossip's **bounded partial mesh** — the swarm keeps a
small neighbour set by design and drops edges it does not need — so a missing
small neighbor set by design and drops edges it does not need — so a missing
edge between two peers is ordinary swarm maintenance, and the matrix would
draw it as a fault. The cells would also be stale by construction, each as
old as its node's last announcement, which means the symmetry the matrix
@@ -482,7 +482,7 @@ exists to demonstrate is not guaranteed: one pair, two cells, two moments,
two possible answers. A picture whose argument its own data can contradict is
not making the argument.
Underneath all of that is a product judgement: what one *other* node can see
Underneath all of that is a product judgment: what one *other* node can see
of a second other node is not something this app currently acts on. If that
changes — a sync that fails for a reason only a third node's view explains —
the trigger is that failure, and the shape to reach for is a diagnostic
@@ -490,9 +490,9 @@ aimed at the pair in question, not a permanent grid on a screen the user
opens for other reasons.
The same defect ran at 1×N in the roster's own `Connected`/`Degraded` badge,
which read the gossip neighbour set for each peer and so called a node "degraded"
which read the gossip neighbor set for each peer and so called a node "degraded"
whenever the swarm had no edge to it. **That badge is removed** — on all four
modalities, along with `MemberConnectionState` and the per-peer neighbour-id
modalities, along with `MemberConnectionState` and the per-peer neighbor-id
accessors behind it — rather than repointed at `remote_info`. A row on this
screen should claim something the user can act on, and "this peer is currently
dialable" is not that: the screen's actions already probe on demand (Ping
@@ -501,7 +501,7 @@ answer than a dot that is stale the moment it is drawn.
The aggregate count survives, worded honestly. `copy::ensemble_roster` now owns
both lines — the roster's "N total nodes · M other nodes", and a gossip
diagnostic that says "N gossip neighbour(s)" rather than the old "N/M other
diagnostic that says "N gossip neighbor(s)" rather than the old "N/M other
nodes reachable now". It reports whether the mesh has formed at all, which is a
real thing to know while bringing up a node, and claims nothing about which
nodes are reachable.
@@ -626,7 +626,7 @@ another hue, which is §3's point and the one thing neither correction has
touched.
Two roles now share the floor. `bg_nav_sidebar` and `bg_media_canvas` are the
same near-black, because a floor this close to black has room for one colour
same near-black, because a floor this close to black has room for one color
and 1.01:1 apart is not a distinction, it is a failure to be the same. They are
never adjacent: the rail, the in-agent sidebar and the canvas alternate
floor / ground / floor across a window, and the ground between them is what
@@ -647,7 +647,7 @@ The re-measured set is in §3's table; the ones that mattered:
- `accent` 5.41 → 4.88:1 against the ground, 4.94 → 4.34:1 against `bg_card`.
Both well above the 3:1 a non-text UI component needs, so the brightening in
the amendment above is not undone by this one.
- `color_error` 5.21 → 4.70:1, the tightest of the status colours, still AA.
- `color_error` 5.21 → 4.70:1, the tightest of the status colors, still AA.
Android's `textOnError` is `bg_window` by construction and moves with it:
5.15 → 4.70:1 on `#e0596e`, still better than white's 3.63:1.
- The selection block, `accent_fill` `#2563eb`, is 3.28:1 against the ground —
@@ -662,12 +662,12 @@ The re-measured set is in §3's table; the ones that mattered:
- `text_on_accent` inverts on Charcoal. It is currently near-black, because the
dark theme's accent was a *light* lilac; with a saturated cobalt fill it
becomes white. Any call site that assumed dark-on-accent needs checking.
- The Settings theme control is relabelled Charcoal / Slate / Follow system,
- The Settings theme control is relabeled Charcoal / Slate / Follow system,
and its default changes to Charcoal.
- A new setting is needed for the reading surface (paper / white / dark),
independent of the theme.
- Android's Compose theme (`ui/theme/`) takes the same pass, per §10: the theme
files become `SynPalette`/`SynShapes`/`Theme.kt`, and the dynamic-colour path
files become `SynPalette`/`SynShapes`/`Theme.kt`, and the dynamic-color path
is removed rather than kept alongside.
- The prose typeface question is untouched here and remains ADR 0024's, to be
settled in `design/typeface-specimen/` rather than by eye.
+4 -4
View File
@@ -72,7 +72,7 @@ would be noise on every row of a fully-synced library.
The rule reads while scanning a grid at arm's length; the word reads once a card
has caught the eye. Because both are present on a card, neither is load-bearing
alone and the pair never rests on colour.
alone and the pair never rests on color.
### Solid rule for `Remote`, dashed for `Unavailable`
@@ -93,7 +93,7 @@ served it during blob-sync, or answered a presence query is indistinguishable
from one that lacks it. The honest claim is that nothing has *claimed* it, not
that the content is gone. Every crossed-out glyph asserts the stronger thing.
### `Remote` spends no colour; `Unavailable` takes warning, never error
### `Remote` spends no color; `Unavailable` takes warning, never error
Remote is the ordinary condition in an ensemble — CLAUDE.md's "data arriving
from another node is the normal case in this system, not an event" — so painting
@@ -153,7 +153,7 @@ picked them rather than a new answer being invented:
When a name can't fit beside its own mark the *name* is elided: a row clipped
to look unmarked would say the opposite of the truth.
- The note **preview pane** is the full pane, with the same sentences the
desktop and Android panes use, centred, under a rule.
desktop and Android panes use, centered, under a rule.
- The **pane header** — a border title already carrying the notebook's name —
gets nothing. That is the desktop's 260px rail argument in a terminal: no
width to spend, and the state is already said in the row.
@@ -211,7 +211,7 @@ and a touch screen has no hover to send anything to. Android's notes tree
therefore takes the terminal's answer instead — the word in a trailing column,
right-aligned against the rail's inner edge, reserving nothing at all in a
notebook that is entirely here, and eliding the *name* rather than the mark when
one of them isn't. The one addition is a colour the other two modalities never
one of them isn't. The one addition is a color the other two modalities never
need: on the selected row the word is written in `textOnAccent` like everything
else on the saturated selection block (ADR 0032 §5), which costs nothing, since
`Remote` and `No source` are told apart by the word.
+2 -2
View File
@@ -52,7 +52,7 @@ The first implementation of this ADR got that backwards and is worth recording
as a trap. Every row had been `text_primary` at `MEDIUM`; the change left
matches exactly there and dropped context to `text_secondary` at `NORMAL`. That
is *withdrawal only* — the baseline never moved up — and it reads acceptably on
the dark ground and too thin on the light one, because mid-greys on a light
the dark ground and too thin on the light one, because mid-grays on a light
ground sit perceptually closer to black than their contrast ratios suggest.
Matches now go to `SEMIBOLD` as well, so the separation is two channels wide
from both directions.
@@ -206,7 +206,7 @@ to work around.
in an actual profile, or if the tree stops being shallow — deep hierarchies are
where adjacency-list traversal degrades and path enumeration earns its keep.
Not before: the filter's unindexed scan (above) dominates anything the tree
work costs, so a materialized path would optimise the cheaper half first.
work costs, so a materialized path would optimize the cheaper half first.
## Alternatives considered
+3 -3
View File
@@ -36,7 +36,7 @@ whole reason clippings hang under one root is so the Documents list shows a
single "Clippings" row instead of five thousand of them.
Marking it derived to get non-purgeability would hide the one row the design
depends on. Adding a second predicate beside `is_derived` gets the behaviour
depends on. Adding a second predicate beside `is_derived` gets the behavior
but leaves two overlapping classifiers to keep consistent — which is how this
rots the second time somebody adds a type and answers only one of them.
@@ -162,7 +162,7 @@ becomes a predicate on one indexed column.
fix it on its own.
- **`SearchParams::include_derived` is now misnamed** and becomes
`include_hidden`, along with the CLI's `--include-derived` and the
Documents screen's "Show derived" toggle. Behaviour is unchanged; the flag
Documents screen's "Show derived" toggle. Behavior is unchanged; the flag
now means what it always did.
- **`CannotPurgeDerived` becomes `CannotPurge { provenance }`** — the error a
user sees when purging an anchor should say "this is the Clippings root",
@@ -180,4 +180,4 @@ becomes a predicate on one indexed column.
what the type was documented to mean before `is_derived` moved in.
- Every existing `is_derived()` caller keeps working through
`document.provenance.hidden()` / `.purgeable()`; there is no third
behaviour to implement, only a better-shaped question.
behavior to implement, only a better-shaped question.
+1 -1
View File
@@ -200,7 +200,7 @@ stops being theoretical — noted in `TODO.md`, not blocking here.
owns a whole pane — monospace, Tab-indent, markdown list continuation,
paragraph jumps — and a clipping is edited in the card it is read in, beside
a composer already using the same box for the same text. There is no
`TextFormat` to honour either: a clipping is whatever was selected on a page.
`TextFormat` to honor either: a clipping is whatever was selected on a page.
A form field was the right component; the prediction was that the *editing*
was the hard part, and the hard part was everything around it.
- **What editing needed was the guard, not the editor**, and building it named
+1 -1
View File
@@ -122,7 +122,7 @@ MessagePack at that size, which is the point this ADR opened with.
- Four response bodies now distinguish "not applicable" from "zero" through an
`Option`, because each changes what a user is told: a single-artifact pull's
absent catalogue counts, a deferred sync's absent totals, a note whose body
absent catalog counts, a deferred sync's absent totals, a note whose body
is on another node, and the indexed document page that never counts past its
limit. A later `#[serde(default)]` on any of them would compile, round-trip,
and quietly swap the message, so a test asserts the absence survives.
@@ -67,7 +67,7 @@ Sync policy is the worked example: it looks like a local preference, so
`SettingsStore` announced nothing for as long as it existed, and all the while
`p2p::node_admin` let a remote administrator set it on this node while the tab
showing it sat there. A store that writes anything a screen shows should have a
`ChangeSignal`, and marking the write is the store's job rather than a judgement
`ChangeSignal`, and marking the write is the store's job rather than a judgment
about which fields are interesting. `SyncStagingStore` is the one store still to
be held to that; the screen it would serve is a sync in progress showing its own
staged count rather than a figure from when it last looked.
@@ -145,7 +145,7 @@ is what keeps a backgrounded app from holding a thread parked indefinitely.
Burst coalescing does not fall out for free, and is arranged the same way it is
on the desktop: a wake lets a 150 ms window pass and reports where the store
ended up, so a catalogue sync costs a handful of reloads a second rather than
ended up, so a catalog sync costs a handful of reloads a second rather than
one per committed transaction. One window rather than a loop waiting for quiet
— a sync running continuously never goes quiet, and a screen that froze until
it did would be worse than one updating a few times a second.
@@ -224,7 +224,7 @@ fails, rather than replacing good rows with an error banner: there is no one
waiting to be told, and the next change tries again. The explicit reloads
behind a Refresh button keep their error paths.
**Not everything a screen shows is a store row.** The gossip neighbour count is
**Not everything a screen shows is a store row.** The gossip neighbor count is
in-memory P2P state; nothing announces it, and the Ensemble screen's `r` remains
the way to ask. A channel for it would have to come from the P2P node, not the
store. (Per-member live reachability was in this sentence too, until the badge
@@ -234,7 +234,7 @@ that showed it was removed — ADR 0032's amendment on the reachability matrix.)
and the polls this ADR set out to remove are gone. What is *deliberately* not
following it, so the next reader doesn't take any of these for an oversight:
- **In-memory P2P state.** The gossip neighbour count is not a store row, so no
- **In-memory P2P state.** The gossip neighbor count is not a store row, so no
store channel can announce it; the Ensemble screens' Refresh is still the way
to ask. A signal for it would have to come from `P2PNode` — see the paragraph
above.
@@ -165,7 +165,7 @@ by causality rather than by timestamp. It also needs per-add identifiers that
accumulate, a causal context to compact them against, and a wire format for
both. The whole rest of this model already resolves concurrency by comparing
timestamps — that is what `LastWriteWins` *is*, and what both other tombstones
do — so an LWW-element-set is consistent with its neighbours, and the failure
do — so an LWW-element-set is consistent with its neighbors, and the failure
mode it retains (two nodes disagreeing within the same wire-clock second) is
one the model accepts everywhere else.
@@ -180,7 +180,7 @@ one the model accepts everywhere else.
- Neither new table has a foreign key to `documents`, matching
`edge_tombstones`/`document_tombstones`: the point of a tombstone is that the
thing it refers to is gone. A purged document's element rows therefore linger,
and that is the correct behaviour — a document that comes back should not
and that is the correct behavior — a document that comes back should not
bring resurrected tags with it.
- Existing databases are seeded once at open (`backfill_set_elements`): every
element of an existing `SetUnion` row is taken as added at the row's own
@@ -161,6 +161,6 @@ join-time snapshot that renames never update was not worth it.
**Drop names from gossip as well, and sync them as documents.** Names would
then ride the admission-gated, vault-encrypted document channel, and nothing
would need sealing. It is a much larger change — a node record document type,
its sync policy, its conflict behaviour — for a field that has to be present
its sync policy, its conflict behavior — for a field that has to be present
before two nodes have synced anything, since the roster is how a user picks a
node to sync *with*.
@@ -32,7 +32,7 @@ browsing a library never opens the audio output device.
the device plays one thing at a time and the ExoPlayer lives in a
`MediaSessionService` so it outlives every screen — but every screen read its
*whole* state. Playing a podcast and then opening Music showed the Music bar
greyed out (Music had no track of its own) yet scrubbed to the podcast's
grayed out (Music had no track of its own) yet scrubbed to the podcast's
position. The rate was shared the same way: ExoPlayer's playback speed is a
property of the player, not of the item, so a podcast's 1.5x would have carried
onto the next song.
@@ -25,7 +25,7 @@ row, not from any one control in it:
a rate pill, Music does not, so the two agents' play buttons sat in
different places — the one control a user reaches for without looking.
- The metadata was left-aligned in a column of its own, so the player had no
centre and read as three unrelated strips rather than as one instrument.
center and read as three unrelated strips rather than as one instrument.
- There was nowhere to put anything else. A visualization, chapter markers, a
queue button: each would have had to displace something already in the row.
@@ -71,15 +71,15 @@ where one is absent.
**The invariants the order exists to protect**, all from the design document:
- Metadata is centred over the *scrubber*, not over the transport cluster.
- Metadata is centered over the *scrubber*, not over the transport cluster.
- The visualization slot is directly above the timeline and nowhere else, and
collapses to nothing rather than to a reserved empty box.
- The primary transport is anchored (left on the desktop, centred on mobile)
- The primary transport is anchored (left on the desktop, centered on mobile)
and the secondary cluster is anchored away from it. The desktop's action row
is `justify_between`, deliberately *not* a centred row: an optional control
is `justify_between`, deliberately *not* a centered row: an optional control
appearing on the right must not move the play button.
- Play/pause is the strongest control. Size alone does not carry that against a
row of same-coloured glyphs, so it is filled — `transport_button_primary` on
row of same-colored glyphs, so it is filled — `transport_button_primary` on
the desktop, `FilledIconButton` on Android — and larger than what flanks it.
**Configuration is capabilities, not modes.** `PlayerCapabilities` has one
@@ -102,7 +102,7 @@ player component contains the word "podcast" or "music".
`Skip` is handled inside the player, because a relative seek is transport and
the owner has no opinion about it. `Adjacent` is not: only the agent knows what
"the next track" is — which list it came out of and how that list was sorted —
so the player emits a request and reports what it was told about neighbours.
so the player emits a request and reports what it was told about neighbors.
**No glyph carries the skip interval.** Both platforms ship icons with the
number drawn in — Material's `Replay10`/`Forward30`, and any equivalent asset
@@ -116,17 +116,17 @@ desktop's tooltip (`step_label`) and Android's content description
(`stepDescription`). 15 seconds each way is the current default and lives with
the agent, not with the player.
**Adjacency is captured at activation, and greys the pair out when absent.**
**Adjacency is captured at activation, and grays the pair out when absent.**
Music records the track list the user activated from (`playback_context` /
`playbackContext`) rather than reading the current library level. Browsing off
to another artist while a song plays must not silently redefine what that
song's Next means. A track with no surrounding list — one song in a folder of
one, a selection the screen has since forgotten — reports both neighbours
one, a selection the screen has since forgotten — reports both neighbors
false, and both buttons render inert rather than doing nothing when pressed.
**Where the two modalities differ, and why it is not drift.** The desktop puts
the times either side of the scrubber and anchors the transport left; mobile
puts the times below a full-width scrubber and centres the transport. A phone's
puts the times below a full-width scrubber and centers the transport. A phone's
~340dp content width minus two time labels leaves the scrubber a stub, and a
left-anchored transport puts the play button under nobody's thumb. This is the
same width-driven split the app already makes between two-column and stacked
@@ -144,7 +144,7 @@ lives in a separate `PlaybackService`. That is its own change.
## Open: where the desktop's primary transport sits
The desktop transport is the bottom-left of the action row, as the design
document draws it. The alternative is a vertically-centred **left flank**
document draws it. The alternative is a vertically-centered **left flank**
beside the artwork, with the secondary cluster mirrored on the right and the
stack dropping to three regions — a pane around 80px instead of 130px, and the
arrangement iTunes and most desktop players settle on.
@@ -159,8 +159,8 @@ worth arguing about against an empty region, so it waits.
The move is cheap when the time comes: the regions are already separate
functions and the clusters already know nothing about each other. What it
trades away is the metadata being dead-centre in the pane — centred over the
scrubber and centred in the pane stop being the same place once the flanks are
trades away is the metadata being dead-center in the pane — centered over the
scrubber and centered in the pane stop being the same place once the flanks are
uneven, and Music's secondary cluster is empty.
## Consequences
+1 -1
View File
@@ -202,7 +202,7 @@ for it for this job, but that is tidying, not part of this decision.
`Comic` a row of its own — but that is a change to policy resolution and to
`has_blob_content`, and it interacts with ADR 0048's sharing rule. **Not
decided here.**
- **Podcast episodes stay visible**, which is the behaviour the rejected
- **Podcast episodes stay visible**, which is the behavior the rejected
structural rule would have broken, and is worth a test that names it.
## See also
+3 -3
View File
@@ -161,11 +161,11 @@ keeping an attachment as a document of its own.
### Purge makes bytes unreferenced; reclaiming them is a separate act
Purge continues not to delete blobs, which is now a position rather than an
omission: purge is catalogue-plane (a tombstone and a delete intent), and
omission: purge is catalog-plane (a tombstone and a delete intent), and
blob deletion is content-plane and goes through the guard. What changes is
that `references_to_blob` makes the resulting orphans *identifiable* — a hash
with zero live references is unambiguously reclaimable, needs no ensemble
evidence round (nothing local wants it and its catalogue entry is gone), and
evidence round (nothing local wants it and its catalog entry is gone), and
is the cheapest possible input to the "Reclaim…" flow. Sweeping them is a
follow-on, not decided here; what this ADR fixes is that they were previously
indistinguishable from bytes something still needed.
@@ -185,7 +185,7 @@ indistinguishable from bytes something still needed.
level and not only at the guard.
- **A promoted document survives its source's purge**, locally and across the
ensemble: separate document rows, separate tombstones, and bytes held by the
reference count. This is the behaviour ADR 0047's promotion story assumes
reference count. This is the behavior ADR 0047's promotion story assumes
and it is worth an explicit test, because nothing about the two documents'
ids relates them.
- **Blob sync is unaffected.** A peer receiving a promoted document sees a
+1 -1
View File
@@ -222,7 +222,7 @@ because this is the first agent that forces the question:
`Attachment` and `Fact`, all of which ADR 0039 or the artifact model already
made generic.
- **Saving the same post twice is idempotent**, and so is saving a post two
nodes both hold. That is new behaviour for this agent — clippings
nodes both hold. That is new behavior for this agent — clippings
deliberately do not work that way — and is worth a test that names it.
- **A saved post and a future feed item are one document.** Which also means
the read/archive state Feeds will add lands on a document the user may
+5 -5
View File
@@ -1,4 +1,4 @@
# 0051: Service credentials are documents on the catalogue plane, sealed with the vault key
# 0051: Service credentials are documents on the catalog plane, sealed with the vault key
**Status**: Accepted — the store shipped 2026-08-30 (`ServiceCredential`
documents, the sealed-field derivation, Podcast Index reading from it, and
@@ -36,7 +36,7 @@ credential reachable only on the machine where it was typed is a credential
that works by luck.
**It has to be encrypted at rest.** The node secret is not
(`TODO.md`, "Encrypt the node secret at rest"), which is defence-in-depth debt
(`TODO.md`, "Encrypt the node secret at rest"), which is defense-in-depth debt
for a value that never leaves the device. A user's third-party API key is meant
to travel to every node, so it is in every backup, every synced folder and every
stolen disk that any node touches. It is the worse thing to leave plaintext, and
@@ -65,7 +65,7 @@ ServiceCredential
would be a cleaner-looking home and would need a wire format, a merge rule, a
tombstone story and a place in the sync protocol — all for one table, and all
duplicating what `p2p::doc_sync` already does. The store layer owning the
schema (ADR 0049's neighbourhood) does not mean every concept deserves a table.
schema (ADR 0049's neighborhood) does not mean every concept deserves a table.
`Provenance::Machinery` is exactly the classifier for it, and ADR 0038 already
argues why: hidden from general listings, from search, and from wikilink
@@ -102,7 +102,7 @@ derived from `(service, field_name)` the way `ContactMethod`'s derives from
`(protocol, value)`. Two nodes given the same key converge on one row;
re-entering a key overwrites rather than accumulating.
### It rides the catalogue plane, and is sealed inline
### It rides the catalog plane, and is sealed inline
```rust
ArtifactValue::SealedSecret { ciphertext: Vec<u8>, plaintext_hash: [u8; 32] }
@@ -114,7 +114,7 @@ content is the *content plane*: policy-governed, fetched on demand, and legitima
absent on a node that has not pulled it (ADR 0009). A credential absent on the
node that happens to be awake is the exact failure this ADR exists to prevent,
and `SyncPolicy::Mirror` would only narrow the window rather than close it. The
catalogue plane syncs eagerly and completely, which is what something small and
catalog plane syncs eagerly and completely, which is what something small and
needed everywhere wants. `has_blob_content` therefore stays false for this type
and it gets no sync-policy row — correctly, since there is no choice to offer.
+3 -3
View File
@@ -38,7 +38,7 @@ transition alone.
| [0006](0006-nativelib-ffi-result-convention.md) | `NativeLib` FFI calls return `Result<T>` | Accepted |
| [0007](0007-android-media-playback-via-mediasessionservice.md) | Android media playback lives in a `MediaSessionService` | Accepted |
| [0008](0008-android-navigation-navhost-viewmodel.md) | Android navigation: `NavHost` + `ViewModel`, type-safe routes | Accepted |
| [0009](0009-two-plane-sync-catalogue-vs-content.md) | Two-plane sync — catalogue always replicates, content is policy-gated | Accepted |
| [0009](0009-two-plane-sync-catalog-vs-content.md) | Two-plane sync — catalog always replicates, content is policy-gated | Accepted |
| [0010](0010-syncpolicy-three-variant-pin-exclude.md) | Three-variant `SyncPolicy`, plus per-document Pin/Exclude overrides | Accepted |
| [0011](0011-jobs-first-class-model.md) | Jobs as a first-class model, deduplicated by (kind, subject) | Accepted |
| [0012](0012-continuous-sync-scheduler.md) | Continuous sync scheduler — three passes, pull-only | Accepted |
@@ -61,7 +61,7 @@ transition alone.
| [0029](0029-content-possession-evidence-and-release-propagation.md) | Per-node content possession evidence for every artifact, with first-hand absence and promulgated eviction releases | Accepted |
| [0030](0030-note-body-crdt-loro.md) | Note bodies merge with Loro — one CRDT document per `NoteBody`, plaintext kept beside it | Accepted |
| [0031](0031-sync-guarded-view-state.md) | `SyncGuarded<T>` — one generic type for "alert, don't apply", not one ad hoc trio per view | Accepted |
| [0032](0032-visual-direction.md) | Visual direction — charcoal ground, cobalt for state, colour never for categories; Charcoal/Slate grounds defaulting to Charcoal | Proposed |
| [0032](0032-visual-direction.md) | Visual direction — charcoal ground, cobalt for state, color never for categories; Charcoal/Slate grounds defaulting to Charcoal | Proposed |
| [0033](0033-availability-marking.md) | Marking availability — a rule, a word, and a tooltip, chosen by the room each surface has; the full enum until the point of use | Accepted |
| [0034](0034-filtering-a-document-tree.md) | Filtering a document tree — matches in place, ancestors as context, emphasis on the match | Accepted |
| [0035](0035-pdf-metadata-in-lib-via-hayro.md) | A PDF's own metadata is read in lib, by `hayro-syntax` | Accepted |
@@ -80,5 +80,5 @@ transition alone.
| [0048](0048-shared-blob-references.md) | Content shared by several documents — liveness by reference count, and sharing disclosed wherever it changes a number | Proposed |
| [0049](0049-parentage-stays-a-field.md) | Parentage stays a field on the document; edges are for facts | Accepted |
| [0050](0050-captured-posts.md) | Captured posts — a `Post` document beside `Snippet`, under one capture agent renamed Clippings | Proposed |
| [0051](0051-service-credentials.md) | Service credentials are documents on the catalogue plane, sealed with the vault key | Accepted |
| [0051](0051-service-credentials.md) | Service credentials are documents on the catalog plane, sealed with the vault key | Accepted |
| [0052](0052-two-local-listeners-and-what-each-trusts.md) | Two local listeners, and what each one is allowed to trust | Accepted |
+1 -1
View File
@@ -634,7 +634,7 @@ Each step is independently shippable and independently revertible.
The palette always searches globally — every source stays reachable at all
times — but the **active agent's results are surfaced first**.
Implement this as **grouping, not score bias**: render labelled sections with
Implement this as **grouping, not score bias**: render labeled sections with
the current agent's first, rather than boosting its items inside one flat
ranked list. A score bonus produces the case where a clearly better match from
another agent sits below a mediocre local one with nothing on screen explaining
+8 -8
View File
@@ -3,7 +3,7 @@
A revision of the *user-facing* half of `docs/sync-management.md`. That
document's model — two planes, per-node policy, pull-only reconciliation — is
right and is built. What it did not design is how a person **sees** whether
the model is being honoured, and that omission is why the system reads as
the model is being honored, and that omission is why the system reads as
broken even where it works.
Nothing here changes the wire protocols or the plane split. It changes what
@@ -33,7 +33,7 @@ and the code implements it faithfully. But the UI offers, on node A:
`settings/ensemble/mod.rs:318`) that calls
`scheduler::sync_with_peer` (`lib/src/scheduler/mod.rs:151`).
`sync_with_peer` runs a bidirectional *catalogue* exchange, then runs passes 2
`sync_with_peer` runs a bidirectional *catalog* exchange, then runs passes 2
and 3 **against the local node's own wants**. So setting B's Music policy to
`Mirror` on A and then pressing Sync on A produces exactly what was reported:
B's document hierarchy fills in (doc-sync is bidirectional), and no audio
@@ -132,7 +132,7 @@ to be holding.
And the unit of all three is **content**: blob-backed artifacts
(`PrimaryContent`, `Attachment`), which is what the two-plane split already
says is the only policy-governed thing. Notes, playback positions, tags,
thumbnails ride the catalogue plane unconditionally and must never appear in
thumbnails ride the catalog plane unconditionally and must never appear in
a policy UI — showing them there is what makes people ask whether policy
"applies to documents or artifacts."
@@ -183,7 +183,7 @@ pub struct ContentLedger {
}
pub struct TypeLedger {
/// Everything of this type the catalogue knows about. The denominator.
/// Everything of this type the catalog knows about. The denominator.
pub files_total: u32, pub bytes_total: u64,
/// On disk right now, whatever policy says.
pub files_here: u32, pub bytes_here: u64,
@@ -240,7 +240,7 @@ instructing a peer is a first-class act instead of an implementation detail
behind a picker.
- Ensemble → per-member **"Sync"** becomes **"Sync both ways"**, and does both
halves: the catalogue exchange and this node's own content pull, *plus* a
halves: the catalog exchange and this node's own content pull, *plus* a
best-effort `node_admin::trigger_sync(peer)` so the peer runs its own three
passes. Sent *before* the local content pass, not after — a peer made to
wait out a multi-minute download here would look, from its own screen,
@@ -308,7 +308,7 @@ Each step is independently useful, and the first two are most of the value.
tallies, which is why a type's "bytes stored" figure will read lower than
before — the two numbers on that row are now commensurable.
2. **Sync-both-ways — done.** `scheduler::sync_with_peer` sends
`node_admin::trigger_sync` after the catalogue exchange and before its own
`node_admin::trigger_sync` after the catalog exchange and before its own
content pass, and reports the outcome as `RemoteReconcile` on the summary.
One shared formatter (`copy::PeerSyncCopy`) replaced four hand-rolled status
lines across the GUI, TUI, CLI, and Android, all of them leading with a
@@ -399,7 +399,7 @@ Each step is independently useful, and the first two are most of the value.
that break here. See §2.
- ~~**Should Ensemble → Reconcile fire `TriggerSync` on the peer?**~~
Settled: yes, and the pull-only rule is demoted to a transport invariant
(§3.2). The button is labelled "Sync both ways" so it is not commanding
(§3.2). The button is labeled "Sync both ways" so it is not commanding
another node from an ambiguous label.
- ~~**Is `Stream` still the right default for every type?**~~ Settled:
**`KeepOnOpen` on the desktop, `Stream` on mobile.** A desktop that holds
@@ -420,7 +420,7 @@ Each step is independently useful, and the first two are most of the value.
press the button is the common case, not the exotic one.
**Queue the directive** as the near-term answer, so pressing the button is
durable rather than best-effort. What the *right* behaviour is against a
durable rather than best-effort. What the *right* behavior is against a
real sleeping phone — how long a queued directive stays valid, whether it
survives the target's policy changing underneath it, whether the user should
see a pending queue at all — is a question for experiment rather than
+4 -4
View File
@@ -13,7 +13,7 @@
| **Edge** | A typed, directed relationship between two Documents, orthogonal to tree hierarchy |
The distinction between Property and Artifact is conceptual, not about size. A Property is
data the system needs to display and organise documents at all times — titles, tags,
data the system needs to display and organize documents at all times — titles, tags,
immutable facts about origin. An Artifact is data representing the user's engagement with
or the actual content of the document — note body, reading position, the PDF bytes
themselves. Some Artifact kinds are *blob-backed*: the artifact row keeps its UUID identity
@@ -118,7 +118,7 @@ an empty list, and no `PropertyKind` grows a sentinel value.
#### Property vs. Artifact: what actually decides which one
The Terminology table's distinction — "needed to display/organise a Document at all times"
The Terminology table's distinction — "needed to display/organize a Document at all times"
vs. "user engagement or content, may be absent" — is about what a field is *for*, not how
narrowly it's named. A field scoped to one `DocumentType` (`PodcastEpisodeApiId`) is not
automatically Artifact material just because no other type shares it, and a broadly-useful
@@ -355,7 +355,7 @@ MergeStrategy =
```
`Crdt` is a bare variant, not `Crdt(CrdtKind)`: one library and one shape (text) are in
play, and enumerating hypothetical CRDT flavours would be generality with no second case to
play, and enumerating hypothetical CRDT flavors would be generality with no second case to
justify it.
Each `ArtifactKind` also has an `Arity`:
@@ -905,7 +905,7 @@ a Node holds a given blob's bytes is answered by querying the blob store directl
### Local Document States
Because artifact values and blob bytes can lag behind the catalogue, a document can exist
Because artifact values and blob bytes can lag behind the catalog, a document can exist
on a node across a spectrum of completeness. The axes are independent — a node can have a
note body value without having the PDF bytes, etc. — but the common progression is:
+1 -1
View File
@@ -48,7 +48,7 @@ Gaps that both reference systems treat as core:
## 2. Monica
Monica exists in two generations with materially different data models. Both are
worth mining: v4 for its mature feature set, v5 for the modelling lessons the
worth mining: v4 for its mature feature set, v5 for the modeling lessons the
rewrite encodes.
### 2.1 Monica v4 (the `4.x` branch — the mature one)
+2 -2
View File
@@ -113,7 +113,7 @@ unwrapped key to run at all. ADR 0052 has that argument in full.
total transfer timeout, so one dead peer can't block a healthy one — but the worst case
still pays each timeout in series, and multiple healthy peers go unused.
- **Details**: Trade-offs to weigh —
- Racing the *same* large blob across peers wastes bandwidth; cancelling in-flight
- Racing the *same* large blob across peers wastes bandwidth; canceling in-flight
transfers once one wins adds complexity.
- Better middle ground: shard *distinct* missing items across peers, and/or race only the
connect/handshake phase and commit the transfer to the first responder.
@@ -148,7 +148,7 @@ is itself the disruption.
Note this is what the Ensemble screen's per-peer `Connected`/`Degraded` badge
was reaching for and getting wrong. That badge answered "is this peer a gossip
neighbour", which is a fact about iroh-gossip's bounded partial mesh and not
neighbor", which is a fact about iroh-gossip's bounded partial mesh and not
about anything the user was doing; it was removed rather than corrected (ADR
0032's amendment on the reachability matrix). The question worth answering was
never "which nodes can I see" in the abstract — it is "is the thing I am doing
+9 -9
View File
@@ -88,7 +88,7 @@ Three separable layers, on top of one prerequisite.
category of document generated by the agent rather than imported, linked by `Edge`s to
each other and to tracks. Additive: it never touches a track's `parent_id`, so the user's
folder tree survives untouched and the library can be discarded and rebuilt at will.
3. **The build job** — a user-triggered, cancellable, progress-reporting long-running job
3. **The build job** — a user-triggered, cancelable, progress-reporting long-running job
that runs layers 1 and 2 over every `MusicTrack`, plus an incremental single-track path so
newly imported tracks join an existing library without a full rebuild.
@@ -167,7 +167,7 @@ New unconditional dependency in `rust/lib/Cargo.toml` (workspace-pinned like the
Tags are **Properties**, not Artifacts, by `docs/data-model.md`'s stated test: a node missing
a track's artist is in an error state that should be re-requested on next sync, not a normal
"user hasn't engaged yet" state. They are needed to *organise* the document — the library
"user hasn't engaged yet" state. They are needed to *organize* the document — the library
graph is literally computed from them.
| Kind | Value | Merge | Notes |
@@ -265,13 +265,13 @@ impl DocumentType {
}
```
Hand-declared, like the neighbouring `has_blob_content()`, and with the same instruction to
Hand-declared, like the neighboring `has_blob_content()`, and with the same instruction to
keep it in step with `docs/data-model.md`.
Consequences of being derived, all of which are the reason to name the category at all:
- **Hidden from general document lists by default.** `SearchParams` gains
`include_derived: bool` (default `false`), honoured by `search_documents` and
`include_derived: bool` (default `false`), honored by `search_documents` and
`search_document_items`, with the rule that an explicit `document_types` filter naming a
derived type overrides it — so "show me every album" is one call and the Documents agent's
browse view doesn't fill up with four hundred albums. The Documents agent gets a "show
@@ -480,8 +480,8 @@ pub fn build_library(
`build_library` takes `Option<&JobHandle>` directly rather than inventing a callback
abstraction — `jobs` is the same crate, `JobHandle` already exposes exactly
`report_progress` / `is_cancelled`, and tests pass `None`. It reports progress every N tracks
(not every track — each report is a SQLite write plus a broadcast) and checks `is_cancelled`
`report_progress` / `is_canceled`, and tests pass `None`. It reports progress every N tracks
(not every track — each report is a SQLite write plus a broadcast) and checks `is_canceled`
on the same cadence; cancellation leaves a partially built graph, which is valid and
converges on the next run precisely because every id is deterministic.
@@ -524,7 +524,7 @@ different kind.
rebuilt yet. It is harmless — with no edges it holds no tracks and shows up in no library
view — and the next build removes it again, converging once every node has run one.
**A cancelled run must not sweep.** Half the tracks having been visited makes every
**A canceled run must not sweep.** Half the tracks having been visited makes every
unvisited album look empty, so sweeping a partial run would delete the rest of the
library. There is a test for exactly this.
5. Return `LibraryBuildSummary { tracks_scanned, tracks_reused, tracks_skipped_not_local,
@@ -565,7 +565,7 @@ per track out of about eight.
The remaining per-track cost is the two `add_edge` calls and the stale-album check, all of
which open their own connection. Batching those would need an edge-level equivalent of the
property batch and is not obviously worth it — this is an explicit, cancellable job with a
property batch and is not obviously worth it — this is an explicit, cancelable job with a
progress bar, not an interactive path. The `list_blob_refs_for_documents` addition and the
per-chunk transaction sketched earlier were **not** implemented for the same reason; revisit
if a real library makes the wait annoying.
@@ -612,7 +612,7 @@ dance.
and port `notebook.rs`'s `create_notebook`/`list_notebooks`/`require_collection_parent` to
it; add `DocumentType::is_container()` and use it for the `matches!(.., Collection)`
ancestor-walk checks in `pictures.rs`, `music.rs`, and `comics.rs`.
- `SearchParams::include_derived` (default `false`), honoured by `search_documents` and
- `SearchParams::include_derived` (default `false`), honored by `search_documents` and
`search_document_items`, overridden by an explicit derived type filter.
- Arms in `title_property_kind`, `display_name`, and `has_blob_content` (both `false`).
- Materialization: **not** `Directory`, as an earlier draft of this said. A derived
+1 -1
View File
@@ -68,7 +68,7 @@
- hence the name
#framed[
"Ra is a single entity distributed across the whole solar system. No part of Ra is slaved to any other part of it. No shell-Earth's core node has a noticeably differing personality from the others, or from the megastructure inside the Sun. Opinions and behaviour and available information are continuously synchronising. Eventually, there is only one Ra."
"Ra is a single entity distributed across the whole solar system. No part of Ra is slaved to any other part of it. No shell-Earth's core node has a noticeably differing personality from the others, or from the megastructure inside the Sun. Opinions and behavior and available information are continuously synchronising. Eventually, there is only one Ra."
- #link("https://qntm.org/ra")[Ra], by qntm
]
]
+5 -5
View File
@@ -159,7 +159,7 @@ scheme breaks. It is also why `blake3` (already a dependency) is used rather
than a hash of the whole entry — the entry's field order and whitespace are not
stable across feed-rs versions, but title and date are.
### 2.4 Fetch behaviour
### 2.4 Fetch behavior
`fetch.rs` wraps `crate::http::build_async_client` with:
@@ -320,7 +320,7 @@ a tombstoned id forever. If a feed's id were `uuidv5(feed_url)`, then
*unsubscribe → resubscribe to the same URL* would derive an id the ensemble has
already permanently killed, and the feed could never be added again. Subscribing
is a rare, explicit, user-initiated act, so the concurrent-creation window is
small; a v4 id makes unsubscribe-as-tombstone correct, which is the behaviour
small; a v4 id makes unsubscribe-as-tombstone correct, which is the behavior
that actually matters (unsubscribing on one node must not be undone by a peer
that still has the feed).
@@ -562,7 +562,7 @@ override (`FeedRefreshIntervalMins`, `U32`, LWW) *is* user-scoped and would be a
Add one `JobKind` variant, display name `"Feed refresh"`. Subject:
`JobSubject::Document(feed_id)` for one feed, `JobSubject::None` for a
refresh-all pass. This buys, for free, the exact behaviour `JobKind::BackgroundSync`
refresh-all pass. This buys, for free, the exact behavior `JobKind::BackgroundSync`
documents: a user hammering the "Refresh" button dedups against the in-flight
scheduled pass rather than stacking N concurrent fetches, and the whole thing
shows up in the Jobs agent with progress like every other long-running
@@ -574,7 +574,7 @@ a 304 short-circuiting to `RefreshOutcome::default()`. These are synced
properties even though they describe *this node's* last fetch, which looks
wrong for about a second and then isn't: a node that receives a peer's validator
and gets a 304 has skipped a fetch whose items are already reaching it through
catalogue sync, which is eager for properties and inline artifacts. The failure
catalog sync, which is eager for properties and inline artifacts. The failure
mode is "fetched less often", not "missing data".
### 5.3 Retention
@@ -607,7 +607,7 @@ destinations: **Unread** (default) / **All** / **Starred**. The subscription
sidebar sits alongside the list within the screen; selecting a feed scopes the
active tab to it.
Per CLAUDE.md's GUI conventions: named constants for spacing/colour rather than
Per CLAUDE.md's GUI conventions: named constants for spacing/color rather than
in-situ literals, functional subcomponents with meaningful names rather than one
long render, and copyable text elements. The item's `link` should use the
standard truncated-value-with-full-value-available component pattern the project
+26 -26
View File
@@ -12,8 +12,8 @@ the per-phase detail and the ADR for each.
| Piece | Today |
|---|---|
| Catalogue sync (`/syn/doc-sync/…`) | Implemented and automatic — `scheduler::run_catalogue_pass` walks every reachable peer on a timer, and the Ensemble screen's per-peer "Sync both ways" is now one trigger among several |
| Blob / artifact value transfer | Implemented and automatic — `run_catalogue_fetch_pass` takes artifact stubs and thumbnails eagerly, `run_content_pass` takes the policy deficit; the detail screen's buttons stay for on-demand pulls |
| Catalog sync (`/syn/doc-sync/…`) | Implemented and automatic — `scheduler::run_catalog_pass` walks every reachable peer on a timer, and the Ensemble screen's per-peer "Sync both ways" is now one trigger among several |
| Blob / artifact value transfer | Implemented and automatic — `run_catalog_fetch_pass` takes artifact stubs and thumbnails eagerly, `run_content_pass` takes the policy deficit; the detail screen's buttons stay for on-demand pulls |
| `SyncPolicy` (Stream/KeepOnOpen/Mirror per type) | Enforced — `content_ledger` resolves it into a deficit that the scheduler fetches and the eviction flow reads, so "want" and "keep" cannot drift apart |
| `SyncDisposition` (per-peer eager push) | Deleted — every node satisfies its own policy by pulling; see "What happens to `SyncDisposition`" below |
| "Sync continuously" toggle (doc detail, per peer) | Replaced by `DocumentSyncOverride` (Pin/Exclude), which is per document rather than per peer |
@@ -25,7 +25,7 @@ the per-phase detail and the ADR for each.
Everything a node holds is split into two planes with different sync rules:
1. **The catalogue plane** — document rows, properties, artifact *values*
1. **The catalog plane** — document rows, properties, artifact *values*
(including the `BlobRef` pointer values of blob-backed kinds — see the
Artifact/Blob unification in `data-model.md`), tombstones, and presence
records. This plane is **always
@@ -40,7 +40,7 @@ Everything a node holds is split into two planes with different sync rules:
only plane where storage cost is real.
The one deliberate upgrade hiding in this split: **artifact values move from
the content plane to the catalogue plane.** Today artifact values (note
the content plane to the catalog plane.** Today artifact values (note
bodies, `ListenProgress`, `ReadingPosition`, bookmarks) are fetched on demand
alongside blobs. They are exactly the data the user wants everywhere as fast
as possible — small, high-value, cross-device (pause a podcast on the phone,
@@ -54,13 +54,13 @@ holds — eager fetching is a scheduler behavior, not a new invariant.
With the Artifact/Blob unification (phase 0 below), the plane assignment
stops being a special-cased rule and becomes a type-system fact: the
catalogue plane is simply *all artifact values* — for `Storage::BlobStore`
catalog plane is simply *all artifact values* — for `Storage::BlobStore`
kinds the value is the `BlobRef` pointer — and the content plane is the
bytes those pointers address. Nothing large can sneak onto the catalogue
bytes those pointers address. Nothing large can sneak onto the catalog
plane without someone deliberately declaring a new inline kind with a large
payload, which is a code-review-visible act.
`Thumbnail`-kind bytes also ride the catalogue plane: they are small, and
`Thumbnail`-kind bytes also ride the catalog plane: they are small, and
browsing a library without cover art is a degraded experience. A size cap
(e.g. skip thumbnails > 1 MiB) guards against something mislabeled as a
thumbnail.
@@ -174,7 +174,7 @@ Resolution order for "should node N hold content for document D":
**Deleted.** Its job ("eagerly push type T to peer P") is subsumed by a
cleaner rule that matches the decentralized architecture: **every node
satisfies its own policy by pulling.** When the desktop imports an album, the
server learns about it via continuous catalogue sync and pulls the audio
server learns about it via continuous catalog sync and pulls the audio
because *its own* policy says `Mirror(MusicTrack)`. No node needs a table of
opinions about what other nodes should receive; you edit the *server's* policy
(possibly remotely) instead of editing a push-table on every other node. This
@@ -211,12 +211,12 @@ NAT-challenged peer) and as an immediacy affordance. It just isn't a policy.
A background component in `lib` (per node, all modalities) that makes the
two-plane promise true without button-pressing. It runs three passes:
1. **Catalogue pass**`sync_from_peer` (incremental, `since` = last
1. **Catalog pass**`sync_from_peer` (incremental, `since` = last
successful sync per peer) against each reachable ensemble peer.
Triggers: node startup; a peer's gossip announcement (a node that just
came online syncs with it); a periodic timer (default 15 min); after any
local import or policy change; manual "Sync now."
2. **Catalogue-plane fetch pass** — after a catalogue pass: fetch all
2. **Catalog-plane fetch pass** — after a catalog pass: fetch all
pending artifact stubs (existing artifact-sync) and missing thumbnail
bytes (existing blob-sync) from whichever peers hold them.
3. **Content pass** — compute **wants**: blob hashes where the resolution
@@ -241,20 +241,20 @@ charging" constraints. Defaults: on, 15 min, Wi-Fi-only for content pass.
`docs/adr/0012-continuous-sync-scheduler.md` for exactly which triggers
shipped vs. were deliberately deferred, and each modality's driver.
### On-demand catalogue refresh for one document
### On-demand catalog refresh for one document
The scheduler's catalogue pass is the *whole* catalogue on a timer, which
The scheduler's catalog pass is the *whole* catalog on a timer, which
leaves a gap the user feels directly: between two passes, this node's idea of
a document is however stale the last pass left it. "Sync this document to
this node" fetching only what the local catalogue already flags as missing
this node" fetching only what the local catalog already flags as missing
means a document whose value changed on another node reports itself as
already up to date — the local catalogue's opinion, presented as the
already up to date — the local catalog's opinion, presented as the
ensemble's.
So the per-document pull runs both planes, in order
(`asset_sync::sync_document_assets_recursive`):
1. **Catalogue phase** (`asset_sync::refresh_document_catalogue`) — one
1. **Catalog phase** (`asset_sync::refresh_document_catalog`) — one
doc-sync exchange per ensemble peer, scoped to this document's subtree,
with no `since` watermark. Filtering by the peer's watermark here would
skip exactly the case worth checking: a peer-side change older than the
@@ -282,7 +282,7 @@ result string says which of the two it is (`gui-app`'s `detail_view.rs`,
A first-class **Jobs** agent (sidebar entry, `Icon::ArrowsUpDown` or
similar), generalizing the "Document Imports" placeholder. Everything
long-running routes through it: imports, catalogue syncs, content fetches,
long-running routes through it: imports, catalog syncs, content fetches,
pushes, evictions, purge propagation. This is infrastructure the rest of this
design assumes, so it lands first in the phasing.
@@ -291,11 +291,11 @@ design assumes, so it lands first in the phasing.
```rust
Job {
id: Uuid,
kind: JobKind, // Import | CatalogueSync | ContentFetch | Push
kind: JobKind, // Import | CatalogSync | ContentFetch | Push
// | Eviction | Purge
subject: JobSubject, // human label + optional document id / peer pubkey
origin: JobOrigin, // User | Scheduler | RemoteAdmin(NodePubkey)
state: JobState, // Queued | Running | Done | Failed(String) | Cancelled
state: JobState, // Queued | Running | Done | Failed(String) | Canceled
progress: Option<JobProgress>, // units done/total + bytes done/total
created_at / started_at / finished_at: Timestamp,
}
@@ -324,7 +324,7 @@ Job {
│ ───────────────────────────────────────────────────│
│ ⣾ Content fetch — Album "Blackstar" (from helios) │
│ ██████████░░░░░░░░ 312 MiB / 512 MiB Cancel │
│ ⣾ Catalogue sync — helios docs 214/~800 │
│ ⣾ Catalog sync — helios docs 214/~800 │
│ ⏸ Import — ~/comics/Akira/ queued Cancel │
└────────────────────────────────────────────────────┘
```
@@ -417,10 +417,10 @@ running" badge, so a per-widget button would be a second route to something
already one click away, whereas Android has no persistent sidebar and the link
is the only route.
Not yet on either: cancelling from the widget. How promptly a cancel takes
Not yet on either: canceling from the widget. How promptly a cancel takes
effect is still up to the work — `asset_sync`'s functions take no
cancellation signal, so a transfer runs to the end and the row is only
*recorded* as `Cancelled`. The Jobs screen's Cancel already says so, and the
*recorded* as `Canceled`. The Jobs screen's Cancel already says so, and the
widgets simply don't offer it. See TODO.md's "Cancellation is still coarse
for a single blocking P2P call."
@@ -483,7 +483,7 @@ single-subtree Eviction job (same guard, same dialog). The
Eviction also writes each dropped blob to `content_release_log`, which
doc-sync ships to peers as a `ContentReleaseBatch` alongside the document
tombstones. Without it an eviction would be invisible: the catalogue is
tombstones. Without it an eviction would be invisible: the catalog is
untouched by design, so an incremental sync never mentions the document again
and every peer goes on listing this node as a holder. A release says only "I
dropped my copy" — not "this is gone from the ensemble", which is what an
@@ -569,7 +569,7 @@ two sections that answer the two real questions — *what's here?* and
│ Policy for Pdf: Keep on open · Override: [ Follow policy ▾ ]
│ [ Download now ] [ Delete Locally ]
│ Across your ensemble ↻ from catalogue + presence
│ Across your ensemble ↻ from catalog + presence
│ node content last confirmed keep here?
│ helios ✓ all today 14:02 Pin ▾ [Push now]
│ phone ✗ none — Follow ▾ [Push now]
@@ -594,7 +594,7 @@ Android mirrors this structure in its existing detail screen sections.
## What the Ensemble screen keeps
It stays the *connectivity* surface: membership, ping, "Sync now" (submits a
CatalogueSync job), last-synced (now scheduler-fed), and a "Manage sync…" link
CatalogSync job), last-synced (now scheduler-fed), and a "Manage sync…" link
into the policy screen per node. Per-document and per-type controls never live
here. Note it does not claim per-peer reachability: Ping is the probe, and ADR
0032's amendment on the reachability matrix says why there is no passive
@@ -654,9 +654,9 @@ earlier ones. (Per VCS style: many small commits within each.)
**That button was, for a while, the only way to make a remote
`Mirror`-policy node actually fetch** — and it sits behind a node picker
on a settings tab, so in practice nobody found it. The Ensemble screen's
per-member "Sync" pulled catalogue + content *into the local node* only,
per-member "Sync" pulled catalog + content *into the local node* only,
computed against the local node's own policy, leaving the remote node with
metadata (catalogue sync is bidirectional) and no blobs until its next
metadata (catalog sync is bidirectional) and no blobs until its next
periodic tick. `scheduler::sync_with_peer` now sends `TriggerSync` itself
and the button says "Sync both ways"; see the correction under "What
happens to `SyncDisposition`" above and
+1 -1
View File
@@ -399,7 +399,7 @@ lint-rust-nix *args:
# Apply clippy's machine-applicable suggestions across the workspace.
#
# Only rewrites what clippy is confident about (needless borrows, useless
# conversions, collapsible ifs); findings that need a judgement call — how to
# conversions, collapsible ifs); findings that need a judgment call — how to
# factor a complex type, whether an argument list wants a struct — are reported
# but left alone. Review the diff: it is mechanical, not automatically right.
[doc: "Apply clippy's mechanical fixes across the workspace"]
+8 -8
View File
@@ -287,16 +287,16 @@ pub fn setup_document_callbacks(
let paths = dialog.pick_files();
let Some(paths) = paths else {
info!("Document import cancelled by user");
info!("Document import canceled by user");
return;
};
let node = match local_node.get().copied() {
Some(pk) => pk,
None => {
log::error!("Node not initialised — cannot import documents");
log::error!("Node not initialized — cannot import documents");
if let Some(ui) = ui_weak.upgrade() {
ui.set_documents_error("Node not initialised".into());
ui.set_documents_error("Node not initialized".into());
}
return;
}
@@ -363,7 +363,7 @@ pub fn setup_document_callbacks(
match rfd::FileDialog::new().set_title("Import Folder").pick_folder() {
Some(path) => path.to_string_lossy().to_string().into(),
None => {
info!("Directory import cancelled by user");
info!("Directory import canceled by user");
slint::SharedString::new()
}
}
@@ -382,9 +382,9 @@ pub fn setup_document_callbacks(
let node = match local_node.get().copied() {
Some(pk) => pk,
None => {
log::error!("Node not initialised — cannot import directory");
log::error!("Node not initialized — cannot import directory");
if let Some(ui) = ui_weak.upgrade() {
ui.set_documents_error("Node not initialised".into());
ui.set_documents_error("Node not initialized".into());
}
return;
}
@@ -455,9 +455,9 @@ pub fn setup_document_callbacks(
let node = match local_node.get().copied() {
Some(pk) => pk,
None => {
log::error!("Node not initialised — cannot import from URL");
log::error!("Node not initialized — cannot import from URL");
if let Some(ui) = ui_weak.upgrade() {
ui.set_documents_error("Node not initialised".into());
ui.set_documents_error("Node not initialized".into());
}
return;
}
+7 -7
View File
@@ -247,7 +247,7 @@ pub fn setup_humans_callbacks(
return;
}
let Some(node) = write_node() else {
log::error!("Node not initialised — cannot create human");
log::error!("Node not initialized — cannot create human");
return;
};
let name = full_name.to_string();
@@ -316,7 +316,7 @@ pub fn setup_humans_callbacks(
religion,
notes| {
let Some(node) = write_node() else {
log::error!("Node not initialised — cannot save human");
log::error!("Node not initialized — cannot save human");
return;
};
let id_str = id.to_string();
@@ -376,7 +376,7 @@ pub fn setup_humans_callbacks(
}
let Some(id_str) = current_human.lock().unwrap().clone() else { return };
let Some(node) = write_node() else {
log::error!("Node not initialised — cannot add pet");
log::error!("Node not initialized — cannot add pet");
return;
};
let name_str = name.to_string();
@@ -411,7 +411,7 @@ pub fn setup_humans_callbacks(
ui.on_remove_pet(move |artifact_id| {
let Some(id_str) = current_human.lock().unwrap().clone() else { return };
let Some(node) = write_node() else {
log::error!("Node not initialised — cannot remove pet");
log::error!("Node not initialized — cannot remove pet");
return;
};
let artifact_str = artifact_id.to_string();
@@ -450,7 +450,7 @@ pub fn setup_humans_callbacks(
}
let Some(id_str) = current_human.lock().unwrap().clone() else { return };
let Some(node) = write_node() else {
log::error!("Node not initialised — cannot add web presence");
log::error!("Node not initialized — cannot add web presence");
return;
};
let url_str = url.to_string();
@@ -486,7 +486,7 @@ pub fn setup_humans_callbacks(
ui.on_remove_web_presence(move |artifact_id| {
let Some(id_str) = current_human.lock().unwrap().clone() else { return };
let Some(node) = write_node() else {
log::error!("Node not initialised — cannot remove web presence");
log::error!("Node not initialized — cannot remove web presence");
return;
};
let artifact_str = artifact_id.to_string();
@@ -520,7 +520,7 @@ pub fn setup_humans_callbacks(
let write_node = write_node.clone();
ui.on_delete_human(move |id| {
let Some(node) = write_node() else {
log::error!("Node not initialised — cannot delete human");
log::error!("Node not initialized — cannot delete human");
return;
};
let id_str = id.to_string();

Some files were not shown because too many files have changed in this diff Show More