diff --git a/android/app/src/main/java/com/fabledsword/inkwell/ui/EditorBlock.kt b/android/app/src/main/java/com/fabledsword/inkwell/ui/EditorBlock.kt index dfe7dbd..4d50808 100644 --- a/android/app/src/main/java/com/fabledsword/inkwell/ui/EditorBlock.kt +++ b/android/app/src/main/java/com/fabledsword/inkwell/ui/EditorBlock.kt @@ -143,28 +143,18 @@ fun List.plusTask(): Pair, 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 - * and nothing reads the body again — every edit travels the other way, through - * [joinBlocks]. So a marker typed by hand stayed literal text on screen until the note - * 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:** re-splitting on a keystroke would move + * the caret, and converting the moment `- [ ]` is complete would catch an item with + * no text yet. * - * **On blur, and only the block being left.** There is no good moment to convert while - * someone is typing: re-splitting on a keystroke moves the caret out of the word being - * 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 when there was nothing to promote; the caller relies on that to + * leave the state alone, so a blur that changed nothing doesn't re-key every field. * - * Returns THIS LIST, not an equal copy, when there was nothing to promote — the caller - * leans on that to leave the state alone, and a blur that changed nothing must not - * 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. + * Non-canonical markers (`- [X]`, an odd bullet) come back canonical, as they would on + * reopen. */ internal fun List.promotingTasks(index: Int): List { val block = getOrNull(index) diff --git a/android/app/src/main/java/com/fabledsword/inkwell/ui/NoteCard.kt b/android/app/src/main/java/com/fabledsword/inkwell/ui/NoteCard.kt index b9b8442..cd94b2a 100644 --- a/android/app/src/main/java/com/fabledsword/inkwell/ui/NoteCard.kt +++ b/android/app/src/main/java/com/fabledsword/inkwell/ui/NoteCard.kt @@ -101,22 +101,13 @@ fun NoteCard( .border(1.dp, if (dark) CARD_EDGE_DARK else CARD_EDGE_LIGHT, RoundedCornerShape(CARD_RADIUS)) .padding(12.dp), ) { - // TAGS FIRST. They used to sit under everything else, which on a tall note put - // the one thing that says what a note IS below the fold of a glance. A board is - // scanned, not read, and the answer to "which of these is about the thing I am - // looking for" should be the first thing the eye lands on rather than the last. + // TAGS FIRST, on a row of their own above the body: a board is scanned, and + // what a note is about should be the first thing the eye lands on. Not beside + // the body, whose first line is the note's name. // - // Above the body rather than beside it, because the body's first line is the - // note's NAME (M13 steps 3 and 4) and a chip floated next to it would compete - // with the thing that identifies the note. A row of its own costs one line and - // 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. + // Only labels whose text is not still in the note (`via_tag` is false). A tag + // left in prose is tinted in place instead (see [tintTags]), so this row holds + // what the body cannot say: a tag lifted off its own line, or one added by hand. val chips = note.labels.filterNot { it.viaTag } if (chips.isNotEmpty()) { 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 - * actions are in the editor" — it was *"there are no long hold context menus in the - * app I have no way to delete notes."* Trash was three interactions deep (open, ⋮, - * 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 + * resources, dispatched through [EditorAction] so `BoardViewModel.onEditorAction` + * stays the one place they are handled. * - * **The same items as the editor's overflow, in the same words, from the same string - * resources.** A note has one vocabulary of things that can be done to it, and two - * 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 (`note.trashed`), not on the view, because Reminders and search + * mix piles: a note already in Trash must not offer "Move to trash". * - * **Gated on the NOTE, not on the destination.** `note.trashed` is what the editor - * 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. + * No Labels item: the picker it opens is editor state. */ @Composable 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_DARK = Color(0xFF171717) -// THE CARD'S EDGE — one grey, every card, both themes. Since M315 it is the only thing -// that differs from the board by more than a hair, which makes it structure rather than -// decoration: it is what a card IS. +// THE CARD'S EDGE — one neutral grey, every card, both themes. The fill barely differs +// from the board, so this line is what makes a card a card. Neutral on purpose: a +// 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 -// was a `{hue}-900` border and failed twice over: the line measured 1.56-2.09 against -// its own fill while the fill managed only 1.03-1.05 against the board, so it was the -// 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. +// Opaque rather than a translucent black/white, which would composite differently over +// whatever is under it. NoteCard.vue writes the same two hex values, which is how the +// surfaces stay the same card. Contrast: #B8B8B8 on white 1.98, #404040 on #171717 1.73. private val CARD_EDGE_LIGHT = Color(0xFFB8B8B8) private val CARD_EDGE_DARK = Color(0xFF404040) private val CHIP_RADIUS = 6.dp diff --git a/android/app/src/main/java/com/fabledsword/inkwell/ui/NoteTint.kt b/android/app/src/main/java/com/fabledsword/inkwell/ui/NoteTint.kt index dce7fff..48636c7 100644 --- a/android/app/src/main/java/com/fabledsword/inkwell/ui/NoteTint.kt +++ b/android/app/src/main/java/com/fabledsword/inkwell/ui/NoteTint.kt @@ -44,22 +44,9 @@ data class NoteTint( val lightChipForeground: Color, val darkChipForeground: Color, /** - * THE INK A TAG IS DRAWN IN — inline in the prose AND as a chip's text — see - * [tagInk]. - * - * 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. + * THE INK A TAG IS DRAWN IN — inline in the prose and as a chip's text — see + * [tagInk]. One value serves both: `-800` light / `-300` dark clears body-text + * contrast on the card and on a chip's own fill. */ val lightTagInk: Color, val darkTagInk: Color, diff --git a/core/src/sync/compat.rs b/core/src/sync/compat.rs index a8a73c5..e274e0e 100644 --- a/core/src/sync/compat.rs +++ b/core/src/sync/compat.rs @@ -187,14 +187,9 @@ pub fn evaluate(info: &ServerInfo) -> Compatibility { /// 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 -/// was `inkwell-desktop/{CARGO_PKG_VERSION}`, and this crate is compiled into -/// the desktop app AND the Android app — so every phone in the field announced -/// 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?"). +/// The core can't know this: it is compiled into both the desktop and the Android +/// app, and its own crate version is a number no build stamps. The app's build is +/// what a reader of the header wants (note 3127 §5). /// /// 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. diff --git a/frontend/src/notes/blocks.ts b/frontend/src/notes/blocks.ts index 772437b..c9ebaaa 100644 --- a/frontend/src/notes/blocks.ts +++ b/frontend/src/notes/blocks.ts @@ -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 - * and nothing reads the body again — every edit travels the other way, through - * `joinBlocks`. So a marker typed by hand stayed literal text on screen until the note - * 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: re-splitting on a keystroke would move the + * caret, and converting the moment `- [ ]` is complete would catch an item with no + * text yet. * - * ON BLUR, and only the block being left. There is no good moment to convert while - * someone is typing: re-splitting on a keystroke moves the caret out of the word being - * 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 when there was nothing to promote; the caller relies on that + * to leave the ref alone, so a blur that changed nothing doesn't re-key every field. * - * Returns THE SAME ARRAY, not an equal copy, when there was nothing to promote — the - * caller leans on that to leave the ref alone, and a blur that changed nothing must not - * 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. + * Non-canonical markers (`- [X]`, an odd bullet) come back canonical, as they would on + * reopen. */ export function promoteTasks(blocks: EditorBlock[], index: number): EditorBlock[] { const block = blocks[index]; diff --git a/frontend/src/notes/colors.ts b/frontend/src/notes/colors.ts index a924189..12930b2 100644 --- a/frontend/src/notes/colors.ts +++ b/frontend/src/notes/colors.ts @@ -36,29 +36,12 @@ export const NOTE_SWATCH_CLASSES: Record = { }; // 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 -// and a `#tag` left in the prose. Compose the two with `labelChipClasses`. +// ink is TAG_TEXT_CLASSES; `labelChipClasses` composes the two. // -// THE RING IS NOT DECORATION. A chip's fill measures 1.02-1.26 against the card in -// light and 1.02-1.73 in dark — that is to say, very nearly nothing. The pill's shape -// is the edge; the fill only tints it. (Dark red is the extreme at 1.02, which is -// invisible: without the ring that chip would be loose text.) An edge holds the shape -// 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. +// The ring is what gives the pill its shape: a chip's fill measures only 1.02-1.73 +// against the card. It is the ink at 65%, the lowest alpha that clears WCAG 1.4.11's +// 3:1 for all ten hues on the one card surface (light 3.03-4.36, dark 4.52-5.76). +// `default` follows the same rule as every other key. // // Mirrored in NoteTint.kt as `chipBackground` and `chipBorder` / CHIP_EDGE_ALPHA. export const LABEL_CHIP_SHELL: Record = { @@ -74,26 +57,13 @@ export const LABEL_CHIP_SHELL: Record = { 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 -// chip's text. It was two, and the split was real while it lasted: a chip carried 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, 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: +// THE INK A TAG IS DRAWN IN — one table, for a `#tag` left in the prose and for a +// chip's text. `-800` in light and `-300` in dark clear body-text contrast in both +// places: // // 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) -// dark `-300` 9.45 - 14.23 8.23 - 11.88 (unchanged) -// -// 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. +// light `-800` 7.09 - 15.13 6.37 - 12.01 +// dark `-300` 9.45 - 14.23 8.23 - 11.88 // // Mirrored in NoteTint.kt as `lightTagInk` / `darkTagInk`. export const TAG_TEXT_CLASSES: Record = { @@ -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. // --------------------------------------------------------------------------- -// THE CARD SURFACE — one neutral, no hue, no note involved (M315). -// -// This used to be a function of the note: a palette class for a tagged one, a fill -// 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": +// 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 +// keys identify nothing on a board of any size. It is the editor panel's surface too. // // card vs board light #ffffff on #fafafa 1.04 // 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 // body vs card light #171717 on #ffffff 17.93 (needs 4.5) // 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 board by its fill and never was — the edge and the shadow do that, which is why -// 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. +// The fill is deliberately the weakest number: the edge and the shadow separate a card +// from the board, and a fill strong enough to do it alone would make every card a panel. export const NOTE_CARD_SURFACE = "bg-white dark:bg-neutral-900"; diff --git a/src/inkwell/sync.py b/src/inkwell/sync.py index 9489e86..7ac27dd 100644 --- a/src/inkwell/sync.py +++ b/src/inkwell/sync.py @@ -51,45 +51,19 @@ DEFAULT_LIMIT = 500 MAX_LIMIT = 1000 MAX_PUSH = 1000 # per-batch change cap -# --- protocol versioning (M10.6) -------------------------------------------- +# --- protocol versioning ------------------------------------------------------ # # The client<->server compatibility contract. These integers version the WIRE -# PROTOCOL, deliberately separate from the app's release version, so a client and -# server on different releases can still work out whether they can talk. Without -# that separation every protocol change would force app<->server lockstep. +# PROTOCOL, separately from the app's release version, so a client and server on +# different releases can still work out whether they can talk. # # SYNC_PROTOCOL_VERSION what this server speaks. # MIN_CLIENT_PROTOCOL_VERSION the oldest client protocol it still accepts. # -# Bump SYNC_PROTOCOL_VERSION for ANY wire change. Raise -# MIN_CLIENT_PROTOCOL_VERSION only for a genuinely BREAKING one: it is the switch -# that hard-blocks older clients, so additive changes must leave it alone. -# v2 (M13): `kind` and `title` both left the wire. Dropping a field a v1 client sends -# 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/` 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. +# Bump SYNC_PROTOCOL_VERSION for ANY wire change. Raise the floor only for a +# BREAKING one: it hard-blocks older clients, so additive changes leave it alone. +# What each version changed, and why the floor moved or didn't, is in docs/sync.md +# ("The policy"). SYNC_PROTOCOL_VERSION = 7 MIN_CLIENT_PROTOCOL_VERSION = 3