notes: color leaves the model, the wire and all three surfaces
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 14s
CI & Build / integration (push) Successful in 19s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 2m28s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m52s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Failing after 4m1s

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-28 14:07:03 -04:00
co-authored by Claude Opus 5
parent 13a88179b8
commit fa89da1fab
36 changed files with 278 additions and 441 deletions
-24
View File
@@ -1,24 +0,0 @@
<script setup lang="ts">
import { NOTE_COLOR_KEYS, NOTE_COLOR_LABELS, NOTE_SWATCH_CLASSES, type NoteColor } from "../notes/colors";
defineProps<{ modelValue: NoteColor }>();
defineEmits<{ (e: "update:modelValue", value: NoteColor): void }>();
</script>
<template>
<div class="flex flex-wrap items-center gap-1.5">
<button
v-for="key in NOTE_COLOR_KEYS"
:key="key"
type="button"
:title="NOTE_COLOR_LABELS[key]"
:aria-label="NOTE_COLOR_LABELS[key]"
:aria-pressed="modelValue === key"
class="h-6 w-6 rounded-full border border-black/10 transition hover:scale-110
focus:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-1
focus-visible:ring-offset-white dark:focus-visible:ring-offset-neutral-900"
:class="[NOTE_SWATCH_CLASSES[key], modelValue === key ? 'ring-2 ring-brand ring-offset-1' : '']"
@click="$emit('update:modelValue', key)"
/>
</div>
</template>
-18
View File
@@ -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<NoteFacets>) {
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"
>
<div class="flex flex-wrap items-center gap-1.5">
<span class="w-16 shrink-0 text-xs text-neutral-400">Color</span>
<button
v-for="c in NOTE_COLOR_KEYS"
:key="c"
type="button"
:title="NOTE_COLOR_LABELS[c]"
:aria-label="NOTE_COLOR_LABELS[c]"
class="h-6 w-6 rounded-full border border-black/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-white/10"
:class="[NOTE_SWATCH_CLASSES[c], facets.color === c ? 'ring-2 ring-brand ring-offset-1' : '']"
@click="setColor(c)"
/>
</div>
<div v-if="labels.items.length" class="flex flex-wrap items-center gap-1.5">
<span class="w-16 shrink-0 text-xs text-neutral-400">Labels</span>
<button
+2 -61
View File
@@ -1,14 +1,7 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from "vue";
import { computed, ref, watch } from "vue";
import { useNotesStore } from "../stores/notes";
import {
NOTE_CARD_SURFACE,
NOTE_COLOR_KEYS,
NOTE_COLOR_LABELS,
NOTE_SWATCH_CLASSES,
labelChipClasses,
type NoteColor,
} from "../notes/colors";
import { NOTE_CARD_SURFACE, labelChipClasses } from "../notes/colors";
import type { Note } from "../stores/notes";
import Icon from "./Icon.vue";
import LinkPreview from "./LinkPreview.vue";
@@ -206,27 +199,6 @@ const tagColors = computed<Record<string, string>>(() => {
return map;
});
// Per-card color popover (recolor without opening the editor).
const colorOpen = ref(false);
function swatch(color: string): string {
return NOTE_SWATCH_CLASSES[color as NoteColor] ?? NOTE_SWATCH_CLASSES.default;
}
function pickColor(color: NoteColor) {
colorOpen.value = false;
void notes.setColor(props.note.id, color);
}
function onDocMousedown(e: MouseEvent) {
if (colorOpen.value && root.value && !root.value.contains(e.target as Node)) colorOpen.value = false;
}
// Only listen for outside clicks while the popover is actually open.
watch(colorOpen, (open) => {
if (open) document.addEventListener("mousedown", onDocMousedown);
else document.removeEventListener("mousedown", onDocMousedown);
});
onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown));
</script>
<template>
@@ -469,19 +441,6 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
</button>
</template>
<template v-else>
<button
type="button"
class="icon-btn"
title="Change color"
aria-label="Change color"
:aria-expanded="colorOpen"
@click.stop="colorOpen = !colorOpen"
>
<span
class="h-4 w-4 rounded-full border border-black/10 dark:border-white/20"
:class="swatch(note.color)"
></span>
</button>
<button
type="button"
class="icon-btn"
@@ -512,24 +471,6 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
<Icon name="trash" />
</button>
</template>
<!-- Inside the action set rather than beside it, so it follows the set to
whichever corner or footer the device put it in. -->
<div
v-if="colorOpen"
class="note-swatches flex w-40 flex-wrap gap-1.5 rounded-lg border border-neutral-200 bg-white p-2 shadow-lg dark:border-neutral-700 dark:bg-neutral-800"
>
<button
v-for="key in NOTE_COLOR_KEYS"
:key="key"
type="button"
:title="NOTE_COLOR_LABELS[key]"
:aria-label="NOTE_COLOR_LABELS[key]"
class="h-6 w-6 rounded-full border border-black/10 transition hover:scale-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand"
:class="[NOTE_SWATCH_CLASSES[key], note.color === key ? 'ring-2 ring-brand' : '']"
@click.stop="pickColor(key)"
/>
</div>
</div>
</div>
</div>
+14 -21
View File
@@ -1,7 +1,6 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from "vue";
import { useNotesStore } from "../stores/notes";
import ColorPicker from "./ColorPicker.vue";
import Icon from "./Icon.vue";
import LabelPicker from "./LabelPicker.vue";
import LinkPreview from "./LinkPreview.vue";
@@ -9,7 +8,7 @@ import { fromLocalInput, toLocalInput } from "../notes/datetime";
import { takeMorphOrigin } from "../composables/useEditorMorph";
import { prefersReducedMotion } from "../composables/useReducedMotion";
import type { Note, NoteLabel, NoteRevision } from "../stores/notes";
import { labelChipClasses, type NoteColor } from "../notes/colors";
import { labelChipClasses } from "../notes/colors";
import {
afterEnter,
type EditorBlock,
@@ -41,7 +40,6 @@ const body = computed(() => joinBlocks(blocks.value));
function setBody(text: string): void {
blocks.value = splitBlocks(text);
}
const color = ref<NoteColor>(props.note?.color ?? "default");
const labelList = ref<NoteLabel[]>(props.note ? [...props.note.labels] : []);
// Whether this editor is showing the checklist. A note HAS a checklist (M13 step 2)
// rather than BEING one, so this is a view flag, not a property of the note: it turns
@@ -80,10 +78,7 @@ const fileInput = ref<HTMLInputElement | null>(null);
const uploadError = ref("");
// Baseline for edit-mode change detection (save only when text actually changed).
const baseline = ref<{ body: string; color: NoteColor }>({
body: props.note?.body ?? "",
color: (props.note?.color ?? "default") as NoteColor,
});
const baseline = ref<{ body: string }>({ body: props.note?.body ?? "" });
const isCreate = computed(() => noteId.value === null);
const hasContent = computed(() => body.value.trim() !== "");
@@ -96,7 +91,6 @@ const draftNote = computed<Note>(() => ({
id: "",
display_title: "",
body: body.value,
color: color.value,
position: 0,
pinned: false,
archived: false,
@@ -124,17 +118,16 @@ watch(
(n) => {
noteId.value = n?.id ?? null;
setBody(n?.body ?? "");
color.value = (n?.color ?? "default") as NoteColor;
labelList.value = n ? [...n.labels] : [];
baseline.value = { body: n?.body ?? "", color: (n?.color ?? "default") as NoteColor };
baseline.value = { body: n?.body ?? "" };
},
);
// ---- persistence ----
async function createFromFields(): Promise<void> {
const created = await notes.create({ body: body.value, color: color.value });
const created = await notes.create({ body: body.value });
noteId.value = created.id;
baseline.value = { body: created.body, color: created.color as NoteColor };
baseline.value = { body: created.body };
}
// Ensure a persisted note exists (for rich actions mid-compose). Returns its id, or
@@ -161,12 +154,12 @@ async function flush(): Promise<void> {
}
const b = baseline.value;
const nextBody = body.value;
const changed = nextBody !== b.body || color.value !== b.color;
const changed = nextBody !== b.body;
if (!changed) return;
saving.value = true;
try {
await notes.saveEdit(noteId.value as string, { body: nextBody, color: color.value });
baseline.value = { body: nextBody, color: color.value };
await notes.saveEdit(noteId.value as string, { body: nextBody });
baseline.value = { body: nextBody };
} finally {
saving.value = false;
}
@@ -175,9 +168,8 @@ async function flush(): Promise<void> {
function resetCompose(): void {
noteId.value = null;
setBody("");
color.value = "default";
labelList.value = [];
baseline.value = { body: "", color: "default" };
baseline.value = { body: "" };
uploadError.value = "";
}
@@ -468,8 +460,7 @@ async function restoreRevisionAt(revId: string) {
if (!id) return;
const updated = await notes.restoreRevision(id, revId);
setBody(updated.body);
color.value = updated.color;
baseline.value = { body: updated.body, color: updated.color };
baseline.value = { body: updated.body };
void loadRevisions(); // the pre-restore state became a new revision
}
function revLabel(iso: string | null): string {
@@ -713,8 +704,10 @@ function revPreview(rev: NoteRevision): string {
</div>
</div>
<div class="flex items-center justify-between gap-2 border-t border-neutral-100 px-3 py-2 dark:border-neutral-800">
<ColorPicker v-model="color" />
<!-- `justify-end`, not `justify-between`: the colour picker sat on the left of
this row until M315 and the row was balanced around it. With one child left,
`between` would push the actions to the far left of a full-width bar. -->
<div class="flex items-center justify-end gap-2 border-t border-neutral-100 px-3 py-2 dark:border-neutral-800">
<div class="flex items-center gap-0.5">
<button
v-if="richEnabled && !liveNote.trashed"