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),
),
)
+9 -1
View File
@@ -222,7 +222,7 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
<template>
<div
ref="root"
class="group relative mb-4 break-inside-avoid rounded-xl border p-3 shadow-sm transition hover:shadow-md"
class="group relative mb-4 break-inside-avoid rounded-xl p-3 shadow transition hover:shadow-md"
:class="[
noteCardClasses(note),
dragging ? 'opacity-40' : '',
@@ -233,6 +233,14 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
]"
:data-note-id="note.id"
>
<!-- No `border`. The hue border that used to frame every card outshouted the fill
it framed and turned the board into a grid of identical outlines see the
measurements above NOTE_CARD_CLASSES. `shadow` rather than `shadow-sm` because
it is now the ONLY thing separating a light-mode card from the board: an
untagged card is `bg-red-50` on `bg-neutral-50`, a 1.05 contrast that reads as
a card purely by sitting above one. Dark mode gets no shadow worth the name
against a near-black board, which is why the subdued fills moved onto the card
surface instead. -->
<img
v-if="firstImage"
:src="firstImage.url"
+58 -26
View File
@@ -17,17 +17,43 @@ export const NOTE_COLOR_KEYS = [
export type NoteColor = (typeof NOTE_COLOR_KEYS)[number];
// The SUBDUED ramp — what a note wears when nothing chose a colour for it.
//
// NO BORDER, here or in the strong ramp below. A note card used to carry a 1px
// `border-{hue}-900`, and measured against its own fill that line was a 1.562.09
// contrast while the fill managed only 1.031.05 against the board. The loudest
// thing on every card was therefore an identical line in an identical place, and a
// board of them read as a grid of outlined rectangles no matter what colour was
// inside — "too samey even with the color differences". The card's boundary now
// comes from its fill in dark mode and from `shadow` in light (NoteCard.vue), both
// of which vary with the card instead of framing it.
//
// The dark values are COMPOSITED HEX rather than a Tailwind step, and that is the
// whole idea. `dark:bg-red-950/25` laid a hue over the near-black BOARD, which put
// the card at the board's own lightness (1.03) — invisible without the border it
// has just lost. These lay the same hue over the CARD SURFACE (`neutral-900`,
// #171717) at 18%, so an untagged card sits exactly where the default white card
// always sat (1.111.14 vs the board, against `bg-neutral-900`'s 1.10) while
// carrying LESS colour than before: chroma 717 where the old ramp had 1023.
//
// Subtler and more visible at once, which is only a contradiction if you assume
// subtlety has to come from lightness. Here it comes from chroma, and lightness is
// left to say "this is a card".
//
// Recipe, so these can be regenerated rather than guessed at: sRGB alpha
// compositing of `{hue}-950` at 0.18 over #171717 — `round(fg*0.18 + 23*0.82)` per
// channel. `gray` uses `neutral-800` as its 950, matching the strong ramp.
export const NOTE_CARD_CLASSES: Record<NoteColor, string> = {
default: "bg-white border-neutral-200 dark:bg-neutral-900 dark:border-neutral-700",
red: "bg-red-50 border-red-200 dark:bg-red-950/25 dark:border-red-900",
orange: "bg-orange-50 border-orange-200 dark:bg-orange-950/25 dark:border-orange-900",
yellow: "bg-amber-50 border-amber-200 dark:bg-amber-950/25 dark:border-amber-900",
green: "bg-green-50 border-green-200 dark:bg-green-950/25 dark:border-green-900",
teal: "bg-teal-50 border-teal-200 dark:bg-teal-950/25 dark:border-teal-900",
blue: "bg-blue-50 border-blue-200 dark:bg-blue-950/25 dark:border-blue-900",
purple: "bg-purple-50 border-purple-200 dark:bg-purple-950/25 dark:border-purple-900",
pink: "bg-pink-50 border-pink-200 dark:bg-pink-950/25 dark:border-pink-900",
gray: "bg-neutral-100 border-neutral-300 dark:bg-neutral-800/25 dark:border-neutral-700",
default: "bg-white dark:bg-neutral-900",
red: "bg-red-50 dark:bg-[#1f1515]",
orange: "bg-orange-50 dark:bg-[#1f1614]",
yellow: "bg-amber-50 dark:bg-[#1f1813]",
green: "bg-green-50 dark:bg-[#141b17]",
teal: "bg-teal-50 dark:bg-[#141b1b]",
blue: "bg-blue-50 dark:bg-[#171a22]",
purple: "bg-purple-50 dark:bg-[#1d1425]",
pink: "bg-pink-50 dark:bg-[#211419]",
gray: "bg-neutral-100 dark:bg-[#1a1a1a]",
};
export const NOTE_SWATCH_CLASSES: Record<NoteColor, string> = {
@@ -204,24 +230,30 @@ export function resolveLabelColor(label: { name: string; color?: string | null }
// (until step 5) by the picker. One Tailwind step deeper in light mode, and a much
// heavier fill in dark.
//
// The two ramps move in opposite directions per theme, because that is where each has
// headroom. Light mode cannot go lighter than `-50` without being white again, so the
// gap is opened by deepening the tagged end to `-100`. Dark mode CAN go lighter, so
// the untagged end drops to `/25` — closer to the board, which also gives the light
// body text MORE contrast rather than less.
// UNCHANGED by the border pass, deliberately. The operator's complaint was the edge
// line and the loudness of the DERIVED tint; the chosen colours were "pretty well"
// where they landed, and a ramp somebody has already signed off on is not something
// to redo while fixing something else.
//
// Borders stay put. The fill is the signal; moving both just muddies the edge.
// WHAT SEPARATES THE TWO RAMPS IS NOW CHROMA, NOT LIGHTNESS. Since the subdued ramp
// moved onto the card surface, the two sit at almost the same lightness in dark mode
// (red: 1.11 vs 1.12 against the board) and differ threefold in colour (chroma 10 vs
// 41). That is the better axis anyway: lightness is what says "this is a card", and
// spending it on emphasis is what left untagged cards flat against the board.
//
// Light mode was already built this way and needed no change — `-50` and `-100` are
// both essentially white plus a different amount of hue.
export const NOTE_CARD_CLASSES_STRONG: Record<NoteColor, string> = {
default: "bg-white border-neutral-200 dark:bg-neutral-900 dark:border-neutral-700",
red: "bg-red-100 border-red-200 dark:bg-red-950/70 dark:border-red-900",
orange: "bg-orange-100 border-orange-200 dark:bg-orange-950/70 dark:border-orange-900",
yellow: "bg-amber-100 border-amber-200 dark:bg-amber-950/70 dark:border-amber-900",
green: "bg-green-100 border-green-200 dark:bg-green-950/70 dark:border-green-900",
teal: "bg-teal-100 border-teal-200 dark:bg-teal-950/70 dark:border-teal-900",
blue: "bg-blue-100 border-blue-200 dark:bg-blue-950/70 dark:border-blue-900",
purple: "bg-purple-100 border-purple-200 dark:bg-purple-950/70 dark:border-purple-900",
pink: "bg-pink-100 border-pink-200 dark:bg-pink-950/70 dark:border-pink-900",
gray: "bg-neutral-200 border-neutral-300 dark:bg-neutral-800/70 dark:border-neutral-700",
default: "bg-white dark:bg-neutral-900",
red: "bg-red-100 dark:bg-red-950/70",
orange: "bg-orange-100 dark:bg-orange-950/70",
yellow: "bg-amber-100 dark:bg-amber-950/70",
green: "bg-green-100 dark:bg-green-950/70",
teal: "bg-teal-100 dark:bg-teal-950/70",
blue: "bg-blue-100 dark:bg-blue-950/70",
purple: "bg-purple-100 dark:bg-purple-950/70",
pink: "bg-pink-100 dark:bg-pink-950/70",
gray: "bg-neutral-200 dark:bg-neutral-800/70",
};
/**