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
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:
@@ -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.
|
||||
|
||||
@@ -64,14 +64,14 @@ class DerivedTintTest {
|
||||
@Test
|
||||
fun `an explicitly picked colour still wins`() {
|
||||
val known = DERIVED_TINT_KEYS.toSet() + "default"
|
||||
assertEquals("teal", resolvedNoteColor("any-id", "teal", known))
|
||||
assertEquals("teal", resolvedNoteColor("any-id", "teal", "", known))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unknown colour key falls back to the derived tint`() {
|
||||
val known = DERIVED_TINT_KEYS.toSet() + "default"
|
||||
val id = "00000000-0000-0000-0000-000000000000"
|
||||
assertEquals("purple", resolvedNoteColor(id, "chartreuse", known))
|
||||
assertEquals("purple", resolvedNoteColor(id, "chartreuse", "", known))
|
||||
}
|
||||
|
||||
/** `default` is not a choice, it is the absence of one — so a note stored as
|
||||
@@ -81,16 +81,52 @@ class DerivedTintTest {
|
||||
fun `a default colour is treated as no colour`() {
|
||||
val known = DERIVED_TINT_KEYS.toSet() + "default"
|
||||
val id = "11111111-1111-1111-1111-111111111111"
|
||||
assertEquals("blue", resolvedNoteColor(id, "default", known))
|
||||
assertNotEquals("default", resolvedNoteColor(id, "default", known))
|
||||
assertEquals("blue", resolvedNoteColor(id, "default", "", known))
|
||||
assertNotEquals("default", resolvedNoteColor(id, "default", "", known))
|
||||
}
|
||||
|
||||
/** A draft has no id yet. It must not be hashed — see the comment in DerivedTint. */
|
||||
@Test
|
||||
fun `a draft stays default until it has an id`() {
|
||||
val known = DERIVED_TINT_KEYS.toSet() + "default"
|
||||
assertEquals("default", resolvedNoteColor("", "", known))
|
||||
assertEquals("default", resolvedNoteColor("", "default", known))
|
||||
assertEquals("default", resolvedNoteColor("", "", "", known))
|
||||
assertEquals("default", resolvedNoteColor("", "default", "", known))
|
||||
}
|
||||
|
||||
/** The point of the whole milestone: notes sharing a tag share a colour, however
|
||||
* different their ids. Four `#todo` notes in four colours is what started this. */
|
||||
@Test
|
||||
fun `notes sharing a tag share a colour whatever their ids`() {
|
||||
val known = DERIVED_TINT_KEYS.toSet() + "default"
|
||||
val todo = resolvedLabelColor("todo", "default", known)
|
||||
val a = resolvedNoteColor("11111111-1111-1111-1111-111111111111", "default", todo, known)
|
||||
val b = resolvedNoteColor("6ba7b810-9dad-11d1-80b4-00c04fd430c8", "default", todo, known)
|
||||
assertEquals(todo, a)
|
||||
assertEquals(a, b)
|
||||
// ...and it is NOT what either id would have derived on its own.
|
||||
assertNotEquals(derivedTint("11111111-1111-1111-1111-111111111111"), a)
|
||||
}
|
||||
|
||||
/** Until step 5 removes the picker, a hand-picked colour outranks the tag. */
|
||||
@Test
|
||||
fun `an explicit note colour still beats its tag`() {
|
||||
val known = DERIVED_TINT_KEYS.toSet() + "default"
|
||||
val todo = resolvedLabelColor("todo", "default", known)
|
||||
assertNotEquals("teal", todo)
|
||||
assertEquals("teal", resolvedNoteColor("any-id", "teal", todo, known))
|
||||
}
|
||||
|
||||
/** Strength is not a second decision — it IS whether the colour was chosen. */
|
||||
@Test
|
||||
fun `only a chosen colour is drawn strongly`() {
|
||||
val known = DERIVED_TINT_KEYS.toSet() + "default"
|
||||
val todo = resolvedLabelColor("todo", "default", known)
|
||||
assertEquals(true, noteColorIsChosen("default", todo, known))
|
||||
assertEquals(true, noteColorIsChosen("teal", "", known))
|
||||
assertEquals(false, noteColorIsChosen("default", "", known))
|
||||
assertEquals(false, noteColorIsChosen("", "", known))
|
||||
// An unrecognised key is not a choice — it is data we could not read.
|
||||
assertEquals(false, noteColorIsChosen("chartreuse", "", known))
|
||||
}
|
||||
|
||||
/** A tag with no colour of its own derives one from its NAME, which is what makes
|
||||
|
||||
Reference in New Issue
Block a user