all: trim the history essays out of the longest comments
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 9s
CI & Build / Python tests (push) Successful in 12s
CI & Build / integration (push) Successful in 2m0s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 3m11s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m51s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m39s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 9m4s

From the audit (#5179). Ten comment blocks narrated how the code got here:
milestone numbers, earlier values, the operator's verdict on an old design.
Each now says what the code does and why, and the history stays in git,
Scribe and docs/sync.md. The protocol-version comment in sync.py points at
docs/sync.md's policy section, which already lists every bump.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 20:03:44 -04:00
co-authored by Claude Opus 5.5
parent e75e3d37d0
commit 888c6410f0
7 changed files with 65 additions and 232 deletions
@@ -143,28 +143,18 @@ fun List<EditorBlock>.plusTask(): Pair<List<EditorBlock>, Long> {
} }
/** /**
* Re-read ONE prose block for `- [ ] ` lines somebody typed by hand. * Re-read ONE prose block for `- [ ] ` lines somebody typed by hand, so a marker
* typed in the editor becomes an item without closing and reopening the note.
* *
* [splitBlocks] runs once, when the editor opens. After that the blocks are the state * **On blur, and only the block being left:** re-splitting on a keystroke would move
* and nothing reads the body again — every edit travels the other way, through * the caret, and converting the moment `- [ ]` is complete would catch an item with
* [joinBlocks]. So a marker typed by hand stayed literal text on screen until the note * no text yet.
* was closed and reopened, even though it was already a real item in storage and the
* card was already drawing a checkbox for it. The editor was the only place that
* disagreed.
* *
* **On blur, and only the block being left.** There is no good moment to convert while * Returns THIS LIST when there was nothing to promote; the caller relies on that to
* someone is typing: re-splitting on a keystroke moves the caret out of the word being * leave the state alone, so a blur that changed nothing doesn't re-key every field.
* written, and converting the instant `- [ ]` is complete does it before the item has
* any text. Blur is the one moment the person has demonstrably finished with the block,
* so a re-split costs no caret and cannot catch a half-typed line.
* *
* Returns THIS LIST, not an equal copy, when there was nothing to promote — the caller * Non-canonical markers (`- [X]`, an odd bullet) come back canonical, as they would on
* leans on that to leave the state alone, and a blur that changed nothing must not * reopen.
* re-key every field below it.
*
* Non-canonical markers (`- [X]`, an odd bullet) come back canonical, exactly as they
* would have on reopen. That is the only case where this changes the body rather than
* only the way it is drawn.
*/ */
internal fun List<EditorBlock>.promotingTasks(index: Int): List<EditorBlock> { internal fun List<EditorBlock>.promotingTasks(index: Int): List<EditorBlock> {
val block = getOrNull(index) val block = getOrNull(index)
@@ -101,22 +101,13 @@ fun NoteCard(
.border(1.dp, if (dark) CARD_EDGE_DARK else CARD_EDGE_LIGHT, RoundedCornerShape(CARD_RADIUS)) .border(1.dp, if (dark) CARD_EDGE_DARK else CARD_EDGE_LIGHT, RoundedCornerShape(CARD_RADIUS))
.padding(12.dp), .padding(12.dp),
) { ) {
// TAGS FIRST. They used to sit under everything else, which on a tall note put // TAGS FIRST, on a row of their own above the body: a board is scanned, and
// the one thing that says what a note IS below the fold of a glance. A board is // what a note is about should be the first thing the eye lands on. Not beside
// scanned, not read, and the answer to "which of these is about the thing I am // the body, whose first line is the note's name.
// looking for" should be the first thing the eye lands on rather than the last.
// //
// Above the body rather than beside it, because the body's first line is the // Only labels whose text is not still in the note (`via_tag` is false). A tag
// note's NAME (M13 steps 3 and 4) and a chip floated next to it would compete // left in prose is tinted in place instead (see [tintTags]), so this row holds
// with the thing that identifies the note. A row of its own costs one line and // what the body cannot say: a tag lifted off its own line, or one added by hand.
// only on notes that have tags at all.
//
// ONLY the labels whose text is not still in the note. `via_tag` means exactly
// "backed by body text" since M311, so a chip for one printed the same tag
// twice — once where it was typed, once up here — and the card was carrying
// furniture for information it was already showing. A tag left in prose is
// tinted in place instead; see [tintTags]. What reaches this row is what the
// body cannot say: a tag lifted off its own line, and a label added by hand.
val chips = note.labels.filterNot { it.viaTag } val chips = note.labels.filterNot { it.viaTag }
if (chips.isNotEmpty()) { if (chips.isNotEmpty()) {
LabelChips(labels = chips) LabelChips(labels = chips)
@@ -192,34 +183,16 @@ fun NoteCard(
} }
/** /**
* What you can do to a note without opening it. * What you can do to a note without opening it, on a long press.
* *
* The board used to have none of this, and the operator's read of that was not "the * The same items as the editor's overflow, in the same words from the same string
* actions are in the editor" — it was *"there are no long hold context menus in the * resources, dispatched through [EditorAction] so `BoardViewModel.onEditorAction`
* app I have no way to delete notes."* Trash was three interactions deep (open, ⋮, * stays the one place they are handled.
* Move to trash), and on a phone that is far enough from the gesture people reach
* for that it may as well not exist.
* *
* **The same items as the editor's overflow, in the same words, from the same string * Gated on the NOTE (`note.trashed`), not on the view, because Reminders and search
* resources.** A note has one vocabulary of things that can be done to it, and two * mix piles: a note already in Trash must not offer "Move to trash".
* surfaces that named them differently would be describing two different apps. It
* dispatches [EditorAction] for the same reason — `BoardViewModel.onEditorAction` is
* already the exhaustive dispatcher for every one of them, so the board reuses the
* seam rather than growing a parallel one that could drift.
* *
* **Gated on the NOTE, not on the destination.** `note.trashed` is what the editor * No Labels item: the picker it opens is editor state.
* gates its own read-only mode on, and it is the only reading that survives the views
* that mix piles: Reminders cuts across archived and active alike, and a search hits
* whatever matches. A menu that offered "Move to trash" on a note already in the
* trash would be offering to do something twice.
*
* **Colour is absent**, though #2946 suggested it. It was left out because `note.color`
* was already scheduled for removal; M315 removed it. There is no colour to set on a
* note any more — a card is one neutral surface and the only coloured thing on a board
* is a tag — so the row this menu never grew is a row that could not exist.
*
* Labels are absent too, for a duller reason: the picker they open is editor state,
* and hoisting it to the board is a bigger change than the friction actually reported.
*/ */
@Composable @Composable
private fun NoteMenu( private fun NoteMenu(
@@ -555,31 +528,13 @@ fun noteCardSurface(dark: Boolean): Color = if (dark) CARD_SURFACE_DARK else CAR
private val CARD_SURFACE_LIGHT = Color(0xFFFFFFFF) private val CARD_SURFACE_LIGHT = Color(0xFFFFFFFF)
private val CARD_SURFACE_DARK = Color(0xFF171717) private val CARD_SURFACE_DARK = Color(0xFF171717)
// THE CARD'S EDGE — one grey, every card, both themes. Since M315 it is the only thing // THE CARD'S EDGE — one neutral grey, every card, both themes. The fill barely differs
// that differs from the board by more than a hair, which makes it structure rather than // from the board, so this line is what makes a card a card. Neutral on purpose: a
// decoration: it is what a card IS. // coloured edge would carry information and turn a board into a grid of outlines.
// //
// It was already neutral before the fill was. The version that came from the palette // Opaque rather than a translucent black/white, which would composite differently over
// was a `{hue}-900` border and failed twice over: the line measured 1.56-2.09 against // whatever is under it. NoteCard.vue writes the same two hex values, which is how the
// its own fill while the fill managed only 1.03-1.05 against the board, so it was the // surfaces stay the same card. Contrast: #B8B8B8 on white 1.98, #404040 on #171717 1.73.
// loudest thing on the card — and it carried the same information the fill did, so a
// field of cards read as a grid of outlines however different the colours inside were.
// A neutral line carries no information at all, which is exactly what lets it be
// structure instead of content. The fill is that same argument one size up.
//
// MEASURED AGAINST ONE FILL NOW, and deliberately left where it was. #B8B8B8 on white
// is 1.98 and #404040 on #171717 is 1.73 — both inside the ranges these values already
// shipped at across twenty fills (light 1.57-1.98, dark 1.58-1.73), but at the top of
// them rather than the ~1.6-1.7 the pair was originally matched on. Softening the light
// edge to re-match would weaken the only boundary a white card on a #FAFAFA board has,
// and the complaint that started M315 was about fill, never about edge weight. If an
// operator pass disagrees it is one constant, in two files.
//
// NOT a translucent black/white edge, which is the tidier way to write this and was
// measured and rejected: a border composites over what is under it, so `White` at 20%
// came out #56396D on a purple card and #A3C9C1 on a teal one. With one fill that
// argument no longer bites — but an opaque grey is what NoteCard.vue must also write,
// and two surfaces stating the same hex is how they stay the same card.
private val CARD_EDGE_LIGHT = Color(0xFFB8B8B8) private val CARD_EDGE_LIGHT = Color(0xFFB8B8B8)
private val CARD_EDGE_DARK = Color(0xFF404040) private val CARD_EDGE_DARK = Color(0xFF404040)
private val CHIP_RADIUS = 6.dp private val CHIP_RADIUS = 6.dp
@@ -44,22 +44,9 @@ data class NoteTint(
val lightChipForeground: Color, val lightChipForeground: Color,
val darkChipForeground: Color, val darkChipForeground: Color,
/** /**
* THE INK A TAG IS DRAWN IN — inline in the prose AND as a chip's text — see * THE INK A TAG IS DRAWN IN — inline in the prose and as a chip's text — see
* [tagInk]. * [tagInk]. One value serves both: `-800` light / `-300` dark clears body-text
* * contrast on the card and on a chip's own fill.
* These were two columns until M315, and the split was real while it lasted: a chip
* brought its own `-100` fill and could afford `-700`, while inline text sat on
* whatever the card was, which included a gray-tagged card at `neutral-200` where
* `-700` measured 3.98 (green), 4.11 (orange) and 4.34 (teal), all under the 4.5
* body text needs. One step deeper cleared every fill at once.
*
* The twenty card fills that split was solving for are gone, so both jobs take this
* one value. The direction is deliberate: since M311 a tag whose text is in the body
* is drawn where it was typed and NOT repeated as a chip, so the inline token is the
* common case and collapsing onto ITS column leaves what is seen most exactly as it
* was. The chip is strictly better for the move — on its own fill it goes from
* 4.52-8.23 to 6.37-12.01 in light. Dark needed no decision: the two columns already
* held the same value for all ten hues.
*/ */
val lightTagInk: Color, val lightTagInk: Color,
val darkTagInk: Color, val darkTagInk: Color,
+3 -8
View File
@@ -187,14 +187,9 @@ pub fn evaluate(info: &ServerInfo) -> Compatibility {
/// Who this client says it is, set once by the host application at startup. /// Who this client says it is, set once by the host application at startup.
/// ///
/// THE CORE CANNOT KNOW THIS, and the value it used to invent was wrong twice. It /// The core can't know this: it is compiled into both the desktop and the Android
/// was `inkwell-desktop/{CARGO_PKG_VERSION}`, and this crate is compiled into /// app, and its own crate version is a number no build stamps. The app's build is
/// the desktop app AND the Android app — so every phone in the field announced /// what a reader of the header wants (note 3127 §5).
/// itself as a desktop. The version was worse: `CARGO_PKG_VERSION` here is the
/// version of the CORE crate, a number no build stamps and no user has ever seen,
/// while the thing a reader of that header wants is the app's own build (note 3127
/// §5 — with no version tags, the artifact's self-report is the only answer to
/// "which build is this?").
/// ///
/// So the host names itself. `OnceLock` because identity is fixed for the life of /// So the host names itself. `OnceLock` because identity is fixed for the life of
/// the process and a second caller should be ignored rather than race the first. /// the process and a second caller should be ignored rather than race the first.
+9 -19
View File
@@ -110,28 +110,18 @@ export function plusTask(blocks: EditorBlock[]): { blocks: EditorBlock[]; focus:
} }
/** /**
* Re-read ONE prose block for `- [ ] ` lines somebody typed by hand. * Re-read ONE prose block for `- [ ] ` lines somebody typed by hand, so a marker
* typed in the editor becomes an item without closing and reopening the note.
* *
* `splitBlocks` runs once, when the editor opens. After that the blocks are the state * On blur, and only the block being left: re-splitting on a keystroke would move the
* and nothing reads the body again — every edit travels the other way, through * caret, and converting the moment `- [ ]` is complete would catch an item with no
* `joinBlocks`. So a marker typed by hand stayed literal text on screen until the note * text yet.
* was closed and reopened, even though it was already a real item in storage and the
* card was already drawing a checkbox for it. The editor was the only place that
* disagreed.
* *
* ON BLUR, and only the block being left. There is no good moment to convert while * Returns THE SAME ARRAY when there was nothing to promote; the caller relies on that
* someone is typing: re-splitting on a keystroke moves the caret out of the word being * to leave the ref alone, so a blur that changed nothing doesn't re-key every field.
* written, and converting the instant `- [ ]` is complete does it before the item has
* any text. Blur is the one moment the person has demonstrably finished with the block,
* so a re-split costs no caret and cannot catch a half-typed line.
* *
* Returns THE SAME ARRAY, not an equal copy, when there was nothing to promote — the * Non-canonical markers (`- [X]`, an odd bullet) come back canonical, as they would on
* caller leans on that to leave the ref alone, and a blur that changed nothing must not * reopen.
* re-key every field below it.
*
* Non-canonical markers (`- [X]`, an odd bullet) come back canonical, exactly as they
* would have on reopen. That is the only case where this changes the body rather than
* only the way it is drawn.
*/ */
export function promoteTasks(blocks: EditorBlock[], index: number): EditorBlock[] { export function promoteTasks(blocks: EditorBlock[], index: number): EditorBlock[] {
const block = blocks[index]; const block = blocks[index];
+15 -73
View File
@@ -36,29 +36,12 @@ export const NOTE_SWATCH_CLASSES: Record<NoteColor, string> = {
}; };
// A chip's SHELL: its fill and its hairline edge, keyed by the colour vocabulary. The // A chip's SHELL: its fill and its hairline edge, keyed by the colour vocabulary. The
// INK is not here — see TAG_TEXT_CLASSES, which one table now serves both a chip's text // ink is TAG_TEXT_CLASSES; `labelChipClasses` composes the two.
// and a `#tag` left in the prose. Compose the two with `labelChipClasses`.
// //
// THE RING IS NOT DECORATION. A chip's fill measures 1.02-1.26 against the card in // The ring is what gives the pill its shape: a chip's fill measures only 1.02-1.73
// light and 1.02-1.73 in dark — that is to say, very nearly nothing. The pill's shape // against the card. It is the ink at 65%, the lowest alpha that clears WCAG 1.4.11's
// is the edge; the fill only tints it. (Dark red is the extreme at 1.02, which is // 3:1 for all ten hues on the one card surface (light 3.03-4.36, dark 4.52-5.76).
// invisible: without the ring that chip would be loose text.) An edge holds the shape // `default` follows the same rule as every other key.
// against any background, where shifting the fill only moves which card it collides
// with.
//
// AT 65%, NOT 60%, AND SOLVED FOR RATHER THAN GUESSED. 0.60 was chosen against a worst
// case that no longer exists — a chip sitting on a card of its own colour, back when a
// note took its first tag's fill. Against the one card surface (M315) the ring is the
// ink at alpha over a known fill, so the alpha that clears the 3:1 of WCAG 1.4.11 for
// all ten hues can simply be solved: 0.60 gives 2.75-3.82 in light and misses for six
// of them, 0.65 gives 3.03-4.36 and misses for none. Dark is 4.52-5.76. 0.80 was the
// number the old comment named as the fallback and it is more than is needed — it
// draws a hard outline where a hairline does the job.
//
// `default`'s ring was `black/10 dark:white/15` here and the ink at alpha on the phone —
// 1.36 against its own fill in light against Compose's 3.21, so the two surfaces were
// drawing visibly different pills for the same chip. It is the ink at 65% on both now,
// like every other key: one rule for ten hues, not nine and an exception.
// //
// Mirrored in NoteTint.kt as `chipBackground` and `chipBorder` / CHIP_EDGE_ALPHA. // Mirrored in NoteTint.kt as `chipBackground` and `chipBorder` / CHIP_EDGE_ALPHA.
export const LABEL_CHIP_SHELL: Record<NoteColor, string> = { export const LABEL_CHIP_SHELL: Record<NoteColor, string> = {
@@ -74,26 +57,13 @@ export const LABEL_CHIP_SHELL: Record<NoteColor, string> = {
gray: "bg-neutral-200 dark:bg-neutral-700 ring-1 ring-inset ring-neutral-800/65 dark:ring-neutral-200/65", gray: "bg-neutral-200 dark:bg-neutral-700 ring-1 ring-inset ring-neutral-800/65 dark:ring-neutral-200/65",
}; };
// THE INK A TAG IS DRAWN IN — one table, for a `#tag` left in the prose AND for a // THE INK A TAG IS DRAWN IN — one table, for a `#tag` left in the prose and for a
// chip's text. It was two, and the split was real while it lasted: a chip carried its // chip's text. `-800` in light and `-300` in dark clear body-text contrast in both
// own `-100` fill and could afford `-700`, while inline text sat on whatever the card // places:
// was, which included a gray-tagged card at `neutral-200` where `-700` measured 3.98
// (green), 4.11 (orange) and 4.34 (teal) — all under the 4.5 body text needs. One step
// deeper cleared every fill at once, so inline got `-800` and the chip kept `-700`.
//
// M315 removed the twenty card fills the split was solving for, and this is the payoff:
// against ONE card surface both jobs can take the same value. `-800`/`-300` is the one
// they take, and the direction is deliberate — the inline token is the common case
// (since M311 a tag whose text is in the body is drawn where it was typed and NOT
// repeated as a chip), so collapsing onto the inline column leaves what is seen most
// exactly as it was, and moves only the chip. The chip is strictly better for it:
// //
// inline, on the card as a chip, on its own fill // inline, on the card as a chip, on its own fill
// light `-800` 7.09 - 15.13 6.37 - 12.01 (was 4.52 - 8.23) // light `-800` 7.09 - 15.13 6.37 - 12.01
// dark `-300` 9.45 - 14.23 8.23 - 11.88 (unchanged) // dark `-300` 9.45 - 14.23 8.23 - 11.88
//
// Dark needed no decision at all: the two tables were already the same value for all
// ten hues, which is on its own most of the argument that one table was always enough.
// //
// Mirrored in NoteTint.kt as `lightTagInk` / `darkTagInk`. // Mirrored in NoteTint.kt as `lightTagInk` / `darkTagInk`.
export const TAG_TEXT_CLASSES: Record<NoteColor, string> = { export const TAG_TEXT_CLASSES: Record<NoteColor, string> = {
@@ -249,33 +219,9 @@ export function resolveLabelColor(label: { name: string; color?: string | null }
// chips of one colour are the same tag. The chip's text is what says which tag it is. // chips of one colour are the same tag. The chip's text is what says which tag it is.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// THE CARD SURFACE — one neutral, no hue, no note involved (M315). // THE CARD SURFACE — one neutral, the same for every note. Colour lives only on tags
// // (the chip and the inline `#tag`); a card's fill can't carry meaning, because nine
// This used to be a function of the note: a palette class for a tagged one, a fill // keys identify nothing on a board of any size. It is the editor panel's surface too.
// generated from the id for the rest. Both are gone. The operator's verdict after
// four passes at it — "my coloring attempt has failed and nothing looks right… we've
// tried a lot to make the color work and somehow it never seems to land" — and the
// diagnosis underneath it is that a card's fill was being asked to carry meaning it
// could not carry. Nine keys is too few to identify anything on a board of any size,
// and a generated fill identifies nothing by construction, so a coloured board taught
// the eye to read hue as significant and then handed it noise.
//
// COLOUR NOW LIVES ONLY ON THE TAG — the chip and the inline `#tag`, both of which sit
// on this one known background from here on. That is a smaller job done properly
// instead of a larger one done four times.
//
// The codebase had already made this argument about the card's EDGE, one level down:
// the hue-coded border came out as "a neutral line carries no information at all,
// which is exactly what lets it be structure instead of content". The fill is the same
// argument at the next size up.
//
// THE VALUES. `bg-white dark:bg-neutral-900` — which is exactly what `default` always
// was, and exactly what the note EDITOR panel has always been (NoteEditor.vue), so
// this is a collapse onto a surface both other surfaces already used rather than a
// new colour anybody has to like.
//
// Measured against the operator's constraint, "not the same color as their background
// but close to it":
// //
// card vs board light #ffffff on #fafafa 1.04 // card vs board light #ffffff on #fafafa 1.04
// dark #171717 on #0a0a0a 1.10 // dark #171717 on #0a0a0a 1.10
@@ -283,11 +229,7 @@ export function resolveLabelColor(label: { name: string; color?: string | null }
// dark #404040 on #171717 1.73 // dark #404040 on #171717 1.73
// body vs card light #171717 on #ffffff 17.93 (needs 4.5) // body vs card light #171717 on #ffffff 17.93 (needs 4.5)
// dark #fafafa on #171717 17.17 // dark #fafafa on #171717 17.17
// muted vs card light #404040 on #ffffff 10.37
// dark #e5e5e5 on #171717 14.23
// //
// The fill is deliberately the WEAKEST of those numbers. A card is not separated from // The fill is deliberately the weakest number: the edge and the shadow separate a card
// the board by its fill and never was — the edge and the shadow do that, which is why // from the board, and a fill strong enough to do it alone would make every card a panel.
// 1.04 is enough and why it has to stay near 1: a fill that separated on its own would
// be a panel, and a board of panels is the wall this whole line of work started from.
export const NOTE_CARD_SURFACE = "bg-white dark:bg-neutral-900"; export const NOTE_CARD_SURFACE = "bg-white dark:bg-neutral-900";
+7 -33
View File
@@ -51,45 +51,19 @@ DEFAULT_LIMIT = 500
MAX_LIMIT = 1000 MAX_LIMIT = 1000
MAX_PUSH = 1000 # per-batch change cap MAX_PUSH = 1000 # per-batch change cap
# --- protocol versioning (M10.6) -------------------------------------------- # --- protocol versioning ------------------------------------------------------
# #
# The client<->server compatibility contract. These integers version the WIRE # The client<->server compatibility contract. These integers version the WIRE
# PROTOCOL, deliberately separate from the app's release version, so a client and # PROTOCOL, separately from the app's release version, so a client and server on
# server on different releases can still work out whether they can talk. Without # different releases can still work out whether they can talk.
# that separation every protocol change would force app<->server lockstep.
# #
# SYNC_PROTOCOL_VERSION what this server speaks. # SYNC_PROTOCOL_VERSION what this server speaks.
# MIN_CLIENT_PROTOCOL_VERSION the oldest client protocol it still accepts. # MIN_CLIENT_PROTOCOL_VERSION the oldest client protocol it still accepts.
# #
# Bump SYNC_PROTOCOL_VERSION for ANY wire change. Raise # Bump SYNC_PROTOCOL_VERSION for ANY wire change. Raise the floor only for a
# MIN_CLIENT_PROTOCOL_VERSION only for a genuinely BREAKING one: it is the switch # BREAKING one: it hard-blocks older clients, so additive changes leave it alone.
# that hard-blocks older clients, so additive changes must leave it alone. # What each version changed, and why the floor moved or didn't, is in docs/sync.md
# v2 (M13): `kind` and `title` both left the wire. Dropping a field a v1 client sends # ("The policy").
# and expects back is breaking, so the FLOOR moves too — a v1 client would keep pushing
# both and would read back notes carrying neither.
#
# One bump for the pair: they landed in the same protocol generation, and nothing ever
# ran against a half-applied v2.
# v4 (M315): `color` left the note. The FLOOR DELIBERATELY DOES NOT MOVE, and v2 is
# the precedent that makes saying so worthwhile — it dropped `kind` and `title` and did
# raise the floor, on the rule that dropping a field a client sends and expects back is
# breaking. `color` fails the second half of that: a v3 client reading a v4 note gets
# `"default"` from its own serde default and draws the colour it derives locally, which
# is the board it drew yesterday; a v3 client PUSHING `color` has the key ignored, since
# `_assign_note_fields` reads its payload key by key and never validates the shape.
# Neither direction errors and neither loses anything visible. `title` was the note's
# NAME; this is a field that no longer renders anywhere.
# v5 (#5168): attachments became a sync entity — `PUT /attachments/<id>` uploads a file
# attached offline, and push takes `attachment` and `preview` deletes. Additive, so the
# floor stays: a v4 client never sends either, and gets the same notes it always did.
# v6 (#5175): shared notes. A client asking `changes?shares=1` gets the notes shared
# with it, each saying how it is held (`permission`, `shared`, `shared_by`), and a
# `revoked` list of notes that have left it; push takes body edits to notes shared at
# `edit`. Additive and opt-in, so the floor stays: a v5 client never asks, and gets
# its own notes exactly as before.
# v7 (#5176): a recipient's own pin, archive and place. On a shared note the feed
# carries the caller's own, and push takes them, stamped `state_at`, for a note shared
# at any level. Additive, so the floor stays: a v6 client pushes only text, as before.
SYNC_PROTOCOL_VERSION = 7 SYNC_PROTOCOL_VERSION = 7
MIN_CLIENT_PROTOCOL_VERSION = 3 MIN_CLIENT_PROTOCOL_VERSION = 3