dev
311
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
ad48d30c68 |
M311 step 1 — lift a tag that is standing on its own
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Failing after 8s
CI & Build / integration (push) Failing after 9s
CI & Build / Build & push image (push) Skipped
A tag was shown twice: once as the `#todo` you typed and once as a chip. The
chip moved to the top of the card in 23fd2da; now the text goes — but only
when the tag was the whole line.
THE RULE: a line containing nothing but tags and whitespace is removed.
Anything else is untouched.
That is the conservative reading of "standalone" and it is the operator's:
"only lift standalone tags, leave mid-sentence ones alone". The looser
reading, also stripping a trailing tag off a prose line, is rejected because
the text does not say which kind it is — `buy milk #grocery` is filing,
`remember to call #mom` is the sentence's object, and lifting the second
leaves "remember to call". Mangling a sentence to save a duplicate chip is a
bad trade.
Two guards. A line inside a ``` fence is never touched: a `#tag` there is a
shell comment in somebody's snippet, and deleting it would eat a line of
their example. And a note that is NOTHING but tags keeps its text rather than
being blanked — a duplicated chip beats an empty card.
WHY THIS IS NOT JUST A TEXT EDIT. `via_tag` labels are DERIVED from the body:
reconcile detaches any row no longer backed by a `#tag`, and the picker only
manages `via_tag=False` rows. So a naive lift deletes every tag on the next
save, and leaves them unremovable until then.
Resolved by giving `via_tag` a sharper meaning — backed by text still in the
body — rather than deleting it:
standalone lifted, attached as an ORDINARY label. Nothing derives it any
more because nothing is left to derive it from.
inline left in place, still derived, still detached when its text goes.
Which costs nothing elsewhere, because both editors already gate their remove
button on `!via_tag` (NoteEditor.vue:618, EditorChrome.kt:349). A lifted tag
gets its × for free — and needs it, since deleting the text is no longer a
way to remove one. No wire change, no column drop, no UI change.
A tag that GRADUATES from inline to standalone is the sharp edge: its row has
to be flipped before the detach pass, or the same row is dropped for no longer
being in the body. That is the bug, and there is a test on it.
The lift and the display_title re-derivation both live inside the function,
which is renamed to admit it mutates the body. All seven call sites derive
display_title BEFORE calling, so anywhere else and every note would be named
after a line that had just been deleted. Spreading a derived-value update
across seven write paths is the failure #2965 named: "easy to miss, and it is
the common one".
Existing notes lift lazily, on their next save. The migration that does the
rest is step 2, and the core's own copy of the rule is step 3.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
23fd2da91e |
The tag goes at the top of the card, where it gets looked at
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 12s
CI & Build / integration (push) Successful in 19s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m58s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m21s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 8m6s
Label chips sat under the body, the checklist, the attachments and the link previews. On a tall note that puts the one thing saying what a note IS below the fold of a glance — and a board is scanned, not read. "Which of these is about the thing I am looking for" should be the first thing the eye lands on. Above the body rather than beside it: 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 carry tags. Both surfaces, same order. Does not depend on tag lifting, which is a much larger change — see the task. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
3d490bb6f3 |
HSL lightness is not luminance — the dark floor was too low
The unit test I added with the generated fills failed on its first run, on exactly the claim it was written to check, so it earned its keep immediately. The floor was 0.090 — `neutral-900`'s own HSL lightness — reasoning that a ramp starting at the card surface and climbing could not end up below it. That confuses HSL lightness with luminance. At one fixed lightness the eye sees very different brightnesses by hue, because green carries 71% of the luminance formula and blue only 7%: at L=0.090 a yellow measures 0.0118 and a blue 0.0061. Every blue-ish untagged note was 1.41x DARKER than the card it was supposed to match, which on the board reads as a hole rather than as variety — the opposite of what the whole change is for. Solved rather than nudged: 0.113 is the lowest floor at which EVERY hue clears the card surface. The range now measures 1.11-1.71 against the board against the old 1.06-1.54, so the floor is back where the shipped ramp had it and the ceiling is higher. Body text 7.8 against the 4.5 it needs, meta 4.6 against 3.0. 338 distinct dark fills. Two things about the test are worth keeping. It asserts on LUMINANCE rather than on the lightness that was put in — a test of the input would have agreed with the bug and passed. And it now sweeps 40,000 ids rather than 500. The worst case is a HUE, not an id, and 500 ids reach only 459 of the 2160 hue/level combinations — it caught this one by luck. 40,000 covers all 2160. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
86f1e4a08f |
detekt: sector indices as a table, not a when
MagicNumber's ignore list is -1/0/1/2, so the `3 ->` and `4 ->` branch labels were findings. A lookup table has no literals to flag, and it is the form colors.ts already uses — the two now read as the same function rather than as two people's idea of it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
c255b170d4 |
ktlint: a stray blank line from the append
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
1d3cc7bcd4 |
Nine tints that looked like three — generate the fill instead
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 12s
CI & Build / integration (push) Successful in 20s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m54s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Failing after 4m21s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m13s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Measured, the nine dark subdued fills were separated from each other by at most a 1.03 contrast ratio. That is not "subtle", it is identical, and it is why a board of them reads as one card repeated: "I only see 3 colors ... it looks like a monolithic wall." Two causes, and the second one is mine. NINE IS TOO FEW. The palette exists to say WHICH TAG. An untagged note's fill says nothing at all — it only has to keep the board from repeating. Those are different jobs and tying them together capped the second at nine values for a board that will hold hundreds. ONE AXIS IS TOO FEW. The subdued ramp varied hue while pinning every fill to the same lightness — deliberately, so each would read as a card against the board. But the eye separates by lightness first, so nine hues at one lightness are one card nine times. Hue alone was never going to carry it at that darkness. So an untagged note's fill is now generated from its id rather than looked up: hue anywhere on the circle, one of six lightness levels, saturation fixed. 324 distinct fills in dark and 193 in light, against nine. Separation between fills goes from a 1.03 ceiling to 1.42. Varying lightness is only SAFE because the card has its own grey edge now. While the fill was the card's only boundary it could not afford to drift toward the board; the edge bought that freedom, one commit before it was needed. Saturation is the one dial the hash never touches — variety comes from hue and lightness, loudness would come from saturation. Dark starts a hair under `neutral-900` and climbs, so no note is ever darker than a plain card. Light runs from white down past the board. Body text measures 8.7 at worst against the 4.5 it needs; the meta row 5.1 against 3.0. DOUBLE, NOT FLOAT, on the Kotlin side. JavaScript has one number type and it is binary64; a Kotlin Float is binary32, so the two would round differently near a channel boundary and a note would be one byte off between the phone and the browser. Nobody would ever file that — they would see two colours that are "sort of the same" and never work out why. The web half cannot be executed here at all (no node on this machine), so the Kotlin fixture test is the only place the two implementations are ever compared. It now pins eight generated values as well as the hash, plus the properties that actually matter: that lightness varies, that nothing sinks below the card surface, and that body text stays clear of AA. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
ae2053d2ed |
Give the cards an edge again — one grey, not ten hues
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 9s
CI & Build / Python lint (push) Successful in 10s
CI & Build / Python tests (push) Successful in 15s
CI & Build / integration (push) Successful in 20s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m59s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m39s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 8m20s
The border was never the problem; a border that carried COLOUR was. It said exactly what the fill already said, at 1.56-2.09 against that fill where the fill managed 1.03-1.05 against the board — the loudest element on every card was redundant with the quietest. A line that varies by colour is content and competes with the fill. A line that never varies is structure and does not. So the edge comes back, and it comes back as a constant in NoteCard rather than a column in the palette. Uniformity is the feature, and putting it where the palette cannot reach it is how that stays true. light #b8b8b8 1.57-1.98 against all twenty card fills dark #404040 1.58-1.73 Matched, not eyeballed: both land at ~1.6-1.7 against the card they edge, so the edge reads with the same authority in either theme. Dark is `neutral-700` — what the `default` card's border always was, one entry's value promoted to the rule for all of them. Light sits between `neutral-300` and `neutral-400` because neither lands in range: 300 fades to 1.18 on a gray-tagged card, 400 jumps to 2.52 and reads as a wireframe. Rejected on measurement: a translucent black/white edge, which is the tidier way to write it and self-adjusts per card. A border composites over the card's own fill, so `border-white/20` comes out #56396d on a purple card and #a3c9c1 on a teal one. Hue-coded edges are the thing being removed. The shadow steps back to what it was for — depth, not the boundary. Web returns to `shadow-sm`; Android's 2dp drops to 1dp, matching it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
47f108c9c8 |
The border was the thing making every note look the same
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 9s
CI & Build / Python tests (push) Successful in 14s
CI & Build / integration (push) Successful in 18s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m8s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m23s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 8m4s
A note card carried a 1px tint border. Measured against its own fill, that
line was a 1.56-2.09 contrast in dark mode while the fill managed only
1.03-1.05 against the board — so the loudest thing on every card was an
identical line in an identical place, and a field of them read as a grid of
outlined rectangles however different the colours inside were.
Removed from the note card on both surfaces. `border` survives for panels,
banners, the update card and the pickers: those are single elements, not a
field of them.
What replaces it differs by theme, because elevation does.
Light leans on a shadow. An untagged card is `bg-red-50` on a `neutral-50`
board — a 1.04 contrast that can only read as a card by sitting above one.
The web goes `shadow-sm` -> `shadow`; Android had no shadow at all and gets
2dp.
Dark cannot use one, black on near-black. So the subdued fills moved onto
the card surface instead: `{hue}-950` composited at 0.18 over #171717 and
baked, rather than the same hue at 0.25 over the near-black board. An
untagged card now sits where the plain white card always sat (1.11-1.14
against the board, against `bg-neutral-900`'s 1.10) while carrying LESS hue
than before — chroma 7-17 where the old ramp had 10-23.
Subtler and more visible at once, which is only a contradiction if subtlety
has to come from lightness. Here it comes from chroma, and lightness is left
to say "this is a card". Which also reframes the two weights: in dark they
now sit within a hair of each other (red: 1.11 vs 1.12) and differ threefold
in colour (chroma 10 vs 41).
The chosen ramp is untouched — the operator signed those colours off, and a
ramp somebody likes is not something to redo while fixing something else.
Light was already built this way: `-50` and `-100` are both white plus a
different amount of hue.
Body text still measures 14.3-16.4 against the 4.5 it needs, meta 6.9-7.1
against 3.0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
fe18aaa956 |
The contrast pass, and the invisible chip it found
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 18s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m53s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m19s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Android / Kotlin + Rust (APK) (push) Successful in 7m30s
Step 4 of milestone 309. All 40 combinations measured rather than eyeballed — 10 hues x 2 themes x 2 weights, dark ones composited over the board the way Compose and CSS both do, against the text actually drawn on a card (neutral-700/300 body, neutral-500/400 meta). Body text ranges 8.23:1 to 13.01:1 against a 4.5:1 requirement; meta text 4.33 to 7.11 against 3.0. Every combination passes AA with room to spare, so the two ramps step 3 introduced need no adjustment. That is the boring half. THE PASS FOUND A REAL REGRESSION. A tagged note takes its first tag's colour and is drawn at that hue's `-100` — which is exactly what the chip uses as its fill. Measured contrast between the chip and the card it had itself coloured: 1.00 in light mode. Perfectly invisible. Dark was 1.04-1.07, invisible in practice. On every tagged note the tag name had stopped reading as a chip and become loose text, and nothing about step 3 looked wrong while writing it. Fixed with an EDGE rather than a different fill. A fill can collide with any card colour and chasing that would need the chip to know what it is sitting on; a border in the chip's own foreground reads against any background and needs no plumbing. Alpha is 0.60, measured: 2.32:1 at worst, where the 0.30 I first wrote gave 1.49 and was no edge at all. It does not reach WCAG 1.4.11's 3:1, which needs 0.80 and draws a hard outline instead of a hairline. 1.4.11 governs boundaries carrying REQUIRED information, and a chip's information is its text — passing AA at 8:1 or better on every card here. The number and the reasoning are both in the source so the judgment can be overruled rather than rediscovered. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
20e9d535de |
android: two more ktlint rules, both in the code I just added
`noteIsStrong` had a single-line body expression wrapped onto the next line; ktlint's function-signature rule wants it on the signature line when it fits. `firstLabelColor` wrapped a call chain after `note.labels.firstOrNull()`, and chain-method-continuation wants a newline before EVERY link once one is wrapped. It reads better as two statements than as a chain, so it is two statements. Third ktlint round trip on this milestone. I pre-flighted the rules I already knew and these were not among them — and when I then wrote greps for the two new rules, they flagged sixteen files that have been passing for months, because my heuristics do not match what the rules actually check. There is no local ktlint (rule 10), so CI is the first and only reader; more elaborate greps are not the fix, and pretending they are would just add false confidence. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
988e1d3f00 |
A note's colour is its first tag's colour, at a heavier weight
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 14s
CI & Build / integration (push) Successful in 20s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m41s
Android / Kotlin + Rust (APK) (push) Failing after 3m53s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m7s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Four #todo notes on the operator's board in four different colours, because the tint was derived per-note-id and ignored tags entirely. Now a tagged note wears its first tag's colour, so notes that share a tag share a look. TWO WEIGHTS, NOT ONE RAMP. The operator, seeing step 1: "the tints look the same as the chosen colors". They did — there was only one ramp. `strong` is not a second decision, it IS whether the colour was chosen: a tag (or, until step 5, the picker) means somebody said what this note is, while a derived tint only means the board should not be a wall of white. The two weights move in OPPOSITE directions per theme, because that is where each has headroom. The operator asked whether the tint could go lighter instead of the tagged end going darker; in dark mode that is the better half of the answer, so the derived end drops to a quarter opacity — closer to the board, which gives the light body text MORE contrast rather than less. Light mode has nowhere to go below `-50` without being white again, so there the gap opens by deepening the chosen end to `-100`. No hex was transcribed for any of it. `-100` is already in NoteTint.kt as every hue's `lightChipBackground`, and the dark weights are the existing `-950` fill re-alphaed, so the only two numbers that have to agree by hand are the alphas. Copying ten more Tailwind values from memory is exactly how this mirror would have drifted. `default` is marked not tintable — it is the ABSENCE of a colour, there is no emphatic version of it, and re-alphaing its opaque neutral fill would have made every draft card translucent. Borders untouched: the fill is the signal, moving both muddies the edge. Resolution order is explicit pick, then first tag, then the id hash. First tag because it is the one you control by typing; manual labels count the same as #tags because nobody can tell which kind they made by looking. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
6fbee27f9c |
A tag with no colour of its own derives one from its name
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 15s
CI & Build / integration (push) Successful in 18s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Update manifest (push) Successful in 6s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m52s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m10s
Android / Kotlin + Rust (APK) (push) Successful in 7m43s
Every #tag ever typed is `default`. `notes/tags.py` mints one as `Label(owner_id=…, name=name)` with no colour, so it takes the column default — which means tag-driven note colour, built on top, would have left the board exactly as grey as it was. Four #todo notes in the operator's screenshot, four different colours, because the tint is per-note-id and ignores tags entirely. DERIVED RATHER THAN PERSISTED AT MINT TIME, reversing the plan in 2965. That plan wanted a hashed colour written wherever a label is born, and named the risk in its own body: `find_or_create_label` is "easy to miss, and it is the common one", because most tags are born from typing `#grocery`, not from a management screen. Deriving has no mint points to miss, needs no backfill for the tags that already exist, and reuses the hash and the fixture the notes already have. The cost is that renaming a tag recolours it. That is defensible — the name IS the tag — and an explicitly picked colour is still stored and still wins, so tag colours stay editable exactly as asked. Lowercased before hashing: tags dedupe case-insensitively, so #Todo and #todo are one tag and must not be two colours. All five places a label's colour is drawn now resolve the same way — the card chip, the editor chip, the drawer's tag list, and the management modal's dot and swatch ring. The modal's ring follows the resolved colour rather than the stored one, so opening the picker highlights what you can already see instead of nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
cddaf35280 |
android: ktlint forces a multiline signature at two parameters
`resolvedNoteColor` and `noteTintFor` are the first non-composable functions here to take more than one parameter, and ktlint_official's function-signature rule requires each parameter on its own line once there are two or more. Four findings on one and four on the other, all the same rule. Nothing had type-checked: ktlint is step 6 and the unit tests are step 8, so the fixture pinning the derived-tint mirror never ran. I checked line width, trailing whitespace and KDoc adjacency before pushing — the three that have bitten before — and not this one. The list of rules learned by failing CI is not the list of rules. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
6f173b166b |
Every note carries a tint, derived from its id
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 11s
CI & Build / Python tests (push) Successful in 9s
CI & Build / integration (push) Successful in 15s
CI & Build / Build & push image (push) Skipped
CI & Build / Python lint (push) Successful in 3s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m33s
Android / Kotlin + Rust (APK) (push) Failing after 6m21s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 7m2s
Desktop (Tauri) / Update manifest (push) Successful in 3s
The board was a wall of white rectangles: `default` is the colour nobody picks, so it was the colour of every note except the two the operator had coloured by hand. Reported twice — 2026-08-23 as "a wall of broken up text", and again today as "all the existing notes are the same dull color". The ask was "random subdued colors", but random is the one thing it must not be. A tint rolled at render time would differ between the phone and the browser and change on every reload. FNV-1a over the note's id is deterministic, identical on every surface, needs no column and no migration, and a note keeps its colour for life — which is what "random" meant here. Two implementations, deliberately mirrored, same discipline as the checklist grammar. The Kotlin half lives in a Compose-free file so a host-JVM test can pin the fixture; the TypeScript half carries the same four ids and hashes as a comment because the frontend has no test runner at all — its whole CI lane is `vue-tsc --noEmit`. That asymmetry is worth naming rather than papering over. A draft has no id yet (DRAFT_ID is ""), so it stays white until it is saved. Hashing the empty string would give every draft one shared tint and then change it on save anyway — two surprises where one will do. An explicitly-picked colour still wins. The picker is on its way out (milestone 309 step 5) but it has not gone yet, and a hand-coloured note changing under the operator would read as data loss. First of five steps toward colour coming from tags. This one stands alone: no storage change, nothing removed, and the board stops being white today. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
f92a3d0a99 |
android: detekt's return limit, on two functions I wrote after it caught me once
`dev` went red on
|
||
|
|
1e2b42af25 |
android: say nothing until the update is downloaded, and only fetch on wifi
Both corrections to what I built, and the second changes the first. NAG ONLY WHEN READY. The banner is now gated on the bytes being on disk. I had it appearing as soon as a build was FOUND, with Install downloading on demand — which turns one tap into an unplanned download, and is exactly the surprise the wifi gate was meant to avoid. Off wifi the app now stays quiet and picks it up later. ONLY ON WIFI, and both halves of that. `isActiveNetworkMetered` alone would download over an unmetered cellular plan, which is not what "on wifi" means. TRANSPORT_WIFI alone would download over a tethered hotspot, which is mobile data wearing a different hat and the precise bill this avoids. It now requires both. Found while making the first change: gating the nag on `ready` broke the nag. The background path returns early once a build is fetched, so `nagDismissed` would never be cleared again and a single "Later" would have silenced the update permanently — the exact "lost" this whole path exists to prevent. Coming forward with a fetched build now clears the dismissal instead of returning. Also: a build found off wifi retries its FETCH on the next foreground rather than waiting out the six-hour check interval. Found on the train, downloaded at home. The banner loses its two-state text with the change, and BoardUpdate loses `ready` — it is implied now. It stays visible while installing, deliberately: that is the one moment it has something to report, and hiding it would look like the tap did nothing. |
||
|
|
a48b034a94 |
android: my insertion stole downloadTarget's doc comment
Anchoring the new function on `fun downloadTarget` put it between that function and its own KDoc — so downloadTarget lost its doc and onUnmeteredNetwork gained a second one describing something else entirely. ktlint caught both halves. Anchor on a declaration and you land inside its documentation. Swept the rest of the tree for the same shape; nothing else. |
||
|
|
ee47a61270 |
android: find updates without being asked, fetch them, then nag
`check()` had exactly one caller: a button on the sync screen. So a new build was found only by someone who went looking for one — and having to remember to go looking is the same as not being told. The operator has been doing that by hand every time. Three parts. FIND. The app checks when it comes forward, which is the moment the person is present. Rate-limited to six hours in the view model, so flicking between two apps is not a re-check, and skipped entirely on an unlinked device — updates come from a linked server and there is nothing to ask. Same ForegroundTransitions shape as AutomaticSync, for the same reason. FETCH. Finding one downloads it, so the nag is a one-tap install rather than the start of a wait. NOT over mobile data: fifty-odd megabytes is a bill nobody agreed to, so this is gated on an unmetered connection (new ACCESS_NETWORK_STATE permission — normal, no prompt). On a metered link the update is still found and still nags; Install downloads it then, which is a choice rather than a surprise. NAG. A banner on the board, under the error banners — an update is worth saying and never worth saying before a note failed to save. "Later" clears it for this sitting only: the next time the app comes forward the check finds the same build and says so again. That is the difference between a reminder and a notice you can lose. downloadAndInstall now skips the download when the background fetch already did it, so the sync screen's button and the banner's are the same action with the same name — whether the bytes are already there is this class's problem, not the person's. |
||
|
|
68f851110f |
android: checklist rows were still 48dp of touch target
Second pass on the same report. Taking the field's own padding off got rows from 57dp to 48dp and the operator said it was still too big — correctly, because 48dp was never the field's, it is Material's minimum touch target and every interactive component gets it. On a checklist that minimum IS the row height. It is the right floor for a control somebody has to find on a screen; it is the wrong one for a box that sits in a predictable column with an identical box directly above and below it, where a near miss ticks the neighbouring item — visible, and undone by tapping again. 36dp, provided to the row rather than hardcoded into the controls, so the checkbox and the delete × move together and nothing else in the app is affected. |
||
|
|
96a6f6e691 |
web: the editor draws the checklist too
CI & Build / integration (push) Successful in 19s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 13s
CI & Build / Build & push image (push) Successful in 38s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m37s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m34s
Desktop (Tauri) / Update manifest (push) Successful in 3s
2992's other half. The browser was the last surface still showing `- [ ] ` as
markup: cards rendered and ticked checkboxes, the editor did not.
Same shape as Android, deliberately. notes/blocks.ts mirrors EditorBlock.kt —
splitBlocks, joinBlocks, afterEnter, withoutIndex, plusTask — because the two
editors should behave alike and the cheapest way to keep them that way is for the
code to read alike. `body` becomes a computed over the blocks, so every save,
baseline check and draft still reads the one markdown string they always did.
markdown.ts now exports parseTaskLine and renderTaskLine, and parseMarkdown uses
the former. The read view and the editor's block split had been matching the same
grammar through two separate copies of one regex; now they agree by construction.
Two places the web can do better than Compose, and does:
* Backspace at the start of an empty item removes it. A browser sends a real
keydown for Backspace; an Android soft keyboard sends an IME delete that never
surfaces as one, which is why that surface only has Enter-on-empty.
* Prose fields size to their text — rows="1" plus a scrollHeight fit, which beats
guessing a row count that is wrong the moment a line wraps.
KNOWN, and the same on both surfaces: typing `- [ ] ` by hand into a prose block
leaves it prose until the note is reopened. Blocks are split when the editor loads,
not re-derived per keystroke — re-splitting mid-type would move the caret. The
toolbar button is the intended path. Converting on blur would fix it and is worth
doing to BOTH editors at once rather than letting them drift.
|
||
|
|
44b3bcb2b2 |
Correct a claim about the operator's data, and the first-row delete
CI & Build / Python lint (push) Successful in 3s
CI & Build / Python tests (push) Successful in 12s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m2s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 8s
CI & Build / integration (push) Successful in 17s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m39s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 8m12s
Two things, one of which I got wrong in a place that outlives the session. **The claim.** Migration 0027's docstring said "The Google Keep import is genuine content on this instance, not fixtures." That is not true and I had no basis for it. Note 2916's headline is the opposite — "there is no work that anyone has done that isn't test data" — and its clause about imports is CONDITIONAL: text arriving from another app would be real, and any import path has to treat it that way. I read a rule about how import code must behave as a fact about what is in the database, then repeated it in a migration that will be read long after anyone remembers this week. The operator has never run the importer. They did not know it existed. Nothing about the migration changes. Content-preserving was cheap and is right for anything that rewrites somebody's text — and it is what the rule will demand the day an import does happen. Only the reason recorded in the file was wrong, and a wrong reason in a migration is how a later decision gets made on a false premise. **The delete.** Removing the FIRST checklist row asked to focus `index - 1`, which is -1, so nothing took focus and the keyboard stayed up over a list with no cursor in it. It now focuses whichever row takes the deleted one's place, which also does the right thing when the deleted row was the only one — `withoutIndex` leaves a fresh empty block behind, and that block is what gets the caret. Found by reading the path the operator said they were about to test, rather than by waiting for them to find it. |
||
|
|
a45a44ef11 |
android: split the block model from the block UI
detekt's TooManyFunctions, at exactly the threshold. Worth taking as the signal it is rather than suppressing: the file held the block MODEL — split a body, join it back, add an item, drop one — and the COMPOSABLES that draw it, which are two jobs that happen to share a data class. EditorBlock.kt keeps the model and is pure: no Compose imports beyond the types it stores, and testable on its own if it ever earns tests. BlockBody.kt keeps the four composables. afterEnter, withoutIndex and nextId become internal, since the UI half calls them across the file boundary now. That is the one cost of the split and it is small — same module, same package, and each says why in its doc. |
||
|
|
56264a9220 |
android: the checklist rows were carrying a form field's padding
Reported from the device with a screenshot: six items took up most of a phone screen. The rows measured ~57dp apart, which is Material's TextField content padding almost exactly — 16dp above the text, 16dp below, around a 24dp line. That padding is right for a form field, where it is the difference between a comfortable target and a fiddly one. On a checklist it IS the row height, so every item was paying for a hit area the checkbox beside it already provides. The editor's blocks drop to BasicTextField. Nothing about the "no box" treatment is lost — PlainTextField exists to strip a container and an indicator, and BasicTextField never had either, so there is nothing here to drift back into existence. What it does not supply and BlockField now does: the text colour, which defaults to Color.Unspecified and draws BLACK (the same default that made the toolbar invisible in dark mode), the cursor brush for the same reason, and the placeholder, which becomes a plain Text behind the field. PlainTextField keeps serving the search box, the label picker and the sync-pairing form — fields where Material's padding is what you want. Its TextFieldValue overload went with the change: the editor was its only caller, and every remaining one passes a String. Rows are now bound by the 48dp checkbox rather than by the field. If that is still looser than it should be, the next lever is the touch targets themselves, which trades against how easy the box is to hit — worth looking at on a device before spending it. |
||
|
|
a88f7c2dd0 |
core: drop the two helpers the block editor made unnecessary
`toggle_at` existed to map a tap on `[ ]` inside a plain text field to an item, and `continuation` to make Return start the next one. The block editor needs neither: a checkbox is a real Checkbox, so it is tapped rather than located, and Return is the field's own IME action rather than a shape recognised in a string. Removed rather than kept for later (rule 22). Both were exported over the FFI with no Kotlin caller, which is API surface promising something nothing does — and their tests were weight on code nothing runs. The section comment above them described the tap-in-a-text-field problem, which is no longer the problem this pair solves. Rewritten to say what is actually there: one function to read a body apart, one to put a line back together, and between them Kotlin renders checkboxes without owning the grammar. |
||
|
|
32ec29fc4a | android: the comment pointed at the file's old name | ||
|
|
ae17b8a8e7 |
android: name the file after the type in it
detekt's MatchingDeclarationName: a file whose only top-level type is EditorBlock has to be EditorBlock.kt. The plural read better as 'the blocks and the machinery around them', but the rule is about the type, and the convention here already works that way — NoteCard.kt holds NoteCard plus its helpers. |
||
|
|
eeca4d48c2 |
android: a trailing blank line where the dead helpers used to be
Removing MIN_BODY_LINES took its declaration but left the newline in front of it, so the file ended with a blank line. My pre-push sweep only looked for consecutive blanks INSIDE a file and could not see one at the end — checked across the whole Kotlin tree this time, not just the files I touched. |
||
|
|
b2435d97b6 |
android: the editor draws the checklist instead of the markup for one
2992. A checklist item is a real Checkbox with its text beside it, so a box can be
ticked while looking at the note — which is what M304 left undone. It changed where
a checklist is STORED and never changed what the editor draws.
The body is split into blocks and joined back on every edit, so the note underneath
is the same markdown string it was this morning. Nothing below the editor can tell
this exists: no migration, no protocol change, no new shape on the wire.
A run of prose lines is ONE block, not one per line. Typing a paragraph has to feel
like typing a paragraph, and a separate field under every sentence would break the
caret mid-sentence. Only a checklist item earns a block, because only a checklist
item needs a widget.
Two things that look like detail and are not:
* A block carries its own TextFieldValue, and an ID that survives insertion.
Compose keys fields by position unless told otherwise, so adding an item would
otherwise move every caret below it up a row. Content cannot be that key —
two empty items are identical and neither is the other.
* Focus is hoisted to the screen rather than kept inside BlockBody, because the
toolbar's checklist button also asks for one. Two owners of one cursor is one
too many.
Return on an item makes the next item and puts the caret in it; on an EMPTY item
the block becomes prose, which is how a list ends and how you get a paragraph after
one — the same rule the plain text field used, now with somewhere to land. It
appends rather than splitting at the caret: splitting an item in two is a rarity,
and the caret is at the end for every ordinary use of that key.
The core gains `render_item` and `DerivedItem.line`; `item_lines` and
`checklist_lines` are gone, subsumed. Every renderer that walks a body line by line
needs the text, the state and the position TOGETHER — asking for them separately is
how two calls come to disagree about a body that changed between them. The card now
reads its items from the body for the same reason, instead of from note.items,
which is the same list by a longer route and one save behind.
WANTS A DEVICE PASS, and the focus behaviours are what to look at: return making a
row and landing in it, return twice at the end of a list getting you a paragraph,
and rotation restoring the right field. CI can only prove this compiles.
|
||
|
|
9a3c4ec377 |
android: ticking a box on a card threw the editor open on top of it
Reported from the device: tapping a checkbox on the board checks it AND opens the note. The "and" is the tell — both things happened, so this was never a tap landing on the wrong target. `mutate` ends with `editing = updated ?: state.editing`. That is right for an editor action, where the reloaded note refreshes a screen already on display. But `editing != null` IS "the editor is up" — it is what MainActivity's `when` selects on — so calling `mutate` from the BOARD, where editing is null, wrote the mutated note into it and opened the editor as a side effect of saving. Fixed at `mutate` rather than at the caller, because the caller was not wrong: any board-initiated mutation would have done this, and toggleItem is simply the first one to exist. It now refreshes an open editor and cannot open a closed one. The comment claimed the narrower behaviour all along — "so an open editor shows its own change" — which is what the code should have been doing and wasn't. |
||
|
|
315c5f19e6 |
android: detekt caught a callback that never reached the cards
Not a style finding. `onToggleItem` was added to BoardScreen's signature and read
inside NoteBoard — which is a separate top-level composable, not a nested one, so
the two were never connected. detekt reported it as an unused parameter; the
compiler would have called it an unresolved reference. Neither had run: Kotlin is
compiled at the "Unit tests" step, which is gated behind detekt, so nothing in this
lane had type-checked the Android changes yet.
Threaded properly now, which is what makes ticking a box from the board actually
work rather than merely appear to.
Two more from reading it again with that in mind:
* `when { item != null -> … onToggleItem(index, …) }` would not have compiled.
Kotlin does not infer that a non-null item implies a non-null index, so the
index stayed `Int?` against an `Int` parameter. Both are in the condition now.
* continueChecklist had six returns against detekt's limit of two. Collapsed to
one `when`, with the two intermediate values guarded on `typedNewline` —
`caret - 1` is only a real index once it is known to be the newline just typed.
|
||
|
|
1a66d9c3a8 |
tests: pin the export against writing every checklist twice
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 12s
CI & Build / integration (push) Successful in 21s
CI & Build / Python tests (push) Successful in 13s
CI & Build / Build & push image (push) Successful in 15s
M304 step 7. The code change landed with the server half — _note_markdown's `if items:` branch went, and the export payload stopped carrying an items array — but neither had a test, and the failure mode is quiet: every list appears twice in an export, then twice again when that export is imported back. Three cases, and the third is the one worth having. An export taken BEFORE this milestone has a body with no task lines and a separate items array, so importing one still has to fold the checklist in. That is the same fold the Keep importer does, and the reason _insert_note still accepts items at all — asymmetric on purpose: the export stopped writing them, the import did not stop reading them. |
||
|
|
77b1a87712 |
android: ktlint on the import order and a leftover blank line
Inserting the checklistLines import after Note split Note from NoteLabel, and removing the checklistOpen state left two blank lines behind it. Both are the formatter only — the bindings built and the Rust lanes were already green. |
||
|
|
68b2a5dc8d |
android: a checklist is lines of the note here too
M304 step 6, and the surface with the least room to hide: Android has no markdown renderer at all, so the card was about to show every list twice — once as literal `- [ ] milk` in the body preview, and again as the glyph rows underneath. Same bug the web had, one commit later. The card now renders the body LINE BY LINE and draws a checkbox where one belongs, which is what puts a list between two paragraphs instead of always after them. The glyphs became tappable while they were being rewritten: ticking something off from the board without opening the note is the common gesture, and the web just gained it. The tap target is the glyph, not the row — tapping the TEXT still opens the note, the way tapping anywhere else on a card does. Kotlin gets no parser. Three implementations of the grammar is the price already paid; a fourth in Compose would be a fourth place for a checklist to change shape when it syncs. So the core exposes three pure functions instead — `checklist_lines`, `checklist_continuation`, `checklist_toggle_at` — and Kotlin does the caret arithmetic around them. Those are FREE functions, not methods, and that is the interesting constraint. The editor's body field is LOCAL state on an idle-debounced autosave, so anything that edits a checklist there has to rewrite the text the field is holding, not a row the store would hand back a moment later. Going through the store would overwrite whatever was being typed. The BOARD has no such problem — nothing there is holding a half-typed body — so the card's toggle goes through the store as usual. `toggle_at` addresses an item by LINE and COLUMN rather than a text offset, because the two sides do not count the same way: Compose measures in UTF-16 units and Rust in bytes, so the same number means different places in a note with an emoji in it. A line number is identical in every encoding, and so is a column inside the marker, which is ASCII at the start of its line. In the editor: the toolbar button inserts `- [ ] ` at the caret — the only toolbar action needing no saved note, so it works on an empty compose box the moment it opens — and Enter continues the list, or ends it on an empty item. Continuation is recognised by SHAPE inside onValueChange (exactly one more character, and it is a newline) rather than by a key event, so a paste or an autocorrect falls through untouched. EditorChecklist.kt and the four item actions are gone (rule 22). Adding, renaming, ticking or deleting an item is editing text now, and the editor already does that — through SaveText, with the same autosave and the same revision window as any other edit. KNOWN GAP, not an oversight: tapping a checkbox inside the EDITOR does nothing yet. Material3's TextField does not expose onTextLayout, so mapping a tap to a character offset means either moving the body to BasicTextField or intercepting pointer events ahead of the field — both real changes to the surface this operator uses most, and neither verifiable without a device. `checklist_toggle_at` lands here, tested, so that task is pure UI. Ticking from the board works today. |
||
|
|
3cab054684 |
web: task lines render as checkboxes where they sit in the note
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 9s
CI & Build / integration (push) Successful in 25s
CI & Build / Build & push image (push) Successful in 41s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m25s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m55s
Desktop (Tauri) / Update manifest (push) Successful in 4s
M304 step 5. The server already returns a derived `items` array, so the web kept working across the last two commits — but it was rendering every list TWICE: once as literal `- [ ] milk` bullets inside the body, and again as the separate NoteChecklist block underneath. This is the commit that makes the body the only place a checklist appears. markdown.ts gains a `task` block, matched BEFORE the plain bullet — which would otherwise swallow the marker and leave the brackets showing, the same ordering reason `code` is matched before emphasis in INLINE_RE. Each item carries its ordinal across the WHOLE document, because that is what an item's id means everywhere else now; counting per block would have made the second list's checkboxes toggle the first list's items. The card's preview clamp is why that ordinal is safe there: it only ever drops lines from the end, so a visible item's index is the same whether or not the body was truncated. MarkdownText emits a toggle rather than reaching for the store. Ticking a box rewrites a line of someone's note, and a renderer used in several places should not be the thing deciding that is allowed — the card passes `toggleable` and wires it, a read-only render does not and the boxes are inert. Not wrapped in a <label> either: on a card the text is the note's own words and clicking it opens the note, so only the box toggles. In the editor, the toolbar button stops revealing a section and inserts `- [ ] ` at the caret. That makes it the one toolbar action needing no persisted note to hang anything off — ensureDraft is gone from it, and it works on an empty compose box the moment it opens. Enter on a task line continues the list, and on an EMPTY one clears the marker; without that second half a list would be impossible to get out of. Indent and bullet are carried over rather than normalised, because continuing someone's `*` list with a `-` is an edit they did not ask for. NoteChecklist.vue is deleted (rule 22). The store's item methods stay: they are the repository seam the REST routes and Tauri commands both implement, not the old path. CI cannot check any of this beyond types — there are no frontend tests, only vue-tsc. It wants a real browser pass. |
||
|
|
fe1f72ae1b |
tests: the display-title tests still passed the argument that went away
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 9s
CI & Build / integration (push) Successful in 18s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build & push image (push) Successful in 30s
Three of them called derive_display_title(body, first_item). I updated the call sites in src/ and not these — the integration lane and the linter both passed, because a stale keyword argument is only a TypeError at the moment it runs. Rewritten rather than deleted. The property the fallback existed to protect is still real — a note that is only a checklist has to have a name — it is just reached differently now: an item IS a body line, so the first one is simply the first line with its marker stripped. The new cases pin the two edges that rule introduces: an empty item must not name a note "", and a list of nothing but empty items still has no name. |
||
|
|
761c3b5e82 |
server: the body is the checklist here too, and note_items is dropped
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Failing after 8s
CI & Build / integration (push) Successful in 20s
CI & Build / Build & push image (push) Skipped
M304 steps 3 and the server half of 4. The client half landed in 668f7fa; these belong in one deploy, and the protocol floor below is what enforces that. notes/checklist.py is the Python half of a grammar that now exists three times — here, core/src/local/derive.rs, and (next) frontend/src/notes/markdown.ts. That triplication is the deliberate cost: the alternative is a round trip to the server before a phone can draw a checkbox. Each copy names the other two, and each is tested against the same table of cases, including the near-misses that must stay prose: `-[ ] x`, `- []`, `- [ ]x`, a `[ ]` mid-sentence. Routes: add/update/delete items stop touching rows and rewrite note.body, all through one _rewrite_body that runs the same sequence the PATCH route runs for a body change — because it IS a body change. Revisions, #tag reconciliation, the name, and link unfurls therefore happen in one place rather than three routes each remembering to. The reorder route is gone (rule 22). Reordering a checklist is moving a line, and no client ever called it — the only reference in the tree was a test asserting the route existed. The API still returns `items`, DERIVED from the body on the way out. That is not a second source of truth and it cannot disagree with the body it came from; it keeps the web client working across the rest of this milestone and saves any consumer that only wants to draw checkboxes from carrying a parser. Export drops its separate items block, in both formats. The body already ends with those exact lines, so writing them again would double every checklist in an export and then double it again on re-import. Import still ACCEPTS items, because a Keep takeout has a list and not a blob; it folds them in before the Note is built, so display_title and _reconcile_tags both see the finished text. Protocol 3 on both sides now. A v2 client is refused rather than half-served — which matters more than I first said: _apply_note_items returned early on an absent `items` key, so an un-bumped v3 client against a v2 server would not have LOST the rows, it would have kept them and then had the migration fold them a second time. Duplicated lists rather than missing ones. The floor prevents both. Migration 0027 folds every existing row into its note's body and drops the table. It inlines its own copy of the fold on purpose — a migration has to keep producing what it produced the day it ran — and a test pins that copy against the app's until they are allowed to diverge. updated_at is deliberately untouched: a client holding an unpushed edit keeps the newer timestamp, so last-write-wins keeps its work instead of the migration silently winning. The downgrade is honest rather than faithful. It recreates an empty note_items and leaves the bodies alone, because once items are lines nothing distinguishes one this migration wrote from one somebody typed, and a downgrade that guessed would eat hand-written lists. Recreating the table is still necessary: 0015's downgrade drops a trigger ON note_items, and IF EXISTS covers the trigger, not the table. |
||
|
|
32dafca148 |
core: what rustfmt actually wanted
Three from the checker's own diff. Two are the rule I had guessed at: when a call overflows and its last argument is a closure, rustfmt keeps the earlier arguments on the line and expands the closure into a block, rather than putting every argument on its own line. The third is a stray double blank line before the test module. |
||
|
|
668f7faf03 |
core: the body is the checklist, and checklist_items is gone
M304 steps 2 and the client half of 4, together — they cannot be separated. A
commit where the store writes items into the body while push.rs still reads them
from a table is one that silently pushes the wrong list, and dev publishes to the
dev channel on every green build.
Store:
* load_items becomes items_of(body) — a parse, not a query. An item's id is its
ORDINAL, which is all it ever amounted to: push.rs sent text and checked and
never an id, and both sides replaced the whole list on every sync.
* add_item / update_item / delete_item route through update_note, so they get
revision snapshotting, #tag re-derivation and the dirty/updated_at bookkeeping
without any of it being written a second time.
* create_note folds its items: input into the body, and syncs tags from the
FOLDED body — an item can carry a #tag too.
* display_title no longer takes items, because items ARE body lines now. It
strips the task marker instead: a list-only note is still named by its first
item, and calling that note "- [ ] milk" would show someone the storage.
Wire: items leave it. A second copy of data already in the body field of the same
message is how the two come to disagree. CLIENT_PROTOCOL_VERSION and
MIN_SERVER_PROTOCOL_VERSION go to 3, which is what makes this safe to land before
the server: a v3 client refuses a v2 server outright rather than pushing a body
whose list the old _apply_note_items would then delete.
Schema v8 folds every existing row into its note's body before dropping the
table. Written in Rust, not SQL: the fold has to produce exactly what
derive::append_item produces, and group_concat only gained a guaranteed ORDER BY
in SQLite 3.44 — a checklist that quietly reordered itself during a migration
would be a poor way to learn that. updated_at and dirty are deliberately left
alone, because the server's migration folds the same rows the same way and both
sides land on identical bodies; marking every note dirty would push a body the
server already has, from every device at once.
NOT deployable yet. The server still speaks v2 and still has note_items, so a
client built from this will refuse to sync until the server half lands.
|
||
|
|
d0e3e48943 |
core: rustfmt splits on fn_call_width, not max_width
Three assertions I had collapsed to one line because they fit inside max_width=100. rustfmt's fn_call_width is 60 and applies to the ARGUMENT list, so a call can sit well under the line limit and still be split vertically. Clippy and the tests were already green; this is the formatter only. |
||
|
|
1045db318b |
core: derive checklist items from the body, the way tags already are
First step of M304. Additive on its own — nothing calls this yet — so it can be read and tested before anything depends on it. A checklist is currently a TABLE, and a table can only ever render after the body, because a row has no idea where in the note it belongs. That is why "inline with the note" is not a styling problem: prose, three checkboxes, then more prose is not expressible at all today. derive.rs already owns "structure derived from body text" for #tags and says so in its module doc. Task lines join it rather than opening a second home for the same idea. The difference between the two is worth stating and now is: tags MATERIALISE into label rows because the board queries by label; items materialise into nothing, because nothing queries them. Their only readers are the card, the editor, and display_title. The grammar is fixed here because three languages will implement it — derive.rs, notes/checklist.py, notes/markdown.ts — and any difference between two of them is a checklist that changes shape when it syncs. `*` is accepted since markdown.ts already takes it for a plain bullet, and a rule that allowed `* item` but not `* [ ] item` would be one nobody could guess. `- [ ]` with nothing after it parses as an empty item: that is what pressing Enter on a list leaves behind, and refusing it would make a half-typed list stop being a list. `- [X]` parses and normalises to lowercase on the first rewrite, so round trips are stable. append_item spaces its output exactly as import_export.py:_note_markdown does. That is not cosmetic — the server migration will fold existing rows into bodies with the same layout, so an export taken before it and one taken after have to agree byte for byte. A stale index is inert rather than fatal: the index comes from a UI that may be a moment behind the store, and a late tap should do nothing rather than panic. |
||
|
|
65af37d159 |
android: a checkmark to leave, and asking for a checklist stops writing a blank one
Two reports from the same device pass. **The exit is a checkmark.** It shipped as the word "Done" one commit ago, on the argument that a tick in a NOTES app reads as a checklist item to anyone who has used one. Overruled by the operator, and the filled treatment is what settles the objection anyway: a tonal button in the note's own colour is plainly a control, where a bare glyph beside a checklist would not be. It carries "Done" as its content description, so the argument survives where it actually mattered — read aloud. **Starting a checklist wrote an empty item**, purely so the section would have something to render. That left a blank row with the always-present add-row beneath it — two empty fields, and the caret in the lower one. Whether a checklist is SHOWING is view state, not a row in the store: the toolbar reveals the section and focuses the add row, and nothing reaches SQLite until an item has words in it. EditorAction.AddChecklist is gone rather than repurposed (rule 22), which makes the first real item the action that can create a body-less note — a note named from its first item, which the core already does. |
||
|
|
8257e1035c |
android: put the way out of a note back within reach
Moving the toolbar to the top took the back arrow with it, and left the only exit from a full-screen editor in the top-left corner — the furthest point on the display from a right-handed thumb, reached over the whole note to get to. Reported on the first device pass, and correctly. So the footer carries a Done as well as the timestamp. With the keyboard up it sits directly above the thumb, which is where a hand already is for every other part of writing a note. The top-left arrow stays. Two affordances for one action is usually clutter, but this is the case that earns it: the arrow is what habit, the system back gesture and TalkBack all expect of a full-screen surface, and removing it would strand the reflex to strike a duplicate costing one icon slot. A word rather than a checkmark, on the same argument the overflow menu makes: a tick in a notes app is a checklist item to anyone who has used one, and "Done" cannot be misread, including aloud. EditorSavedLine is now EditorFooter, since it is no longer only a line. |
||
|
|
bca9e16bd0 | android: ktlint wants that body expression on one line | ||
|
|
9ea2a2f9b6 |
android: the toolbar moves to the top, and the note says when it saved
The capture sheet's drag handle cost a strip of screen and did nothing a back gesture does not already do. The toolbar takes that strip instead, which is where it belonged once one surface served both writing and editing: the keyboard owns the bottom of the display for most of a note's life, so a bar down there spends its time riding on the IME. The bottom is now the answer to "did that land". There is no save button — writes are continuous, so a button offering to do what already happened would be a lie with a tap attached — but that left nothing on screen saying the work was safe. Not saved yet → Saving… → Edited just now is the whole lifecycle in the corner, and someone who watches it once never has to be told that closing a note keeps it. DateUtils formats the relative part, so plurals and "yesterday" are not this app's problem to solve twice. Shape: the screen keeps the sheet's rounded top and its gap below the status bar, so opening a note still reads as something rising over the board. Full height rather than a real ModalBottomSheet — a sheet spends a writing session negotiating with the IME for the bottom half of the display, and the swipe-down it buys is a gesture back already does. Both content colours on the card are spelled out. Surface and Scaffold each default theirs to contentColorFor(their container), which returns Unspecified for anything that is not a colour-scheme role; a note tint never is. That is the same default that made the last toolbar invisible in dark mode, latent in two more places. Also: the running LinearProgressIndicator is gone, since the corner line now says the same thing without moving the text; and the SaveText comment in BoardViewModel still claimed saves happened on close. |
||
|
|
ce6a1093a3 |
android: writing a note and editing one are the same surface
The + button raised a capture sheet with a single text field. The editor is
a screen with a toolbar. So a note being WRITTEN could not be given a
colour, a reminder or a checklist — those live on the toolbar, and the sheet
had none. To make a checklist you wrote a note, saved it, reopened it, and
found a control you had never seen.
ComposeSheet is deleted. + opens the editor on an unsaved draft.
A draft is a real Note carrying DRAFT_ID (the empty string) rather than a
null. Note has eighteen fields and the editor reads eight of them; threading
nullability through all of that to express "not saved yet" would spread the
concept across a screen that should not have to know about it. A real id is
a uuid, so the sentinel cannot collide.
It becomes a row on its first save, and the first save is now an autosave:
the editor writes a second after typing stops. That is affordable because
|
||
|
|
2707054563 |
A write should not cost a revision
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 12s
CI & Build / integration (push) Successful in 19s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m3s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m21s
Desktop (Tauri) / Update manifest (push) Successful in 3s
Android / Kotlin + Rust (APK) (push) Successful in 7m42s
Every body change snapshotted into history — core/src/local/store.rs and notes/__init__.py both — so a write was expensive, and the clients compensated by writing as rarely as they could. BoardViewModel says it outright: "Saved on close rather than per keystroke, so a session of typing costs one write and one revision snapshot." That is durability paying for version history. An app kill mid-session lost everything typed, so that the revision list would stay tidy. The safety property is worth more than the feature it was subsidising, and no comparable product makes this trade: Keep and Apple Notes write continuously with no history, Docs and Notion write continuously and coalesce history behind the scenes, Obsidian debounces and snapshots on an interval. Save-on-close is the outlier, and this coupling is why we had it. A body change now earns a snapshot only if it is the first of an editing session — the body actually differs, and the note carries no revision from the last ten minutes. Session granularity falls out of the window rather than being declared. A snapshot stores the body as it was BEFORE the edit, so the first write of a sitting captures the note as you found it and every write after it inside the window adds nothing. One revision per sitting, with no commit flag for a client to send and no wire surface to carry it. That is why it is a time rule and not a protocol one. sync.py applies pushed bodies through the same check, so a client autosaving every second cannot make the server snapshot every second either — which a client-declared commit point could not have guaranteed without a protocol bump. Restoring a revision still snapshots unconditionally: a considered act, not a keystroke, and it stays undoable. Unblocks idle-debounced autosave, an honest updated_at, and the "Edited just now" line the editor is getting. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
24685556b7 |
android: open an existing note ready to keep writing
Opening a note put no cursor anywhere, so carrying on cost a tap into the body and usually a second one to drag the caret past the existing text. The compose sheet has always focused its field on open; the editor never did, and continuing a note is the more common act of the two. Focus the body on open, caret at the end. Not for a trashed note — that renders read-only and a keyboard over a record you cannot edit is noise. Keyed on note.id so the reused editor re-requests when pointed at a different note. The caret position is why the body state moves from String to TextFieldValue: a String field always starts its selection at offset zero, so focusing one lands the cursor before the first character — the wrong end of a note you meant to continue. PlainTextField gains a TextFieldValue overload for it, and the two overloads share one colours definition rather than growing a second copy of the "no box" treatment this file exists to keep in one place. I recorded this backwards in Scribe 2947 — as the keyboard opening unwanted, when the report was the opposite. The source having no FocusRequester was the tell, and I read it as a mystery instead of as evidence I had the direction wrong. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
50e2d308ea |
android: the editor toolbar was black icons on a near-black bar
Reported as "I'm unable to see a toolbar in the editor on android, is there one?" — and it was rendering the whole time. EditorBottomBar passed containerColor but no contentColor, so Material3 defaulted it to contentColorFor(containerColor). That maps a colour-SCHEME ROLE to its `on-` pair and returns Color.Unspecified for anything else. A note tint is never a role: the default note is 0xFF171717 while the dark scheme's surface is 0xFF0A0A0A. So contentColor resolved to Unspecified, Surface published it as LocalContentColor, Icon took it as its tint, and an unspecified tint applies no colour filter — leaving the icons-core vectors their intrinsic black, on a near-black bar. Every note colour, both themes, only visible in dark. The top bar escaped it because topAppBarColors(containerColor = …) overrides the container and leaves the icon colours at their scheme defaults. Also inset the bar for the keyboard. enableEdgeToEdge makes the manifest's adjustResize a no-op and Scaffold does not inset its bottomBar slot, so the bar would sit under the IME the moment anyone typed — a second way to not see it. imePadding moves to the bar; the content Column drops its own, since Scaffold now measures the bar at its lifted height and the inset reaches the content through innerPadding. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
77c5422951 |
ci: a failing lane must not publish an image
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 9s
CI & Build / integration (push) Successful in 15s
CI & Build / Build & push image (push) Successful in 16s
build gated on lint + typecheck only, so run 4293 failed its test lane and
pushed :dev and :09b5f87 regardless — the deployed server was running a
build whose tests were red.
The comment justified this by saying DB-backed testing happened manually
against the dev image rather than on every push. That was true when it was
written and stopped being true at
|
||
|
|
c851b901df |
The proxy-hops test still read the value from Config
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 8s
CI & Build / Python lint (push) Successful in 3s
CI & Build / integration (push) Successful in 14s
CI & Build / Build & push image (push) Successful in 15s
|