A note's colour is its first tag's colour, at a heavier weight
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 14s
CI & Build / integration (push) Successful in 20s
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>
This commit is contained in:
2026-08-26 13:15:47 -04:00
co-authored by Claude Opus 5
parent 6fbee27f9c
commit 988e1d3f00
7 changed files with 217 additions and 50 deletions
@@ -114,10 +114,14 @@ fun resolvedLabelColor(
fun resolvedNoteColor(
id: String,
color: String,
labelColor: String,
known: Set<String>,
): String =
when {
color.isNotEmpty() && color != "default" && color in known -> color
// The note's FIRST tag. Passed in already resolved, so this does not need to
// know that a colourless tag derives one from its name.
labelColor.isNotEmpty() -> labelColor
// A draft carries DRAFT_ID (""), so there is no identity to derive from yet.
// Staying white until the note exists costs one colour change at save time;
// hashing the empty string instead would give EVERY draft the same tint and
@@ -125,3 +129,18 @@ fun resolvedNoteColor(
id.isEmpty() -> "default"
else -> derivedTint(id)
}
/**
* Whether a note's colour was CHOSEN rather than derived — which is what decides
* between the two weights the card is drawn at.
*
* A tag (or, until step 5, the picker) means somebody said what this note is. A
* derived tint only means the board should not be a wall of white. Drawing both at
* the same weight is what prompted the operator's "the tints look the same as the
* chosen colors" — the tint was never meant to shout as loudly as a decision.
*/
fun noteColorIsChosen(
color: String,
labelColor: String,
known: Set<String>,
): Boolean = labelColor.isNotEmpty() || (color.isNotEmpty() && color != "default" && color in known)
@@ -37,7 +37,8 @@ fun NoteCard(
onToggleItem: (Int, Boolean) -> Unit,
) {
val dark = isSystemInDarkTheme()
val tint = noteTintFor(note.id, note.color)
val tint = noteTintFor(note)
val strong = noteIsStrong(note)
Column(
modifier =
@@ -47,7 +48,7 @@ fun NoteCard(
// rounded corners instead of a rectangle overhanging them.
.clip(RoundedCornerShape(CARD_RADIUS))
.clickable(onClickLabel = stringResource(R.string.board_open_note), onClick = onOpen)
.background(tint.background(dark))
.background(tint.cardBackground(dark, strong))
.border(1.dp, tint.border(dark), RoundedCornerShape(CARD_RADIUS))
.padding(12.dp),
) {
@@ -63,7 +63,8 @@ fun NoteEditorScreen(
onAction: (EditorAction) -> Unit,
) {
val dark = isSystemInDarkTheme()
val tint = noteTintFor(note.id, note.color)
val tint = noteTintFor(note)
val strong = noteIsStrong(note)
// 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
@@ -155,7 +156,7 @@ fun NoteEditorScreen(
Surface(
modifier = Modifier.fillMaxSize(),
shape = RoundedCornerShape(topStart = SHEET_CORNER, topEnd = SHEET_CORNER),
color = tint.background(dark),
color = tint.cardBackground(dark, strong),
// Both content colours are spelled out for the reason the toolbar had to
// be: Surface and Scaffold each default theirs to contentColorFor(their
// container), which returns Unspecified for anything that is not a
@@ -166,7 +167,7 @@ fun NoteEditorScreen(
contentColor = MaterialTheme.colorScheme.onSurface,
) {
Scaffold(
containerColor = tint.background(dark),
containerColor = tint.cardBackground(dark, strong),
contentColor = MaterialTheme.colorScheme.onSurface,
topBar = {
EditorTopBar(
@@ -3,6 +3,7 @@ package com.fabledsword.thoughtsync.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.graphics.Color
import com.fabledsword.thoughtsync.core.Note
/**
* The note colour palette, matching `frontend/src/notes/colors.ts` VALUE FOR VALUE.
@@ -30,9 +31,44 @@ data class NoteTint(
val lightChipForeground: Color,
val darkChipBackground: Color,
val darkChipForeground: Color,
/**
* False only for `default`, which is the ABSENCE of a colour rather than one of
* them. Everything else has two weights (see [cardBackground]); `default` has one,
* because there is no such thing as an emphatic lack of colour — and re-alphaing
* its opaque neutral fill would make a draft card translucent.
*/
val tintable: Boolean = true,
) {
fun background(dark: Boolean): Color = if (dark) darkBackground else lightBackground
/**
* A NOTE card's fill, at one of two weights.
*
* `strong` means the colour was CHOSEN — by a tag, or (until step 5) by the
* picker. Subdued means it was derived from the note's id purely so the board is
* not a wall of white. Drawing those at the same weight is what prompted the
* operator's "the tints look the same as the chosen colors".
*
* The two weights move in OPPOSITE directions per theme, because that is where
* each has headroom. Light cannot go lighter than `-50` without being white again,
* so the gap opens by deepening the chosen end to `-100` — which is exactly
* [lightChipBackground], already in this table, so no new hex is transcribed.
* Dark CAN go lighter, so the derived end drops to a quarter opacity: closer to
* the board, which gives the light body text MORE contrast, not less.
*
* Borders are untouched. The fill is the signal; moving both muddies the edge.
*/
fun cardBackground(
dark: Boolean,
strong: Boolean,
): Color =
when {
!tintable -> background(dark)
dark -> darkBackground.copy(alpha = if (strong) STRONG_DARK_ALPHA else SUBDUED_DARK_ALPHA)
strong -> lightChipBackground
else -> lightBackground
}
fun border(dark: Boolean): Color = if (dark) darkBorder else lightBorder
fun chipBackground(dark: Boolean): Color = if (dark) darkChipBackground else lightChipBackground
@@ -40,6 +76,12 @@ data class NoteTint(
fun chipForeground(dark: Boolean): Color = if (dark) darkChipForeground else lightChipForeground
}
// The two dark-theme weights, as fractions. Mirror `dark:bg-{hue}-950/25` and `/70`
// in colors.ts — these are the only two numbers that have to agree by hand, since the
// light weights are both already columns of the table below.
private const val SUBDUED_DARK_ALPHA = 0.25f
private const val STRONG_DARK_ALPHA = 0.70f
/** Keyed by the core's colour vocabulary. Order matches the web's picker. */
val NOTE_TINTS: Map<String, NoteTint> =
mapOf(
@@ -54,6 +96,7 @@ val NOTE_TINTS: Map<String, NoteTint> =
lightChipForeground = Color(0xFF525252),
darkChipBackground = Color(0x1AFFFFFF),
darkChipForeground = Color(0xFFD4D4D4),
tintable = false,
),
"red" to
NoteTint(
@@ -185,10 +228,31 @@ fun noteTint(key: String): NoteTint = NOTE_TINTS[key] ?: NOTE_TINTS.getValue("de
*/
@Composable
@ReadOnlyComposable
fun noteTintFor(
id: String,
color: String,
): NoteTint = noteTint(resolvedNoteColor(id, color, NOTE_TINTS.keys))
fun noteTintFor(note: Note): NoteTint =
noteTint(resolvedNoteColor(note.id, note.color, firstLabelColor(note), NOTE_TINTS.keys))
/**
* Whether this note's card is drawn at the CHOSEN weight — see [NoteTint.cardBackground].
*
* Not `@Composable`: the card needs it alongside `isSystemInDarkTheme()`, and keeping
* it an ordinary function means it can be read anywhere the note is.
*/
fun noteIsStrong(note: Note): Boolean =
noteColorIsChosen(note.color, firstLabelColor(note), NOTE_TINTS.keys)
/**
* The colour of the note's FIRST label, already resolved, or "" when it has none.
*
* First rather than any other: it is the one the person controls by typing, where
* alphabetical or most-used would move a note's colour when an unrelated tag was
* added somewhere else. Manual labels count the same as `#tags` — someone looking at
* a chip cannot tell which kind they made, and two identically-tagged notes in
* different colours for an invisible reason is worse than the rule being loose.
*/
private fun firstLabelColor(note: Note): String =
note.labels.firstOrNull()
?.let { resolvedLabelColor(it.name, it.color, NOTE_TINTS.keys) }
?: ""
/**
* The tint for a LABEL, derived from its name when nobody has picked one.