From 47f108c9c827eea3f1d8afd2e602e7dbf8671b0a Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 26 Aug 2026 14:21:42 -0400 Subject: [PATCH] The border was the thing making every note look the same MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A note card carried a 1px tint border. Measured against its own fill, that line was a 1.56-2.09 contrast in dark mode while the fill managed only 1.03-1.05 against the board — so the loudest thing on every card was an identical line in an identical place, and a field of them read as a grid of outlined rectangles however different the colours inside were. Removed from the note card on both surfaces. `border` survives for panels, banners, the update card and the pickers: those are single elements, not a field of them. What replaces it differs by theme, because elevation does. Light leans on a shadow. An untagged card is `bg-red-50` on a `neutral-50` board — a 1.04 contrast that can only read as a card by sitting above one. The web goes `shadow-sm` -> `shadow`; Android had no shadow at all and gets 2dp. Dark cannot use one, black on near-black. So the subdued fills moved onto the card surface instead: `{hue}-950` composited at 0.18 over #171717 and baked, rather than the same hue at 0.25 over the near-black board. An untagged card now sits where the plain white card always sat (1.11-1.14 against the board, against `bg-neutral-900`'s 1.10) while carrying LESS hue than before — chroma 7-17 where the old ramp had 10-23. Subtler and more visible at once, which is only a contradiction if subtlety has to come from lightness. Here it comes from chroma, and lightness is left to say "this is a card". Which also reframes the two weights: in dark they now sit within a hair of each other (red: 1.11 vs 1.12) and differ threefold in colour (chroma 10 vs 41). The chosen ramp is untouched — the operator signed those colours off, and a ramp somebody likes is not something to redo while fixing something else. Light was already built this way: `-50` and `-100` are both white plus a different amount of hue. Body text still measures 14.3-16.4 against the 4.5 it needs, meta 6.9-7.1 against 3.0. Co-Authored-By: Claude Opus 5 --- .../fabledsword/thoughtsync/ui/NoteCard.kt | 20 ++++- .../fabledsword/thoughtsync/ui/NoteTint.kt | 76 +++++++++++++---- frontend/src/components/NoteCard.vue | 10 ++- frontend/src/notes/colors.ts | 84 +++++++++++++------ 4 files changed, 147 insertions(+), 43 deletions(-) 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 af7077d..1575fc4 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 @@ -19,6 +19,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.style.TextDecoration @@ -44,12 +45,23 @@ fun NoteCard( modifier = Modifier .fillMaxWidth() + // Elevation instead of an edge line. The card used to be framed by a + // 1px tint border, which measured a higher contrast against its own + // fill (1.56-2.09) than the fill managed against the board + // (1.03-1.05) — so the loudest thing on every card was the same line + // in the same place, and a board of them read as a grid of outlines + // however different the colours inside. See NoteTint.cardBackground. + // + // It matters most in LIGHT mode, where an untagged card is a 1.04 + // contrast against the board and nothing but a shadow can lift it + // off. In dark the shadow is black on near-black and invisible, which + // is why the subdued fills were moved onto the card surface instead. + .shadow(CARD_ELEVATION, RoundedCornerShape(CARD_RADIUS)) // Clipped BEFORE clickable, so the ripple is bounded by the card's // rounded corners instead of a rectangle overhanging them. .clip(RoundedCornerShape(CARD_RADIUS)) .clickable(onClickLabel = stringResource(R.string.board_open_note), onClick = onOpen) .background(tint.cardBackground(dark, strong)) - .border(1.dp, tint.border(dark), RoundedCornerShape(CARD_RADIUS)) .padding(12.dp), ) { // Body then checklist, in order — a note can carry both (M13 step 2), and @@ -236,4 +248,10 @@ private const val MAX_PREVIEW_LINES = 8 private const val MAX_WRAPPED_LINES = 2 private const val MAX_LABEL_CHIPS = 3 private val CARD_RADIUS = 12.dp + +// Enough to lift a white card off a #fafafa board and no more. Material's own +// resting card elevation is 1dp; this is a shade above it because the board is a +// masonry field of cards rather than one card on a page, and at 1dp the whole field +// reads flat again — which is what the removed border was compensating for. +private val CARD_ELEVATION = 2.dp private val CHIP_RADIUS = 6.dp diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteTint.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteTint.kt index e324ee1..3c30e6a 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteTint.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteTint.kt @@ -14,9 +14,10 @@ import com.fabledsword.thoughtsync.core.Note * is amber on the desktop is the same amber on the phone rather than a near-miss. * Generated from tailwindcss 3.4's palette rather than transcribed by eye. * - * Dark tints keep the web's ALPHA (`dark:bg-red-950/40`) instead of a + * Most dark tints keep the web's ALPHA (`dark:bg-red-950/70`) instead of a * precomputed blend — Compose composites a translucent colour over what's beneath - * exactly as CSS does, so the card sits on the background the same way in both. + * exactly as CSS does, so the card sits on the background the same way in both. The + * one exception is [NoteTint.darkCardSubdued], which is baked; see its own note. * * `yellow` maps to Tailwind's *amber*, matching colors.ts; plain yellow is too * acid against the neutral surfaces. @@ -31,6 +32,28 @@ data class NoteTint( val lightChipForeground: Color, val darkChipBackground: Color, val darkChipForeground: Color, + /** + * The DARK subdued card fill, opaque, as a colour rather than an alpha. + * + * Every other dark value in this table is `{hue}-950` at an alpha, composited by + * whoever draws it over the near-black BOARD — which put an untagged card at the + * board's own lightness (a 1.03 contrast) and left the border doing all the work + * of saying "card". The border is gone (see [cardBackground]), so these are + * `{hue}-950` composited at 0.18 over the CARD SURFACE, #171717, and baked: the + * card now sits where the plain white card always sat, 1.11–1.14 against the + * board, while carrying LESS hue than the old ramp did — chroma 7–17 against + * 10–23. + * + * Opaque rather than another alpha because the backdrop is no longer the board + * alone: the editor draws the same fill on a sheet. A colour that means one thing + * on the board and another in the editor is the bug this whole milestone exists + * to stop. + * + * Baked rather than lerped at draw time: Compose's `lerp(Color, Color, Float)` + * interpolates in Oklab, CSS alpha-composites in sRGB, and the mirror in + * colors.ts is only worth having if both sides land on the same byte. + */ + val darkCardSubdued: 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, @@ -42,21 +65,34 @@ data class NoteTint( fun background(dark: Boolean): Color = if (dark) darkBackground else lightBackground /** - * A NOTE card's fill, at one of two weights. + * A NOTE card's fill, at one of two weights, and the card's ONLY boundary. * * `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. + * THE CARD NO LONGER HAS A BORDER. It used to carry a 1px `{hue}-900` edge, and + * measured against its own fill that line was a 1.56–2.09 contrast where the fill + * managed 1.03–1.05 against the board. The loudest thing on every card was an + * identical line in an identical place, so a board of them read as a grid of + * outlined rectangles whatever colour was inside — the operator's "too samey even + * with the color differences". [border] survives for panels, banners and pickers, + * which are single elements rather than a field of them; a note card gets none. * - * Borders are untouched. The fill is the signal; moving both muddies the edge. + * What replaces it differs by theme, because elevation does. Dark leans on + * [darkCardSubdued] sitting above the near-black board. Light leans on a real + * shadow (`NoteCard.kt`), since white-on-#fafafa is a 1.04 contrast and no fill + * is going to rescue it. + * + * WHAT SEPARATES THE TWO WEIGHTS IS CHROMA, NOT LIGHTNESS. In dark they now sit + * within a hair of each other (red: 1.11 against the board versus 1.12) and + * differ threefold in colour (chroma 10 versus 41). Lightness is what says "this + * is a card"; spending it on emphasis is what left untagged cards flat. + * + * Light was already built this way and is untouched: `-50` and `-100` are both + * white plus a different amount of hue, and `-100` is exactly + * [lightChipBackground], already in this table, so no new hex is transcribed. */ fun cardBackground( dark: Boolean, @@ -64,7 +100,8 @@ data class NoteTint( ): Color = when { !tintable -> background(dark) - dark -> darkBackground.copy(alpha = if (strong) STRONG_DARK_ALPHA else SUBDUED_DARK_ALPHA) + dark && strong -> darkBackground.copy(alpha = STRONG_DARK_ALPHA) + dark -> darkCardSubdued strong -> lightChipBackground else -> lightBackground } @@ -103,10 +140,9 @@ data class NoteTint( // it does not carry the meaning. Raise to 0.80 if that judgment is ever overruled. private const val CHIP_EDGE_ALPHA = 0.60f -// 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 +// The chosen weight in dark, as a fraction. Mirrors `dark:bg-{hue}-950/70` in +// colors.ts. Its counterpart is no longer an alpha at all — the subdued weight is a +// baked colour, [NoteTint.darkCardSubdued], for the reasons recorded there. private const val STRONG_DARK_ALPHA = 0.70f /** Keyed by the core's colour vocabulary. Order matches the web's picker. */ @@ -123,6 +159,7 @@ val NOTE_TINTS: Map = lightChipForeground = Color(0xFF525252), darkChipBackground = Color(0x1AFFFFFF), darkChipForeground = Color(0xFFD4D4D4), + darkCardSubdued = Color(0xFF171717), tintable = false, ), "red" to @@ -136,6 +173,7 @@ val NOTE_TINTS: Map = lightChipForeground = Color(0xFFB91C1C), darkChipBackground = Color(0x80450A0A), darkChipForeground = Color(0xFFFCA5A5), + darkCardSubdued = Color(0xFF1F1515), ), "orange" to NoteTint( @@ -148,6 +186,7 @@ val NOTE_TINTS: Map = lightChipForeground = Color(0xFFC2410C), darkChipBackground = Color(0x80431407), darkChipForeground = Color(0xFFFDBA74), + darkCardSubdued = Color(0xFF1F1614), ), "yellow" to NoteTint( @@ -160,6 +199,7 @@ val NOTE_TINTS: Map = lightChipForeground = Color(0xFF92400E), darkChipBackground = Color(0x80451A03), darkChipForeground = Color(0xFFFCD34D), + darkCardSubdued = Color(0xFF1F1813), ), "green" to NoteTint( @@ -172,6 +212,7 @@ val NOTE_TINTS: Map = lightChipForeground = Color(0xFF15803D), darkChipBackground = Color(0x80052E16), darkChipForeground = Color(0xFF86EFAC), + darkCardSubdued = Color(0xFF141B17), ), "teal" to NoteTint( @@ -184,6 +225,7 @@ val NOTE_TINTS: Map = lightChipForeground = Color(0xFF0F766E), darkChipBackground = Color(0x80042F2E), darkChipForeground = Color(0xFF5EEAD4), + darkCardSubdued = Color(0xFF141B1B), ), "blue" to NoteTint( @@ -196,6 +238,7 @@ val NOTE_TINTS: Map = lightChipForeground = Color(0xFF1D4ED8), darkChipBackground = Color(0x80172554), darkChipForeground = Color(0xFF93C5FD), + darkCardSubdued = Color(0xFF171A22), ), "purple" to NoteTint( @@ -208,6 +251,7 @@ val NOTE_TINTS: Map = lightChipForeground = Color(0xFF7E22CE), darkChipBackground = Color(0x803B0764), darkChipForeground = Color(0xFFD8B4FE), + darkCardSubdued = Color(0xFF1D1425), ), "pink" to NoteTint( @@ -220,6 +264,7 @@ val NOTE_TINTS: Map = lightChipForeground = Color(0xFFBE185D), darkChipBackground = Color(0x80500724), darkChipForeground = Color(0xFFF9A8D4), + darkCardSubdued = Color(0xFF211419), ), "gray" to NoteTint( @@ -232,6 +277,7 @@ val NOTE_TINTS: Map = lightChipForeground = Color(0xFF404040), darkChipBackground = Color(0xFF404040), darkChipForeground = Color(0xFFE5E5E5), + darkCardSubdued = Color(0xFF1A1A1A), ), ) diff --git a/frontend/src/components/NoteCard.vue b/frontend/src/components/NoteCard.vue index 34052b5..d392d41 100644 --- a/frontend/src/components/NoteCard.vue +++ b/frontend/src/components/NoteCard.vue @@ -222,7 +222,7 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))