Remove the title field — a note is named by its first line
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) Failing after 7s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 7s
CI & Build / Python tests (push) Successful in 11s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Failing after 31s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 6m45s

Operator (note 2897): "notes shouldn't have a title field." The concept of a NAME
stays — search results, export filenames and the command palette all need one —
but nothing is typed into it any more. `display_title` is now the first non-empty
line of the body, falling back to the first checklist item.

That fallback is what step 2 bought, and the reason this could not go first: a
checklist had no body to be named from, so the title was its only name. Now every
note has a body, and a note that is only a checklist is named by its first item.

Gone everywhere: the column and note_revisions.title (0026), the field on the
core's Note/NoteCreateInput/NoteRevision and its SQLite columns (user_version 7),
`normalize_title`, the wire field, the FFI record and `NoteEdit::Title` /
`ClearTitle`, the web editor's "Title (optional)" input and the card's <h3>, and
the Android title field in both the compose sheet and the editor.

**The search vector had to be rebuilt, not just left alone.** `notes.search_vector`
is a STORED GENERATED column whose expression names `title` — Postgres refuses to
drop a column another generated column depends on. It is dropped and recreated over
`display_title` at weight A, which keeps the original intent: a note's NAME ranks
above the rest of its body.

**An imported title becomes the note's first body line.** Keep notes carry one, and
so does any ThoughtSync export taken before this. Dropping it would silently lose
text someone wrote; folding it in puts it exactly where a name now lives, so the
note arrives named as it was. Skipped when the body already opens with that line,
so re-importing an export this code produced doesn't stack duplicates.

Two smaller things fell out. The Android editor loses its bold first field — one
weight throughout, because the first line is the note's name but not a different
KIND of text, which is most of step 4 arriving early. And `ClearTitle`'s
justification comment moved to `ClearRemindAt`, which is now the surviving example
of why NoteEdit is a list rather than a struct of options.

Protocol note corrected to say what actually shipped: v2 is "no kind, no title",
one bump for the pair.

Verified with the local Rust gate this time, not by CI: fmt, clippy and 116 tests
all green before pushing. It caught four things — orphaned serde attributes where
fields were removed, a `wire::Preview.title` I deleted by mistake (a link preview
still has one), nine retention fixtures inserting a dropped column, and four
rustfmt diffs.
This commit is contained in:
2026-08-22 19:33:57 -04:00
parent 6d778f26a7
commit 95aa10c2c3
29 changed files with 420 additions and 435 deletions
@@ -226,8 +226,8 @@ private fun App(
ComposeSheet(
saving = board.state.saving,
onDismiss = { composing = false },
onSave = { title, content ->
board.create(title, content)
onSave = { content ->
board.create(content)
composing = false
},
)
@@ -194,19 +194,15 @@ class BoardViewModel(
* Blank input is ignored rather than rejected: an empty save is a slip, not a
* mistake worth interrupting someone over.
*/
fun create(
title: String,
content: String,
) {
val cleanTitle = title.trim()
fun create(content: String) {
val cleanContent = content.trim()
if (cleanTitle.isEmpty() && cleanContent.isEmpty()) return
if (cleanContent.isEmpty()) return
viewModelScope.launch {
state = state.copy(saving = true)
state =
try {
val created = withContext(Dispatchers.IO) { core.createNote(draft(cleanTitle, cleanContent)) }
val created = withContext(Dispatchers.IO) { core.createNote(draft(cleanContent)) }
// Prepend rather than reload: the new note belongs at the top
// of the board, and a full re-query would cost a round trip to
// tell us what we already know. Skipped when the board is not
@@ -277,28 +273,10 @@ class BoardViewModel(
EditorAction.Close -> state = state.copy(editing = null)
EditorAction.DismissError -> dismissError()
// Text is the only edit that batches: title and body are typed
// together and saved together on close, so they cost one write and
// one revision snapshot rather than two of each.
// Saved on close rather than per keystroke, so a session of typing
// costs one write and one revision snapshot.
is EditorAction.SaveText ->
mutate {
it.updateNote(
id,
listOf(
// An emptied title CLEARS the column rather than
// storing "". The core derives `display_title` from
// the first body line when the title is null, so the
// difference is whether an untitled note is nameable
// or blank — exactly what `ClearTitle` exists for.
if (action.title.isBlank()) {
NoteEdit.ClearTitle
} else {
NoteEdit.Title(action.title.trim())
},
NoteEdit.Body(action.body),
),
)
}
mutate { it.updateNote(id, listOf(NoteEdit.Body(action.body))) }
is EditorAction.SetColor -> edit(id, NoteEdit.Color(action.color))
@@ -464,12 +442,8 @@ private fun query(
labelId: String? = null,
) = NoteQuery(view = view, labelId = labelId, sort = null, facets = null)
private fun draft(
title: String,
content: String,
): NoteDraft =
// Body carries the text; the core derives display_title from its first line when
// no title was given, so a captured thought is nameable without making the user
// name it. A checklist is added afterwards, in the editor — it is something a note
// HAS, not a different thing to capture (M13 step 2).
NoteDraft(title = title, body = content, color = DEFAULT_COLOR, items = null)
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)
@@ -57,27 +57,26 @@ import com.fabledsword.thoughtsync.R
fun ComposeSheet(
saving: Boolean,
onDismiss: () -> Unit,
onSave: (String, String) -> Unit,
onSave: (String) -> Unit,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
// Saveable, not just remembered: a rotation mid-sentence is the same lost
// thought as a discarded one, and it was losing it before this.
var title by rememberSaveable { mutableStateOf("") }
var content by rememberSaveable { mutableStateOf("") }
val contentFocus = remember { FocusRequester() }
val written = title.isNotBlank() || content.isNotBlank()
val leave = { if (written) onSave(title, content) else onDismiss() }
val written = content.isNotBlank()
val leave = { if (written) onSave(content) else onDismiss() }
// Land in the body, not the title. Most captures are a thought, not a titled
// document, and making someone tab past an optional field is the difference
// between "under a second" and not.
// Straight into the one field there is. A capture is a thought, and every field
// someone has to tab past is the difference between "under a second" and not —
// which is why the title field is gone rather than merely skipped (M13 step 3).
LaunchedEffect(Unit) { contentFocus.requestFocus() }
// Backgrounding PERSISTS but does not close an empty sheet. Someone who tapped
// + and then got distracted should find the composer where they left it; the
// only reason to act here is that there is something to lose.
FlushOnStop { if (written) onSave(title, content) }
FlushOnStop { if (written) onSave(content) }
ModalBottomSheet(onDismissRequest = leave, sheetState = sheetState) {
Column(
@@ -91,13 +90,6 @@ fun ComposeSheet(
) {
// No note/list switch any more: there is one thing to capture. A
// checklist is added to a note in the editor, once there is a note.
PlainTextField(
value = title,
onValueChange = { title = it },
hint = R.string.compose_title_hint,
singleLine = true,
)
PlainTextField(
value = content,
onValueChange = { content = it },
@@ -109,7 +101,7 @@ fun ComposeSheet(
SheetActions(
canSave = !saving && written,
onDiscard = onDismiss,
onSave = { onSave(title, content) },
onSave = { onSave(content) },
)
}
}
@@ -22,7 +22,6 @@ sealed interface EditorAction {
data object DismissError : EditorAction
data class SaveText(
val title: String,
val body: String,
) : EditorAction
@@ -21,7 +21,6 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -50,21 +49,9 @@ fun NoteCard(
.border(1.dp, tint.border(dark), RoundedCornerShape(CARD_RADIUS))
.padding(12.dp),
) {
// A title only renders when one was actually set. `displayTitle` is
// derived from the first body line when it wasn't, so printing both would
// show the same text twice.
note.title?.takeIf { it.isNotBlank() }?.let { title ->
Text(
text = title,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.height(4.dp))
}
// Both, in order — a note can carry a body AND a checklist (M13 step 2).
// Body then checklist, in order — a note can carry both (M13 step 2), and
// nothing above them: the first line of the body IS the note's name, at the
// same weight as the rest of it (M13 steps 3 and 4).
if (note.body.isNotBlank()) {
Text(
text = note.body,
@@ -78,9 +65,9 @@ fun NoteCard(
Checklist(items = note.items)
}
// A note with no title, no body and no items still has to occupy the
// board legibly — otherwise it reads as a rendering bug.
if (note.title.isNullOrBlank() && note.body.isBlank() && note.items.isEmpty()) {
// A note with no body and no items still has to occupy the board legibly —
// otherwise it reads as a rendering bug.
if (note.body.isBlank() && note.items.isEmpty()) {
Text(
text = stringResource(R.string.board_empty_note),
style = MaterialTheme.typography.bodyMedium,
@@ -30,7 +30,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.fabledsword.thoughtsync.R
import com.fabledsword.thoughtsync.core.Label
@@ -62,7 +61,6 @@ fun NoteEditorScreen(
// Keyed by note id: the editor is reused across notes, and without the key the
// second note opened would show the first one's text.
var title by remember(note.id) { mutableStateOf(note.title.orEmpty()) }
var body by remember(note.id) { mutableStateOf(note.body) }
var picker by remember(note.id) { mutableStateOf(Picker.NONE) }
var confirmingDelete by remember(note.id) { mutableStateOf(false) }
@@ -77,8 +75,8 @@ fun NoteEditorScreen(
// would bump `updated_at`, mark the note dirty for sync, and snapshot a
// revision identical to the one before it.
val flush = {
if (!readOnly && (title != note.title.orEmpty() || body != note.body)) {
onAction(EditorAction.SaveText(title, body))
if (!readOnly && body != note.body) {
onAction(EditorAction.SaveText(body))
}
}
val leave = {
@@ -142,14 +140,9 @@ fun NoteEditorScreen(
ErrorBanner(message = message, onDismiss = { onAction(EditorAction.DismissError) })
}
EditorField(
value = title,
onValueChange = { title = it },
hint = R.string.editor_title_hint,
enabled = !readOnly,
bold = true,
)
// One field. A note is its body; its NAME is that body's first line, so
// there is nothing separate to type into and nothing to render bolder
// than the line beneath it (M13 steps 3 and 4).
EditorField(
value = body,
onValueChange = { body = it },
@@ -252,11 +245,15 @@ private fun EditorOverlays(
}
/**
* The title and body fields.
* The note's body field.
*
* Undecorated, via the shared [PlainTextField]: the screen is already painted in
* the note's colour, and a filled field would draw a second surface over the first
* and turn a note into a form.
*
* One weight throughout. The first line is the note's name, but it is not a
* different KIND of text from the line after it, and typing it should not feel like
* filling in a header.
*/
@Composable
private fun EditorField(
@@ -264,7 +261,6 @@ private fun EditorField(
onValueChange: (String) -> Unit,
@StringRes hint: Int,
enabled: Boolean,
bold: Boolean = false,
minLines: Int = 1,
) {
PlainTextField(
@@ -272,16 +268,8 @@ private fun EditorField(
onValueChange = onValueChange,
hint = hint,
enabled = enabled,
// The title is one line by contract — it is a name, and a name that wraps
// has become a body. The body itself never is.
singleLine = bold,
minLines = minLines,
textStyle =
if (bold) {
MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold)
} else {
MaterialTheme.typography.bodyLarge
},
textStyle = MaterialTheme.typography.bodyLarge,
)
}
@@ -10,7 +10,6 @@
<!-- Compose sheet -->
<string name="compose_open">New note</string>
<string name="compose_title_hint">Title</string>
<string name="compose_body_hint">Take a note…</string>
<string name="compose_discard">Discard</string>
<string name="compose_save">Save</string>
@@ -38,7 +37,6 @@
<!-- Editor -->
<string name="board_open_note">Open note</string>
<string name="editor_back">Back to notes</string>
<string name="editor_title_hint">Title</string>
<string name="editor_add_checklist">Add a checklist</string>
<string name="editor_body_hint">Note</string>
<string name="editor_add_item">Add item</string>
+25 -43
View File
@@ -568,9 +568,8 @@ mod tests {
dir.to_string_lossy().into_owned()
}
fn draft(title: &str, body: &str) -> NoteDraft {
fn draft(body: &str) -> NoteDraft {
NoteDraft {
title: title.to_string(),
body: body.to_string(),
color: "default".to_string(),
items: None,
@@ -587,62 +586,50 @@ mod tests {
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
let created = app
.create_note(draft("Groceries", "milk"))
.create_note(draft("Groceries\nmilk"))
.expect("create should succeed");
assert_eq!(created.title.as_deref(), Some("Groceries"));
assert_eq!(created.body, "milk");
assert_eq!(created.body, "Groceries\nmilk");
let fetched = app
.get_note(created.id.clone())
.expect("get should succeed");
assert_eq!(fetched.id, created.id);
// The NAME is the first line — there is no title field to have set (M13 step 3).
assert_eq!(fetched.display_title, "Groceries");
std::fs::remove_dir_all(&dir).ok();
}
/// A body-only note still has to be nameable — that is what `display_title` is
/// for, and the Android board relies on it exactly as the desktop does.
/// Every note has to be nameable — that is what `display_title` is for, and the
/// Android board relies on it exactly as the desktop does.
#[test]
fn body_only_notes_still_have_a_display_title() {
fn a_note_is_named_by_its_first_line() {
let dir = scratch_dir();
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
let created = app
.create_note(draft("", "just a thought"))
.create_note(draft("just a thought"))
.expect("create should succeed");
assert_eq!(created.title, None);
assert_eq!(created.display_title, "just a thought");
std::fs::remove_dir_all(&dir).ok();
}
/// Clearing a field and setting one are different edits, and the difference has
/// to survive the trip through the patch object.
/// The hole that made removing the title unsafe until checklists stopped being
/// their own kind of thing: a note with no body text still needs a name.
#[test]
fn edits_can_both_set_and_clear_a_title() {
fn a_note_with_only_items_is_named_by_its_first_item() {
let dir = scratch_dir();
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
let note = app.create_note(draft("First", "body")).expect("create");
let renamed = app
.update_note(
note.id.clone(),
vec![NoteEdit::Title {
value: "Second".to_string(),
}],
)
.expect("rename");
assert_eq!(renamed.title.as_deref(), Some("Second"));
let cleared = app
.update_note(note.id.clone(), vec![NoteEdit::ClearTitle])
.expect("clear");
assert_eq!(
cleared.title, None,
"ClearTitle must null the column, not set it to an empty string — the \
distinction is why NoteEdit is a list rather than a struct of options"
);
let created = app
.create_note(NoteDraft {
body: String::new(),
color: "default".to_string(),
items: Some(vec!["milk".to_string(), "eggs".to_string()]),
})
.expect("create should succeed");
assert_eq!(created.display_title, "milk");
std::fs::remove_dir_all(&dir).ok();
}
@@ -673,8 +660,7 @@ mod tests {
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
let note = app
.create_note(NoteDraft {
title: "Packing".to_string(),
body: String::new(),
body: "Packing".to_string(),
color: "default".to_string(),
items: Some(vec!["socks".to_string()]),
})
@@ -728,7 +714,7 @@ mod tests {
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
let note = app
.create_note(draft("Trip", "book the ferry #travel"))
.create_note(draft("Trip\nbook the ferry #travel"))
.expect("create");
assert_eq!(
note.labels.len(),
@@ -767,7 +753,7 @@ mod tests {
fn deleting_forever_removes_the_note() {
let dir = scratch_dir();
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
let note = app.create_note(draft("Ephemeral", "body")).expect("create");
let note = app.create_note(draft("Ephemeral\nbody")).expect("create");
app.delete_note_forever(note.id.clone())
.expect("delete forever");
@@ -784,7 +770,7 @@ mod tests {
fn reminders_can_be_snoozed_and_completed() {
let dir = scratch_dir();
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
let note = app.create_note(draft("Call back", "")).expect("create");
let note = app.create_note(draft("Call back")).expect("create");
assert_eq!(note.remind_at, None);
let snoozed = app.snooze_reminder(note.id.clone(), 60).expect("snooze");
@@ -810,9 +796,7 @@ mod tests {
fn completing_a_recurring_reminder_moves_it_rather_than_ending_it() {
let dir = scratch_dir();
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
let note = app
.create_note(draft("Water the plants", ""))
.expect("create");
let note = app.create_note(draft("Water the plants")).expect("create");
let armed = app
.update_note(
@@ -849,9 +833,7 @@ mod tests {
// A one-off clears BOTH fields, so an unrecognised rule cannot linger
// invisibly on a note with no reminder.
let once = app
.create_note(draft("Post the letter", ""))
.expect("create");
let once = app.create_note(draft("Post the letter")).expect("create");
app.update_note(
once.id.clone(),
vec![NoteEdit::RemindAt {
+19 -37
View File
@@ -29,9 +29,8 @@ use thoughtsync_core::sync::state as core_state;
#[derive(Debug, Clone, uniffi::Record)]
pub struct Note {
pub id: String,
pub title: Option<String>,
/// Title if set, else the first body line — always present, so a body-only note
/// is still nameable. Derived by the core, never stored.
/// The note's NAME: its first non-blank body line, else its first checklist item.
/// Always present. Derived by the core, never stored.
pub display_title: String,
pub body: String,
pub color: String,
@@ -129,7 +128,6 @@ impl From<core_models::Note> for Note {
// Exhaustive on purpose — see the module header.
let core_models::Note {
id,
title,
display_title,
body,
color,
@@ -149,7 +147,6 @@ impl From<core_models::Note> for Note {
} = value;
Note {
id,
title,
display_title,
body,
color,
@@ -341,7 +338,6 @@ impl From<NoteFacets> for core_models::Facets {
/// A new note.
#[derive(Debug, Clone, uniffi::Record)]
pub struct NoteDraft {
pub title: String,
pub body: String,
/// "default" unless the user picked a colour.
pub color: String,
@@ -352,18 +348,8 @@ pub struct NoteDraft {
impl From<NoteDraft> for core_models::NoteCreateInput {
fn from(value: NoteDraft) -> Self {
let NoteDraft {
title,
body,
color,
items,
} = value;
core_models::NoteCreateInput {
title,
body,
color,
items,
}
let NoteDraft { body, color, items } = value;
core_models::NoteCreateInput { body, color, items }
}
}
@@ -371,14 +357,12 @@ impl From<NoteDraft> for core_models::NoteCreateInput {
///
/// A LIST of these rather than a struct of optional fields, because the core's patch
/// semantics distinguish three states — leave alone, set to a value, and clear to
/// null — and Kotlin has no way to express the third with a nullable field. `title:
/// null` in a data class is indistinguishable from `title` unset, so the editor
/// could never clear a title. Explicit `Clear*` variants say it out loud, and Kotlin
/// gets a sealed class it can `when` over exhaustively.
/// null — and Kotlin has no way to express the third with a nullable field.
/// `remindAt: null` in a data class is indistinguishable from `remindAt` unset, so
/// the editor could never clear a reminder. Explicit `Clear*` variants say it out
/// loud, and Kotlin gets a sealed class it can `when` over exhaustively.
#[derive(Debug, Clone, uniffi::Enum)]
pub enum NoteEdit {
Title { value: String },
ClearTitle,
Body { value: String },
Color { value: String },
Pinned { value: bool },
@@ -399,8 +383,6 @@ impl NoteEdit {
fn entry(self) -> (&'static str, serde_json::Value) {
use serde_json::Value;
match self {
NoteEdit::Title { value } => ("title", Value::String(value)),
NoteEdit::ClearTitle => ("title", Value::Null),
NoteEdit::Body { value } => ("body", Value::String(value)),
NoteEdit::Color { value } => ("color", Value::String(value)),
NoteEdit::Pinned { value } => ("pinned", Value::Bool(value)),
@@ -415,8 +397,8 @@ impl NoteEdit {
/// Fold a list of edits into the single patch object the store applies.
///
/// Later edits win on a repeated key, which is what a caller batching "set title,
/// then clear title" would expect.
/// Later edits win on a repeated key, which is what a caller batching "set a
/// reminder, then clear it" would expect.
pub fn patch_from(edits: Vec<NoteEdit>) -> serde_json::Value {
let mut map = serde_json::Map::new();
for edit in edits {
@@ -697,14 +679,14 @@ mod tests {
#[test]
fn a_set_and_a_clear_are_different_patch_entries() {
let set = patch_from(vec![NoteEdit::Title {
value: "x".to_string(),
let set = patch_from(vec![NoteEdit::RemindAt {
value: "2026-01-01T00:00:00Z".to_string(),
}]);
assert_eq!(set["title"], serde_json::json!("x"));
assert_eq!(set["remind_at"], serde_json::json!("2026-01-01T00:00:00Z"));
let cleared = patch_from(vec![NoteEdit::ClearTitle]);
let cleared = patch_from(vec![NoteEdit::ClearRemindAt]);
assert!(
cleared["title"].is_null(),
cleared["remind_at"].is_null(),
"a clear must reach the store as JSON null — an absent key means \
'leave alone', which is a different instruction"
);
@@ -720,11 +702,11 @@ mod tests {
#[test]
fn later_edits_win_on_a_repeated_field() {
let patch = patch_from(vec![
NoteEdit::Title {
value: "first".to_string(),
NoteEdit::RemindAt {
value: "2026-01-01T00:00:00Z".to_string(),
},
NoteEdit::ClearTitle,
NoteEdit::ClearRemindAt,
]);
assert!(patch["title"].is_null());
assert!(patch["remind_at"].is_null());
}
}