The border was the thing making every note look the same
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 9s
CI & Build / Python tests (push) Successful in 14s
CI & Build / integration (push) Successful in 18s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m8s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m23s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 8m4s

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-26 14:21:42 -04:00
co-authored by Claude Opus 5
parent fe18aaa956
commit 47f108c9c8
4 changed files with 147 additions and 43 deletions
@@ -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
@@ -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.111.14 against the
* board, while carrying LESS hue than the old ramp did — chroma 717 against
* 1023.
*
* 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.562.09 contrast where the fill
* managed 1.031.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<String, NoteTint> =
lightChipForeground = Color(0xFF525252),
darkChipBackground = Color(0x1AFFFFFF),
darkChipForeground = Color(0xFFD4D4D4),
darkCardSubdued = Color(0xFF171717),
tintable = false,
),
"red" to
@@ -136,6 +173,7 @@ val NOTE_TINTS: Map<String, NoteTint> =
lightChipForeground = Color(0xFFB91C1C),
darkChipBackground = Color(0x80450A0A),
darkChipForeground = Color(0xFFFCA5A5),
darkCardSubdued = Color(0xFF1F1515),
),
"orange" to
NoteTint(
@@ -148,6 +186,7 @@ val NOTE_TINTS: Map<String, NoteTint> =
lightChipForeground = Color(0xFFC2410C),
darkChipBackground = Color(0x80431407),
darkChipForeground = Color(0xFFFDBA74),
darkCardSubdued = Color(0xFF1F1614),
),
"yellow" to
NoteTint(
@@ -160,6 +199,7 @@ val NOTE_TINTS: Map<String, NoteTint> =
lightChipForeground = Color(0xFF92400E),
darkChipBackground = Color(0x80451A03),
darkChipForeground = Color(0xFFFCD34D),
darkCardSubdued = Color(0xFF1F1813),
),
"green" to
NoteTint(
@@ -172,6 +212,7 @@ val NOTE_TINTS: Map<String, NoteTint> =
lightChipForeground = Color(0xFF15803D),
darkChipBackground = Color(0x80052E16),
darkChipForeground = Color(0xFF86EFAC),
darkCardSubdued = Color(0xFF141B17),
),
"teal" to
NoteTint(
@@ -184,6 +225,7 @@ val NOTE_TINTS: Map<String, NoteTint> =
lightChipForeground = Color(0xFF0F766E),
darkChipBackground = Color(0x80042F2E),
darkChipForeground = Color(0xFF5EEAD4),
darkCardSubdued = Color(0xFF141B1B),
),
"blue" to
NoteTint(
@@ -196,6 +238,7 @@ val NOTE_TINTS: Map<String, NoteTint> =
lightChipForeground = Color(0xFF1D4ED8),
darkChipBackground = Color(0x80172554),
darkChipForeground = Color(0xFF93C5FD),
darkCardSubdued = Color(0xFF171A22),
),
"purple" to
NoteTint(
@@ -208,6 +251,7 @@ val NOTE_TINTS: Map<String, NoteTint> =
lightChipForeground = Color(0xFF7E22CE),
darkChipBackground = Color(0x803B0764),
darkChipForeground = Color(0xFFD8B4FE),
darkCardSubdued = Color(0xFF1D1425),
),
"pink" to
NoteTint(
@@ -220,6 +264,7 @@ val NOTE_TINTS: Map<String, NoteTint> =
lightChipForeground = Color(0xFFBE185D),
darkChipBackground = Color(0x80500724),
darkChipForeground = Color(0xFFF9A8D4),
darkCardSubdued = Color(0xFF211419),
),
"gray" to
NoteTint(
@@ -232,6 +277,7 @@ val NOTE_TINTS: Map<String, NoteTint> =
lightChipForeground = Color(0xFF404040),
darkChipBackground = Color(0xFF404040),
darkChipForeground = Color(0xFFE5E5E5),
darkCardSubdued = Color(0xFF1A1A1A),
),
)