tags: one ink, chip and inline, and the chip edge solved for 3:1
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 5s
CI & Build / TypeScript typecheck (push) Successful in 11s
CI & Build / Python tests (push) Successful in 14s
CI & Build / integration (push) Successful in 21s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m31s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 6m1s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Android / Kotlin + Rust (APK) (push) Successful in 8m45s

Step 1 left one card surface per theme, so the tag ink is no longer
choosing a value that has to clear twenty backgrounds. Re-measured against
the one it actually lands on, the two tables collapse into one.

`-800` in light, `-300` in dark, for a `#tag` in the prose AND for a chip's
text. Dark needed no decision at all — the two tables already held the same
value for all ten hues, which is most of the argument on its own. Light
collapses onto the INLINE column deliberately: since M311 a tag whose text
is in the body is drawn where it was typed and not repeated as a chip, so
inline is the common case and this leaves what is seen most exactly as it
was. The chip is strictly better for the move:

                    inline, on the card    as a chip, on its own fill
  light `-800`      7.09 - 15.13           6.37 - 12.01  (was 4.52 - 8.23)
  dark  `-300`      9.45 - 14.23           8.23 - 11.88  (unchanged)

The chip edge goes 0.60 -> 0.65, and this is the first time that number
could be solved rather than judged. 0.60 was picked against a chip sitting
on a card of its own colour, a case that no longer exists; against a known
fill the smallest alpha clearing the 3:1 of WCAG 1.4.11 for all ten hues is
arithmetic. 0.60 gives 2.75-3.82 and misses for six of them, 0.65 gives
3.03-4.36 and misses for none. Dark runs 4.52-5.76.

That edge is doing more work than it looks: a chip's fill measures 1.02-1.26
against the card in light and 1.02-1.73 in dark, and dark red at 1.02 is
invisible. The ring is the pill; the fill only tints it.

`LABEL_CHIP_CLASSES` becomes `LABEL_CHIP_SHELL` — fill and edge, no ink —
and `labelChipClasses(label)` composes shell and ink in one place. The board
and the editor each had their own copy of that composition, with a comment
on one of them asking the other to stay in step. Now it is one call.

Fixed on the way past: the web drew `default`'s chip ring at `black/10`
(1.36 against its own fill) where Compose derived it from the ink (3.21) —
the same chip, visibly different pills. Both are the ink at 65% now.

`chipForeground` stays, narrowed to what it always actually was: the
REMINDER pill's ink, transcribed from NoteCard.vue's literal red-700 /
neutral-600. It is not a tag and must not move with one.

Also gone: `NOTE_NODE_FILL`, a per-hue table of solid hexes for graph nodes
with no consumer anywhere in the repo.

Step 2 of M315. #3149

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-28 13:13:43 -04:00
co-authored by Claude Opus 5
parent b91091caca
commit 13a88179b8
6 changed files with 131 additions and 100 deletions
@@ -331,7 +331,7 @@ fun EditorLabelRow(
// "from tag" hint beside one is what says why it has no cross.
text = "#${label.name}",
style = MaterialTheme.typography.labelLarge,
color = tint.chipForeground(dark),
color = tint.tagInk(dark),
modifier =
Modifier
.clip(CircleShape)
@@ -375,7 +375,7 @@ private fun LabelChips(labels: List<NoteLabel>) {
// also keeps a lifted chip reading as the `#todo` somebody wrote.
text = "#${label.name}",
style = MaterialTheme.typography.labelSmall,
color = tint.chipForeground(dark),
color = tint.tagInk(dark),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier =
@@ -33,24 +33,33 @@ data class NoteTint(
val darkBackground: Color,
val darkBorder: Color,
val lightChipBackground: Color,
val lightChipForeground: Color,
val darkChipBackground: Color,
/**
* The REMINDER pill's ink, and nothing else's — see [chipForeground].
*
* Only two of these ten are ever read (`red` when a reminder has passed, `default`
* otherwise). They stay a per-hue column because they are transcribed from the
* web's literals rather than derived from anything here.
*/
val lightChipForeground: Color,
val darkChipForeground: Color,
/**
* The colour a `#tag` is drawn in when it is left in the prose — see [tagInk].
* THE INK A TAG IS DRAWN IN — inline in the prose AND as a chip's text — see
* [tagInk].
*
* A SEPARATE value from [lightChipForeground] rather than a reuse of it, and the
* difference is not cosmetic. A chip brings its own `-100` fill, so its text only
* ever has to read against that one colour. Inline text sits on whatever the card
* happens to be, which includes the gray-tagged card at `neutral-200` — and there
* the chip's `-700` measured 3.98 (green), 4.11 (orange) and 4.34 (teal), all
* under the 4.5 body text needs. One step deeper puts every hue between 5.63 and
* 12.01 in light and 7.20 and 10.84 in dark, across every fill that existed then,
* so it was one rule rather than three exceptions.
* These were two columns until M315, and the split was real while it lasted: a chip
* brought its own `-100` fill and could afford `-700`, while inline text sat on
* whatever the card was, which included a gray-tagged card at `neutral-200` where
* `-700` measured 3.98 (green), 4.11 (orange) and 4.34 (teal), all under the 4.5
* body text needs. One step deeper cleared every fill at once.
*
* THE CONSTRAINT IS GONE as of M315 — one card fill per theme means this is solving
* for two backgrounds instead of twenty. Left here unchanged because it still
* clears 4.5 on both; re-measuring against one known background is #3149.
* The twenty card fills that split was solving for are gone, so both jobs take this
* one value. The direction is deliberate: since M311 a tag whose text is in the body
* is drawn where it was typed and NOT repeated as a chip, so the inline token is the
* common case and collapsing onto ITS column leaves what is seen most exactly as it
* was. The chip is strictly better for the move — on its own fill it goes from
* 4.52-8.23 to 6.37-12.01 in light. Dark needed no decision: the two columns already
* held the same value for all ten hues.
*/
val lightTagInk: Color,
val darkTagInk: Color,
@@ -66,10 +75,19 @@ data class NoteTint(
fun chipBackground(dark: Boolean): Color = if (dark) darkChipBackground else lightChipBackground
/**
* The REMINDER pill's ink. NOT a tag's — a tag takes [tagInk] wherever it is drawn.
*
* Kept apart from [tagInk] because the reminder pill is not a tag: it borrows the
* chip's shape and its `red-100`/`black-5` fills, and its `red-700`/`neutral-600`
* text is transcribed from NoteCard.vue's literal classes. The two happened to be
* one value; making the tag ink one step deeper (M315) is where they parted, and
* moving the reminder with it would have silently broken that mirror instead.
*/
fun chipForeground(dark: Boolean): Color = if (dark) darkChipForeground else lightChipForeground
/**
* The colour for a `#tag` still sitting in the note's own words.
* The colour a `#tag` is drawn in — in the note's own words, or as a chip.
*
* A tag whose text is in the body is no longer repeated as a chip (the card was
* printing every tag twice — once where it was typed, once at the top). It is
@@ -79,32 +97,37 @@ data class NoteTint(
fun tagInk(dark: Boolean): Color = if (dark) darkTagInk else lightTagInk
/**
* A hairline edge for a chip, in its own text colour at low alpha.
* A hairline edge for a chip, in its own ink at low alpha.
*
* Needed because a chip's fill can no longer be trusted to differ from what is
* behind it. A tagged note takes its FIRST tag's colour, and that card is drawn
* at exactly [lightChipBackground] — the same value the chip uses — so in light
* mode the pill was measured at a contrast ratio of 1.00 against the card it had
* itself coloured. Perfectly invisible: the tag name read as loose text.
* THE EDGE IS THE PILL. Against the one card surface a chip's fill measures
* 1.02-1.26 in light and 1.02-1.73 in dark — very nearly nothing, and dark red at
* 1.02 is literally invisible. Without this the tag name would read as loose text.
* The fill only tints a shape the edge is drawing.
*
* An edge rather than shifting the fill, because the fill can collide with any
* card colour and chasing that needs the chip to know what it is sitting on. A
* border in the chip's own foreground always reads, against any background, and
* needs no plumbing.
* An edge rather than a heavier fill, because a fill loud enough to hold its own
* shape would be the loudest thing on a board whose whole point is now that the tag
* is the one coloured thing on it.
*/
fun chipBorder(dark: Boolean): Color = chipForeground(dark).copy(alpha = CHIP_EDGE_ALPHA)
fun chipBorder(dark: Boolean): Color = tagInk(dark).copy(alpha = CHIP_EDGE_ALPHA)
}
// How strongly a chip's edge is drawn, as a fraction of its own text colour.
// How strongly a chip's edge is drawn, as a fraction of its own ink.
//
// 0.60 measured, not guessed: against the worst case — a chip on a card of its own
// colour — this is a 2.32:1 boundary, where 0.30 gave only 1.49:1 and was effectively
// no edge at all. It does NOT reach the 3:1 of WCAG 1.4.11, which needs 0.80 and
// draws a hard outline rather than a hairline. 1.4.11 governs boundaries that carry
// REQUIRED information, and a chip's information is its text — which passes AA at
// 8:1 or better on every card in the palette. The edge restores the pill's shape;
// it does not carry the meaning. Raise to 0.80 if that judgment is ever overruled.
private const val CHIP_EDGE_ALPHA = 0.60f
// SOLVED FOR, NOT GUESSED — and re-solved once the answer became solvable. 0.60 was
// picked against the worst case of the time: a chip on a card of its OWN colour, back
// when a note took its first tag's fill. It gave 2.32:1 there, missed the 3:1 of WCAG
// 1.4.11, and the comment here reasoned its way out of that on the grounds that a
// chip's information is its text.
//
// M315 removed that worst case. The edge is now the ink at alpha over a KNOWN fill, so
// the smallest alpha clearing 3:1 for all ten hues is arithmetic rather than judgment:
// 0.60 gives 2.75-3.82 in light and misses for six of the ten, 0.65 gives 3.03-4.36 and
// misses for none. Dark runs 4.52-5.76. The old comment named 0.80 as the fallback if
// the judgment were ever overruled; it is not needed, and it draws a hard outline where
// a hairline does the job.
//
// Mirrored on the web as the `/65` in LABEL_CHIP_SHELL's ring.
private const val CHIP_EDGE_ALPHA = 0.65f
/** Keyed by the core's colour vocabulary. Order matches the web's picker. */
val NOTE_TINTS: Map<String, NoteTint> =