From fa89da1fab5799a9bfa6bf780619563fb1302596 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 28 Aug 2026 14:07:03 -0400 Subject: [PATCH] notes: `color` leaves the model, the wire and all three surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 3 of M315, and the destructive half. Steps 1 and 2 stopped every read of this field: a card is one neutral surface per theme, and the only coloured thing on a board is a tag. What was left was a column written by a picker and read by nothing. Rule 22 — the old path comes out completely. No flag, no fallback, no "override if set". Server: the column, the `?color=` facet, the create/update/serialise paths, the sync assignment, the front-matter line, and Keep's colour map. Alembic 0029 drops it and sweeps `"color"` out of stored saved-filter params — a view that silently filtered on a field the app no longer has would return nothing and never say why. That sweep is Python, not `params::jsonb - 'color'`, because Postgres has no try-cast and one malformed blob would abort a migration that is running over somebody's saved views. `NOTE_COLORS` moves from `models/note.py` to `colors.py`. A palette defined on the model that lost one is an invitation to put the column back; labels still name a colour, so the vocabulary belongs where the normalizer already is. Core: the field, the facet, the `NoteCreateInput`, and every read and write in store/push/pull. Local schema v9 drops the column and does the same saved-filter sweep, guarded on `json_valid` so a corrupt blob loses a key rather than becoming NULL. The uniffi layer drops `NoteEdit::Color` and `NoteDraft.color` with it. Web: `ColorPicker.vue`, the per-card swatch popover and its stylesheet rule, the FilterBar colour row, the facet in the query round-trip, and the colour half of the editor's baseline-and-save. Android: the `ColorSheet`, the `Picker.COLOR` case, the toolbar's swatch dot, `EditorAction.SetColor`. ## The protocol: v4, and the floor deliberately stays at 3 Checked against `compat.rs` and the push handler rather than trusting the `#[serde(default)]` annotation, because the v2 precedent points the other way: v2 dropped `kind` and `title` and DID raise both floors, on the rule that dropping a field a client sends and expects back is breaking. `color` fails the second half of that test. A v3 client reading a v4 note gets `"default"` from its own serde default and draws the colour it derives locally — 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 shows anything wrong. `title` was the note's NAME; this is a field that no longer renders. So `SYNC_PROTOCOL_VERSION` and `CLIENT_PROTOCOL_VERSION` go to 4, and both floors stay at 3. `docs/sync.md` carries the reasoning and the per-version history, and its push example is brought back in line — it still listed `title`, `kind` and `items`, all gone before this. Import stays tolerant: a pre-M315 export or a Keep takeout carrying `color:` imports fine, the key simply read past. Old exports must still import. #3041 Co-Authored-By: Claude Opus 5 --- alembic/versions/0029_drop_note_color.py | 93 +++++++++++++++++++ .../thoughtsync/ui/BoardViewModel.kt | 7 +- .../thoughtsync/ui/EditorAction.kt | 4 - .../thoughtsync/ui/EditorChrome.kt | 17 ---- .../thoughtsync/ui/EditorPickers.kt | 85 +---------------- .../fabledsword/thoughtsync/ui/NoteCard.kt | 8 +- .../thoughtsync/ui/NoteEditorScreen.kt | 16 +--- android/app/src/main/res/values/strings.xml | 1 - android/ffi/src/lib.rs | 7 +- android/ffi/src/models.rs | 14 +-- core/src/local/models.rs | 9 -- core/src/local/schema.rs | 24 ++++- core/src/local/store.rs | 32 +++---- core/src/sync/compat.rs | 16 +++- core/src/sync/pull.rs | 7 +- core/src/sync/push.rs | 29 +++--- core/src/sync/wire.rs | 2 - docs/sync.md | 25 ++++- frontend/src/adapters/repo.ts | 4 +- frontend/src/adapters/rest.ts | 1 - frontend/src/components/ColorPicker.vue | 24 ----- frontend/src/components/FilterBar.vue | 18 ---- frontend/src/components/NoteCard.vue | 63 +------------ frontend/src/components/NoteEditor.vue | 35 +++---- frontend/src/notes/facets.ts | 4 - frontend/src/stores/notes.ts | 17 +--- frontend/src/style.css | 19 ---- src/thoughtsync/colors.py | 36 +++++-- src/thoughtsync/models/note.py | 18 ---- src/thoughtsync/models/saved_filter.py | 5 +- src/thoughtsync/notes/__init__.py | 11 +-- src/thoughtsync/notes/import_export.py | 23 ----- src/thoughtsync/saved_filters.py | 4 +- src/thoughtsync/sync.py | 12 ++- tests/test_notes.py | 24 +++-- tests/test_saved_filters.py | 5 +- 36 files changed, 278 insertions(+), 441 deletions(-) create mode 100644 alembic/versions/0029_drop_note_color.py delete mode 100644 frontend/src/components/ColorPicker.vue diff --git a/alembic/versions/0029_drop_note_color.py b/alembic/versions/0029_drop_note_color.py new file mode 100644 index 0000000..bf0e2e3 --- /dev/null +++ b/alembic/versions/0029_drop_note_color.py @@ -0,0 +1,93 @@ +"""drop notes.color — a card is one neutral surface, colour lives on the tag + +Revision ID: 0029 +Revises: 0028 +Create Date: 2026-08-28 + +M315 step 3. A note's colour was set by a picker and read by three card renderers. +Steps 1 and 2 stopped every one of those reads: the card is one neutral per theme and +the only coloured thing on a board is a tag. This drops the column that nothing has +been reading since, and the picker goes with it. + +`labels.color` is untouched. That is the colour that survived, and the one the whole +milestone was about keeping. + +## What is lost, and why that is the change rather than a cost of it + +Any colour a note was explicitly given. There is nowhere to preserve it TO — the field +it would be preserved in is the one being dropped — and nothing renders it, so a +preserved value would be a column kept warm for a feature that was deliberately +removed. A note that had a colour now takes its identity from its tags, which is what +the operator asked for: "strip color from the cards ... and keep the color for tags +just on the tag." + +The palette itself is not lost. `NOTE_COLORS` moved from `models/note.py` to +`colors.py` in the same change — labels still name a colour, and leaving the vocabulary +defined on the model that lost one would be an invitation to put the column back. + +## The saved-filter sweep is not optional + +`saved_filters.params` is opaque JSON mirroring the `GET /api/notes` facet query, and +a stored view could carry `"color": "teal"`. With the facet gone that key would sit +there forever, and `clean_params` only guards what is written FROM here on. A view that +silently filters on a field the app no longer has is worse than one that visibly lost a +criterion, so the stored rows are swept too. + +Done in Python rather than as `params::jsonb - 'color'`, deliberately. Postgres has no +try-cast: one malformed blob would abort the whole migration, and these rows are +somebody's saved views. `json.loads` in a try/except lets a corrupt row keep whatever it +holds and lets every other row be fixed. + +## Search is not affected + +`notes.search_vector` is a stored generated column over `display_title` and `body` +(rebuilt in 0026). It never named `color`, so unlike the title drop there is nothing +here to tear down and recreate. + +## Downgrade + +Restores the column, empty, at its old default. The values are not recoverable — see +above. It is the schema that comes back, not the data. +""" +import json + +from alembic import op +import sqlalchemy as sa + +revision = "0029" +down_revision = "0028" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.drop_column("notes", "color") + + bind = op.get_bind() + rows = bind.execute( + sa.text("SELECT id, params FROM saved_filters WHERE params LIKE '%color%'") + ).fetchall() + for sf_id, params in rows: + try: + parsed = json.loads(params) + except (ValueError, TypeError): + # A blob that does not parse cannot be edited safely. Leaving it is + # correct: it was already unreadable by the app, and this migration is not + # the place to decide what it should have said. + continue + if not isinstance(parsed, dict) or "color" not in parsed: + continue + parsed.pop("color") + bind.execute( + sa.text("UPDATE saved_filters SET params = :p WHERE id = :id"), + {"p": json.dumps(parsed), "id": sf_id}, + ) + + +def downgrade() -> None: + # Comes back at the default every note would have had anyway. Which notes once + # carried a chosen colour is not recorded anywhere after the upgrade. + op.add_column( + "notes", + sa.Column("color", sa.Text(), nullable=False, server_default="default"), + ) diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/BoardViewModel.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/BoardViewModel.kt index 6d3e2ac..f9f4002 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/BoardViewModel.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/BoardViewModel.kt @@ -354,7 +354,6 @@ class BoardViewModel( is EditorAction.SaveText -> mutate { it.updateNote(id, listOf(NoteEdit.Body(action.body))) } - is EditorAction.SetColor -> edit(id, NoteEdit.Color(action.color)) // Pinning re-sorts the board rather than emptying it, and on a phone // you often pin while still reading — so unlike the three below, it @@ -511,9 +510,6 @@ class BoardViewModel( } } -/** The palette key a note starts on, matching the web and the desktop. */ -private const val DEFAULT_COLOR = "default" - // ── pure builders ─────────────────────────────────────────────────────────── // // Neither of these reads or writes view-model state; they only shape a core input @@ -529,7 +525,7 @@ private fun draft(content: String): NoteDraft = // The core names the note from the body's first line, so a captured thought is // findable without anyone being asked to name it. A checklist is added afterwards, // in the editor — it is something a note HAS, not a different thing to capture. - NoteDraft(body = content, color = DEFAULT_COLOR, items = null) + NoteDraft(body = content, items = null) /** * The id a note has before it has been saved. @@ -545,7 +541,6 @@ private fun blankDraft(): Note = id = DRAFT_ID, displayTitle = "", body = "", - color = DEFAULT_COLOR, position = 0, pinned = false, archived = false, diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/EditorAction.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/EditorAction.kt index 74182a9..93acab9 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/EditorAction.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/EditorAction.kt @@ -25,10 +25,6 @@ sealed interface EditorAction { val body: String, ) : EditorAction - data class SetColor( - val color: String, - ) : EditorAction - data class SetPinned( val pinned: Boolean, ) : EditorAction diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/EditorChrome.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/EditorChrome.kt index 5248d1a..266fdbd 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/EditorChrome.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/EditorChrome.kt @@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack @@ -76,9 +75,6 @@ import com.fabledsword.thoughtsync.core.Note fun EditorTopBar( note: Note, readOnly: Boolean, - /** What the colour PICKER is set to — the swatch dot's fill, and nothing else. - * Since M315 no surface paints with a note's colour; see [noteCardSurface]. */ - picked: NoteTint, onClose: () -> Unit, onStartChecklist: () -> Unit, onPicker: (Picker) -> Unit, @@ -101,18 +97,6 @@ fun EditorTopBar( }, actions = { if (!readOnly) { - // A dot in the picker's CURRENT colour rather than a palette icon: it - // shows what the setting is as well as what the button does. - IconButton(onClick = { onPicker(Picker.COLOR) }) { - Box( - modifier = - Modifier - .size(SWATCH_DOT) - .clip(CircleShape) - .background(picked.chipBackground(dark)) - .border(1.dp, picked.border(dark), CircleShape), - ) - } IconButton(onClick = { onPicker(Picker.REMINDER) }) { Icon( Icons.Filled.Notifications, @@ -410,6 +394,5 @@ fun EditorReminderRow( } } -private val SWATCH_DOT = 22.dp private const val SNOOZE_HOUR = 60L private const val SNOOZE_DAY = 1440L diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/EditorPickers.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/EditorPickers.kt index 1536dc0..8a74801 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/EditorPickers.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/EditorPickers.kt @@ -1,11 +1,7 @@ package com.fabledsword.thoughtsync.ui -import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.clickable -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth @@ -13,21 +9,16 @@ import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Check import androidx.compose.material3.AlertDialog import androidx.compose.material3.Checkbox import androidx.compose.material3.DatePicker import androidx.compose.material3.DatePickerDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip -import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Text @@ -42,7 +33,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp @@ -57,80 +47,17 @@ import java.time.LocalTime import java.time.ZoneId import java.time.temporal.TemporalAdjusters -// The three things you pick rather than type: a colour, a set of labels, a time. +// The two things you pick rather than type: a set of labels, and a time. +// +// It was three. The colour sheet went with `note.color` in M315 — a card is one neutral +// surface now and colour lives on the tag, so the swatch grid was a control with nothing +// behind it. // // All bottom sheets rather than dialogs. A dialog takes the middle of the screen // and asks to be dismissed; a sheet rises from the bottom, under the thumb, with // the note still visible above it — which matters when the choice you are making // is about the thing you are looking at. -/** The note palette, as swatches. Order and colours come from [NOTE_TINTS]. */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun ColorSheet( - selected: String, - onPick: (String) -> Unit, - onDismiss: () -> Unit, -) { - val dark = isSystemInDarkTheme() - ModalBottomSheet(onDismissRequest = onDismiss) { - Column( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .navigationBarsPadding(), - ) { - SheetTitle(R.string.color_picker_title) - // Chunked into fixed rows rather than a flow layout: ten swatches - // always lay out as two rows of five on every phone width, and a flow - // would reshuffle them between devices for no gain. - NOTE_TINTS.entries.chunked(SWATCHES_PER_ROW).forEach { row -> - Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp), - horizontalArrangement = Arrangement.SpaceEvenly, - ) { - row.forEach { (key, tint) -> - Box( - contentAlignment = Alignment.Center, - modifier = - Modifier - .size(SWATCH_SIZE) - .clip(CircleShape) - .background(tint.background(dark)) - .border( - // The selected swatch gets a heavier ring - // as well as a tick: on the pale tints the - // tick alone is nearly invisible. - if (key == selected) 2.dp else 1.dp, - if (key == selected) { - MaterialTheme.colorScheme.primary - } else { - tint.border(dark) - }, - CircleShape, - ).clickable(onClickLabel = tint.label) { onPick(key) }, - ) { - if (key == selected) { - Icon( - Icons.Filled.Check, - contentDescription = tint.label, - modifier = Modifier.size(18.dp), - ) - } - } - } - // Pad a short final row so its swatches line up with the row - // above instead of spreading across the full width. - repeat(SWATCHES_PER_ROW - row.size) { - Box(modifier = Modifier.size(SWATCH_SIZE)) - } - } - } - } - } -} - /** * Every label, ticked where it is on the note. * @@ -461,8 +388,6 @@ private val RECURRENCE_RULES: List> = "yearly" to R.string.recurrence_yearly, ) -private const val SWATCHES_PER_ROW = 5 private const val EVENING_HOUR = 18 private const val MORNING_HOUR = 8 -private val SWATCH_SIZE = 44.dp private val LABEL_LIST_MAX_HEIGHT = 320.dp diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteCard.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteCard.kt index 46eab4f..26ff0fa 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteCard.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteCard.kt @@ -179,10 +179,10 @@ fun NoteCard( * 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 deliberately absent**, though #2946 suggested it. `note.color` is - * scheduled for removal along with the whole picker (#3041, the last step of M309) — - * colour comes from the note's tags now. Building a swatch row here would be building - * the one control in this menu already known to be coming out. + * **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. diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteEditorScreen.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteEditorScreen.kt index 45622e3..e3c9ebd 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteEditorScreen.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteEditorScreen.kt @@ -59,10 +59,6 @@ fun NoteEditorScreen( onAction: (EditorAction) -> Unit, ) { val dark = isSystemInDarkTheme() - // The colour the PICKER is set to, which since M315 is all `note.color` still is: - // nothing paints with it any more. It feeds the swatch dot so the control shows its - // own state; both go together in #3041. - val picked = noteTint(note.color) // Keyed by the SESSION, not by note.id: the editor is reused across notes, so it // needs a key — but a draft's id changes the moment it is first saved, and @@ -171,7 +167,6 @@ fun NoteEditorScreen( EditorTopBar( note = note, readOnly = readOnly, - picked = picked, onClose = leave, onStartChecklist = { val (next, id) = blocks.plusTask() @@ -273,7 +268,7 @@ fun NoteEditorScreen( } /** Which overlay is open. One at a time, so they cannot stack on a phone screen. */ -enum class Picker { NONE, COLOR, LABELS, REMINDER } +enum class Picker { NONE, LABELS, REMINDER } /** The pickers, hoisted out so the screen above reads as a layout rather than a switch. */ @Composable @@ -287,15 +282,6 @@ private fun EditorOverlays( val dismiss = { onPicker(Picker.NONE) } when (picker) { Picker.NONE -> Unit - Picker.COLOR -> - ColorSheet( - selected = note.color, - onPick = { - onAction(EditorAction.SetColor(it)) - dismiss() - }, - onDismiss = dismiss, - ) Picker.LABELS -> LabelSheet( note = note, diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 2a4af2d..ec1c943 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -67,7 +67,6 @@ Delete - Color Labels Type a label and press enter from #tag diff --git a/android/ffi/src/lib.rs b/android/ffi/src/lib.rs index c803cff..a293ad3 100644 --- a/android/ffi/src/lib.rs +++ b/android/ffi/src/lib.rs @@ -617,7 +617,6 @@ mod tests { fn draft(body: &str) -> NoteDraft { NoteDraft { body: body.to_string(), - color: "default".to_string(), items: None, } } @@ -671,8 +670,7 @@ mod tests { let created = app .create_note(NoteDraft { body: String::new(), - color: "default".to_string(), - items: Some(vec!["milk".to_string(), "eggs".to_string()]), + items: Some(vec!["milk".to_string(), "eggs".to_string()]), }) .expect("create should succeed"); assert_eq!(created.display_title, "milk"); @@ -707,8 +705,7 @@ mod tests { let note = app .create_note(NoteDraft { body: "Packing".to_string(), - color: "default".to_string(), - items: Some(vec!["socks".to_string()]), + items: Some(vec!["socks".to_string()]), }) .expect("create"); assert_eq!(note.items.len(), 1); diff --git a/android/ffi/src/models.rs b/android/ffi/src/models.rs index ff4d493..aacf8f0 100644 --- a/android/ffi/src/models.rs +++ b/android/ffi/src/models.rs @@ -33,7 +33,6 @@ pub struct Note { /// Always present. Derived by the core, never stored. pub display_title: String, pub body: String, - pub color: String, pub position: i64, pub pinned: bool, pub archived: bool, @@ -187,7 +186,6 @@ impl From for Note { id, display_title, body, - color, position, pinned, archived, @@ -206,7 +204,6 @@ impl From for Note { id, display_title, body, - color, position, pinned, archived, @@ -344,7 +341,6 @@ pub struct NoteQuery { #[derive(Debug, Clone, uniffi::Record)] pub struct NoteFacets { pub q: Option, - pub color: Option, pub label: Option>, pub has_reminder: Option, pub has_attachment: Option, @@ -373,7 +369,6 @@ impl From for core_models::Facets { fn from(value: NoteFacets) -> Self { let NoteFacets { q, - color, label, has_reminder, has_attachment, @@ -382,7 +377,6 @@ impl From for core_models::Facets { } = value; core_models::Facets { q, - color, label, has_reminder, has_attachment, @@ -396,8 +390,6 @@ impl From for core_models::Facets { #[derive(Debug, Clone, uniffi::Record)] pub struct NoteDraft { pub body: String, - /// "default" unless the user picked a colour. - pub color: String, /// Checklist lines. A note can carry both a body and items (M13 step 2), so this /// is not an alternative to `body` — it is an addition to it. pub items: Option>, @@ -405,8 +397,8 @@ pub struct NoteDraft { impl From for core_models::NoteCreateInput { fn from(value: NoteDraft) -> Self { - let NoteDraft { body, color, items } = value; - core_models::NoteCreateInput { body, color, items } + let NoteDraft { body, items } = value; + core_models::NoteCreateInput { body, items } } } @@ -421,7 +413,6 @@ impl From for core_models::NoteCreateInput { #[derive(Debug, Clone, uniffi::Enum)] pub enum NoteEdit { Body { value: String }, - Color { value: String }, Pinned { value: bool }, Archived { value: bool }, RemindAt { value: String }, @@ -441,7 +432,6 @@ impl NoteEdit { use serde_json::Value; match self { NoteEdit::Body { value } => ("body", Value::String(value)), - NoteEdit::Color { value } => ("color", Value::String(value)), NoteEdit::Pinned { value } => ("pinned", Value::Bool(value)), NoteEdit::Archived { value } => ("archived", Value::Bool(value)), NoteEdit::RemindAt { value } => ("remind_at", Value::String(value)), diff --git a/core/src/local/models.rs b/core/src/local/models.rs index 3b5b1d0..97234b6 100644 --- a/core/src/local/models.rs +++ b/core/src/local/models.rs @@ -13,7 +13,6 @@ pub struct Note { /// never stored. pub display_title: String, pub body: String, - pub color: String, pub position: i64, pub pinned: bool, pub archived: bool, @@ -121,16 +120,10 @@ pub struct User { pub is_admin: bool, } -fn default_color() -> String { - "default".to_string() -} - #[derive(Deserialize)] pub struct NoteCreateInput { #[serde(default)] pub body: String, - #[serde(default = "default_color")] - pub color: String, #[serde(default)] pub items: Option>, } @@ -154,8 +147,6 @@ pub struct Facets { #[serde(default)] pub q: Option, #[serde(default)] - pub color: Option, - #[serde(default)] pub label: Option>, #[serde(default)] pub has_reminder: Option, diff --git a/core/src/local/schema.rs b/core/src/local/schema.rs index 943e0d2..c16ec6c 100644 --- a/core/src/local/schema.rs +++ b/core/src/local/schema.rs @@ -15,7 +15,7 @@ CREATE TABLE notes ( id TEXT PRIMARY KEY, title TEXT, body TEXT NOT NULL DEFAULT '', - color TEXT NOT NULL DEFAULT 'default', + color TEXT NOT NULL DEFAULT 'default', -- dropped in v9; kept so DROP COLUMN has something to drop kind TEXT NOT NULL DEFAULT 'text', -- dropped in v6; kept so DROP COLUMN has something to drop position INTEGER NOT NULL DEFAULT 0, pinned INTEGER NOT NULL DEFAULT 0, @@ -250,6 +250,24 @@ fn migrate_v8(conn: &Connection) -> rusqlite::Result<()> { } /// Bring the database up to the latest schema. Idempotent. +// v9 (M315): `notes.color` is gone. A card is one neutral surface now and colour lives +// only on a tag, so the column was written by a picker nothing read and read by nothing +// at all. `labels.color` is untouched — that is the colour that survived. +// +// The saved-filter sweep is the second half and not optional. `params` is opaque JSON +// and a stored view could carry `"color": "teal"`; with the facet gone that key would +// sit there forever, and a view that silently filters on a field the app no longer has +// is worse than one that lost a criterion. Guarded on `json_valid` because a corrupt +// blob must lose a key, not become NULL. +const SCHEMA_V9: &str = r#" +ALTER TABLE notes DROP COLUMN color; + +UPDATE saved_filters + SET params = json_remove(params, '$.color') + WHERE json_valid(params) + AND json_extract(params, '$.color') IS NOT NULL; +"#; + pub fn migrate(conn: &Connection) -> rusqlite::Result<()> { conn.execute_batch("PRAGMA foreign_keys = ON;")?; let version: i64 = conn.query_row("PRAGMA user_version", [], |r| r.get(0))?; @@ -285,6 +303,10 @@ pub fn migrate(conn: &Connection) -> rusqlite::Result<()> { migrate_v8(conn)?; conn.execute_batch("PRAGMA user_version = 8;")?; } + if version < 9 { + conn.execute_batch(SCHEMA_V9)?; + conn.execute_batch("PRAGMA user_version = 9;")?; + } Ok(()) } diff --git a/core/src/local/store.rs b/core/src/local/store.rs index d71d7ad..ccdffe3 100644 --- a/core/src/local/store.rs +++ b/core/src/local/store.rs @@ -141,7 +141,7 @@ fn load_previews(conn: &Connection, note_id: &str) -> rusqlite::Result rusqlite::Result { let mut note = conn.query_row( - "SELECT id, body, color, position, pinned, archived, trashed, remind_at, recurrence, created_at, updated_at, trashed_at + "SELECT id, body, position, pinned, archived, trashed, remind_at, recurrence, created_at, updated_at, trashed_at FROM notes WHERE id = ?1", [id], |r| { @@ -150,14 +150,13 @@ fn load_note(conn: &Connection, id: &str) -> rusqlite::Result { id: r.get(0)?, display_title: String::new(), // filled below — it may need a query body, - color: r.get(2)?, - position: r.get(3)?, - pinned: r.get(4)?, - archived: r.get(5)?, - trashed: r.get(6)?, - deleted_at: r.get(11)?, - remind_at: r.get(7)?, - recurrence: r.get(8)?, + position: r.get(2)?, + pinned: r.get(3)?, + archived: r.get(4)?, + trashed: r.get(5)?, + deleted_at: r.get(10)?, + remind_at: r.get(6)?, + recurrence: r.get(7)?, labels: Vec::new(), items: Vec::new(), attachments: Vec::new(), @@ -327,10 +326,6 @@ pub fn list_notes(conn: &Connection, q: &ListQuery) -> rusqlite::Result rusqlite::Resu } } conn.execute( - "INSERT INTO notes (id, body, color, position, created_at, updated_at, dirty) - VALUES (?1, ?2, ?3, ?4, ?5, ?5, 1)", - params![id, body, input.color, position, ts], + "INSERT INTO notes (id, body, position, created_at, updated_at, dirty) + VALUES (?1, ?2, ?3, ?4, ?4, 1)", + params![id, body, position, ts], )?; // The FOLDED body, not the input one: an item can carry a #tag too. lift_and_sync_tags(conn, &id, &body)?; @@ -508,11 +503,6 @@ pub fn update_note(conn: &Connection, id: &str, changes: &Value) -> rusqlite::Re )?; lift_and_sync_tags(conn, id, body)?; } - "color" => { - if let Some(s) = v.as_str() { - conn.execute("UPDATE notes SET color = ?1 WHERE id = ?2", params![s, id])?; - } - } "pinned" => { if let Some(b) = v.as_bool() { conn.execute("UPDATE notes SET pinned = ?1 WHERE id = ?2", params![b, id])?; diff --git a/core/src/sync/compat.rs b/core/src/sync/compat.rs index 46a2f52..d2cf77a 100644 --- a/core/src/sync/compat.rs +++ b/core/src/sync/compat.rs @@ -19,10 +19,24 @@ use serde::{Deserialize, Serialize}; /// The sync wire protocol this client speaks. -pub const CLIENT_PROTOCOL_VERSION: u32 = 3; +/// +/// v4 (M315): `color` left the note. NOT a floor raise on either side — see the note +/// on [`MIN_SERVER_PROTOCOL_VERSION`]. +pub const CLIENT_PROTOCOL_VERSION: u32 = 4; /// The oldest server protocol this client can drive — the symmetric half of the /// server's `min_client_protocol_version`. +/// +/// STAYS AT 3 ACROSS v4, and the v2 precedent is the reason to say why rather than +/// leave it looking like an oversight. v2 dropped `kind` and `title` and DID move both +/// floors, on the rule that "dropping a field a client sends and expects back is +/// breaking". `color` fails that test on the second half: a v3 client reading a v4 +/// server gets `"default"` from serde's default and draws the colour it derives +/// locally, which is a board that looks exactly like the one it drew yesterday. A v3 +/// client PUSHING `color` to a v4 server has the key ignored — the server reads its +/// payload key by key and never validates the shape. Neither direction errors, and +/// neither loses anything a person can see; `title` was the note's NAME, and this is a +/// field that no longer renders anywhere. pub const MIN_SERVER_PROTOCOL_VERSION: u32 = 3; /// Capabilities without which syncing is meaningless, so their absence BLOCKS the diff --git a/core/src/sync/pull.rs b/core/src/sync/pull.rs index 103b51f..abdace2 100644 --- a/core/src/sync/pull.rs +++ b/core/src/sync/pull.rs @@ -240,13 +240,12 @@ fn upsert_note(conn: &Connection, note: &wire::Note) -> rusqlite::Result<()> { // `created_at` is deliberately absent from the UPDATE clause: a note's birth time // never changes, and the server's copy is the same value anyway. conn.execute( - "INSERT INTO notes (id, body, color, position, pinned, archived, + "INSERT INTO notes (id, body, position, pinned, archived, trashed, remind_at, recurrence, created_at, updated_at, sync_revision, trashed_at, dirty) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, 0) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, 0) ON CONFLICT(id) DO UPDATE SET body = excluded.body, - color = excluded.color, position = excluded.position, pinned = excluded.pinned, archived = excluded.archived, @@ -260,7 +259,6 @@ fn upsert_note(conn: &Connection, note: &wire::Note) -> rusqlite::Result<()> { params![ note.id, note.body, - note.color, note.position, note.pinned, note.archived, @@ -463,7 +461,6 @@ mod tests { wire::Note { id: id.to_string(), body: "Body".into(), - color: "default".into(), position: 0, pinned: false, archived: false, diff --git a/core/src/sync/push.rs b/core/src/sync/push.rs index d5858ce..535d18d 100644 --- a/core/src/sync/push.rs +++ b/core/src/sync/push.rs @@ -64,6 +64,8 @@ pub struct Change { #[serde(skip_serializing_if = "Option::is_none")] pub body: Option, + /// A LABEL's colour. A note has none since M315, so a note change leaves this + /// `None` and the key never reaches the wire. #[serde(skip_serializing_if = "Option::is_none")] pub color: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -220,7 +222,6 @@ fn collect_notes(conn: &Connection, out: &mut Vec, limit: usize) -> rusq /// field-to-column mapping stays readable at the call site. struct NoteRow { body: String, - color: String, position: i64, pinned: bool, archived: bool, @@ -233,22 +234,21 @@ struct NoteRow { fn note_row(conn: &Connection, id: &str) -> rusqlite::Result { conn.query_row( - "SELECT body, color, position, pinned, archived, trashed, + "SELECT body, position, pinned, archived, trashed, remind_at, recurrence, created_at, updated_at FROM notes WHERE id = ?1", params![id], |r| { Ok(NoteRow { body: r.get(0)?, - color: r.get(1)?, - position: r.get(2)?, - pinned: r.get::<_, i64>(3)? != 0, - archived: r.get::<_, i64>(4)? != 0, - trashed: r.get::<_, i64>(5)? != 0, - remind_at: r.get(6)?, - recurrence: r.get(7)?, - created_at: r.get(8)?, - updated_at: r.get(9)?, + position: r.get(1)?, + pinned: r.get::<_, i64>(2)? != 0, + archived: r.get::<_, i64>(3)? != 0, + trashed: r.get::<_, i64>(4)? != 0, + remind_at: r.get(5)?, + recurrence: r.get(6)?, + created_at: r.get(7)?, + updated_at: r.get(8)?, }) }, ) @@ -275,7 +275,8 @@ fn note_change(conn: &Connection, id: &str) -> rusqlite::Result { // server's last-write-wins comparison runs against. edited_at: row.updated_at, body: Some(row.body), - color: Some(row.color), + // A note has no colour to send. See the field on `Change`. + color: None, pinned: Some(row.pinned), archived: Some(row.archived), trashed: Some(row.trashed), @@ -497,9 +498,9 @@ mod tests { fn seed_note(conn: &Connection, id: &str, dirty: i64) { conn.execute( - "INSERT INTO notes (id, body, color, position, pinned, archived, + "INSERT INTO notes (id, body, position, pinned, archived, trashed, created_at, updated_at, sync_revision, dirty) - VALUES (?1, 'B', 'default', 0, 0, 0, 0, + VALUES (?1, 'B', 0, 0, 0, 0, '2026-07-26T00:00:00.000Z', '2026-07-26T00:00:00.000Z', 3, ?2)", params![id, dirty], ) diff --git a/core/src/sync/wire.rs b/core/src/sync/wire.rs index be4f930..8ad2d77 100644 --- a/core/src/sync/wire.rs +++ b/core/src/sync/wire.rs @@ -25,8 +25,6 @@ pub struct Note { pub id: String, #[serde(default)] pub body: String, - #[serde(default = "default_color")] - pub color: String, #[serde(default)] pub position: i64, #[serde(default)] diff --git a/docs/sync.md b/docs/sync.md index 709c81f..d9e6e46 100644 --- a/docs/sync.md +++ b/docs/sync.md @@ -52,6 +52,15 @@ syncs everything else. ### The policy - **Any wire change** → bump `SYNC_PROTOCOL_VERSION`. + - v2 (M13): `kind` and `title` left the wire; **floor raised**, because a v1 + client kept pushing both and read back notes carrying neither — and `title` was + the note's NAME, so an old client showed nameless notes. + - v3: attachments/tombstones/revisions. + - v4 (M315): `color` left the note; **floor NOT raised**. Both directions degrade + in silence and neither loses anything visible — an old client reading a v4 note + falls back to the colour it derives locally, and one pushing `color` has the key + ignored. The test is not "did a field leave" but "does either side end up + showing something wrong". - **Additive change** (a new field, a new capability) → add a `sync_features` name. Do **not** raise a minimum. Old clients keep working. - **Breaking change only** → raise `MIN_CLIENT_PROTOCOL_VERSION` (or the client's @@ -190,18 +199,24 @@ Body: `{ "changes": [ ... ] }` (max 1000 per batch). Each change: ```json { "entity": "note", "id": "", "op": "upsert", "edited_at": "", - "title": "...", "body": "...", "color": "blue", "kind": "text", + "body": "...", "pinned": false, "archived": false, "trashed": false, "remind_at": null, - "position": 0, "items": [ {"text": "...", "checked": false} ], + "recurrence": null, "position": 0, "label_ids": ["", ...], "created_at": "" } ``` - **Client-generated ids.** Notes/labels are UUIDs; the client mints the id when it creates the row offline and sends it here. Create-if-absent, else update. - **Whole-note semantics.** A note upsert carries the client's *full* current - state (not a partial patch) — the server overwrites all scalar fields, replaces - items, and sets manual label memberships from `label_ids` (tag-sourced labels - are re-derived from the body). `#tags` are recomputed server-side. + state (not a partial patch) — the server overwrites all scalar fields and sets + manual label memberships from `label_ids` (tag-sourced labels are re-derived + from the body). `#tags` are recomputed server-side. A checklist is `- [ ] ` lines + inside `body` (M304), so there is no separate `items` array. +- **Fields a change may still carry, and the server reads past.** `title` and + `kind` (removed in v2), `items` (M304) and `color` (v4, M315). The server reads + its payload key by key and never validates the shape, which is exactly what lets + an older client keep pushing a field this one has stopped storing — see the + version policy above for why none of those needed a floor raise on their own. - **`op: "delete"`** purges (tombstones) the row. Trashing is just an upsert with `trashed: true`. - **Labels:** `{entity: "label", op: "upsert"|"delete", id, edited_at, name, diff --git a/frontend/src/adapters/repo.ts b/frontend/src/adapters/repo.ts index 3573952..66be22e 100644 --- a/frontend/src/adapters/repo.ts +++ b/frontend/src/adapters/repo.ts @@ -9,7 +9,6 @@ // consume. Client-side logic (list reconciliation, optimistic updates, toasts) // stays in the stores — the repo is data access only. -import type { NoteColor } from "../notes/colors"; import type { Note, NoteFacets, NoteView, NoteRevision } from "../stores/notes"; import type { Label } from "../stores/labels"; import type { SavedFilter } from "../stores/savedFilters"; @@ -33,13 +32,12 @@ export interface NoteListQuery { export interface NoteCreateInput { body: string; - color: NoteColor; items?: string[]; } // The mutable subset of a note (PATCH /api/notes/:id). export type NoteChanges = Partial< - Pick + Pick >; export interface ChecklistItemChanges { diff --git a/frontend/src/adapters/rest.ts b/frontend/src/adapters/rest.ts index 12efa73..651197a 100644 --- a/frontend/src/adapters/rest.ts +++ b/frontend/src/adapters/rest.ts @@ -31,7 +31,6 @@ function notesQuery(q: NoteListQuery): string { if (q.labelId) params.append("label", q.labelId); for (const id of q.facets?.label ?? []) if (id) params.append("label", id); if (q.facets?.q) params.set("q", q.facets.q); - if (q.facets?.color) params.set("color", q.facets.color); if (q.facets?.has_reminder) params.set("has_reminder", "true"); if (q.facets?.has_attachment) params.set("has_attachment", "true"); if (q.facets?.created_after) params.set("created_after", q.facets.created_after); diff --git a/frontend/src/components/ColorPicker.vue b/frontend/src/components/ColorPicker.vue deleted file mode 100644 index c6fce03..0000000 --- a/frontend/src/components/ColorPicker.vue +++ /dev/null @@ -1,24 +0,0 @@ - - - diff --git a/frontend/src/components/FilterBar.vue b/frontend/src/components/FilterBar.vue index 9adcbd0..f5f6839 100644 --- a/frontend/src/components/FilterBar.vue +++ b/frontend/src/components/FilterBar.vue @@ -7,7 +7,6 @@ import { useUiStore } from "../stores/ui"; import type { NoteFacets } from "../stores/notes"; import { facetCount, facetsFromQuery, facetsToQuery } from "../notes/facets"; import { addLocalDays, formatLocalDay, parseLocalDate } from "../notes/datetime"; -import { NOTE_COLOR_KEYS, NOTE_COLOR_LABELS, NOTE_SWATCH_CLASSES, type NoteColor } from "../notes/colors"; import Icon from "./Icon.vue"; // A dead-simple facet bar over the board: color + labels + has-reminder @@ -32,9 +31,6 @@ function patch(p: Partial) { function clearAll() { void router.replace({ path: "/", query: {} }); } -function setColor(c: NoteColor) { - patch({ color: facets.value.color === c ? undefined : c }); -} function toggleLabel(id: string) { const cur = facets.value.label ?? []; const next = cur.includes(id) ? cur.filter((x) => x !== id) : [...cur, id]; @@ -111,20 +107,6 @@ const chipOff = "border-neutral-300 text-neutral-600 hover:bg-neutral-100 dark:b v-if="open" class="mt-2 flex flex-col gap-3 rounded-xl border border-neutral-200 p-3 dark:border-neutral-800" > -
- Color -
-
Labels
diff --git a/frontend/src/components/NoteEditor.vue b/frontend/src/components/NoteEditor.vue index d3e7095..9dfd421 100644 --- a/frontend/src/components/NoteEditor.vue +++ b/frontend/src/components/NoteEditor.vue @@ -1,7 +1,6 @@