board: a tag in the prose is coloured where it sits, not printed twice
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 10s
CI & Build / integration (push) Successful in 16s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 2m37s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m53s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Canceled after 3m25s

A tagged note was showing its tag twice — once where it was typed, once as a
chip — and the duplicate was the loud copy. Now the chip row carries only what
the body cannot say (a tag lifted off its own line, a label from the picker),
and a `#tag` left mid-sentence is tinted in place.

Which characters are a tag is asked of the CORE, the way the card already asks
it which lines are checklist items: `extract_tag_spans` keeps the spans
`extract_tags` throws away, and `body_tags` hands them to Kotlin. Offsets are
UTF-16 code units, because `AnnotatedString` and JS both index that way and a
char index lands mid-token the first time somebody writes an emoji. The web
keeps its own matcher in markdown.ts, mirroring `line_tags` case for case.

The inline ink is its own table, one Tailwind step deeper than the chip's. A
chip brings its own -100 fill and reads against that alone; inline text sits on
whatever the card is, including a gray-tagged card at neutral-200 — where the
chip's -700 measured 3.98 (green), 4.11 (orange) and 4.34 (teal), under the 4.5
body text needs. At -800/-300 every hue lands 5.63-12.01 light and 7.20-10.84
dark across every palette and generated fill.

Chips now carry the `#` on every surface. The via_tag branch that used to
decide it is gone from the card, and Android's row said no hash at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-26 22:20:17 -04:00
co-authored by Claude Opus 5
parent 8c22425e91
commit d9e5753dc2
13 changed files with 397 additions and 30 deletions
@@ -336,7 +336,10 @@ fun EditorLabelRow(
modifier = Modifier.padding(vertical = 2.dp), modifier = Modifier.padding(vertical = 2.dp),
) { ) {
Text( Text(
text = label.name, // `#` on every chip, matching the card. This row still shows the
// tags the BODY owns as well — it is the control surface, and the
// "from tag" hint beside one is what says why it has no cross.
text = "#${label.name}",
style = MaterialTheme.typography.labelLarge, style = MaterialTheme.typography.labelLarge,
color = tint.chipForeground(dark), color = tint.chipForeground(dark),
modifier = modifier =
@@ -22,7 +22,11 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -30,6 +34,7 @@ import com.fabledsword.thoughtsync.R
import com.fabledsword.thoughtsync.core.BodyItem import com.fabledsword.thoughtsync.core.BodyItem
import com.fabledsword.thoughtsync.core.Note import com.fabledsword.thoughtsync.core.Note
import com.fabledsword.thoughtsync.core.NoteLabel import com.fabledsword.thoughtsync.core.NoteLabel
import com.fabledsword.thoughtsync.core.bodyTags
import com.fabledsword.thoughtsync.core.checklistItems import com.fabledsword.thoughtsync.core.checklistItems
@Composable @Composable
@@ -67,8 +72,16 @@ fun NoteCard(
// note's NAME (M13 steps 3 and 4) and a chip floated next to it would compete // note's NAME (M13 steps 3 and 4) and a chip floated next to it would compete
// with the thing that identifies the note. A row of its own costs one line and // with the thing that identifies the note. A row of its own costs one line and
// only on notes that have tags at all. // only on notes that have tags at all.
if (note.labels.isNotEmpty()) { //
LabelChips(labels = note.labels) // ONLY the labels whose text is not still in the note. `via_tag` means exactly
// "backed by body text" since M311, so a chip for one printed the same tag
// twice — once where it was typed, once up here — and the card was carrying
// furniture for information it was already showing. A tag left in prose is
// tinted in place instead; see [tintTags]. What reaches this row is what the
// body cannot say: a tag lifted off its own line, and a label added by hand.
val chips = note.labels.filterNot { it.viaTag }
if (chips.isNotEmpty()) {
LabelChips(labels = chips)
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
} }
@@ -129,13 +142,13 @@ private fun NoteBody(
val found = itemAtLine[n] val found = itemAtLine[n]
when { when {
found != null -> found != null ->
ChecklistRow(found.second) { onToggleItem(found.first, !found.second.checked) } ChecklistRow(note, found.second) { onToggleItem(found.first, !found.second.checked) }
// Kept as a gap rather than dropped: it is the paragraph break // Kept as a gap rather than dropped: it is the paragraph break
// somebody typed, and the card reads as a wall without it. // somebody typed, and the card reads as a wall without it.
line.isBlank() -> Spacer(Modifier.height(4.dp)) line.isBlank() -> Spacer(Modifier.height(4.dp))
else -> else ->
Text( Text(
text = line, text = tintTags(line, note),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
maxLines = MAX_WRAPPED_LINES, maxLines = MAX_WRAPPED_LINES,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
@@ -163,6 +176,7 @@ private fun NoteBody(
*/ */
@Composable @Composable
private fun ChecklistRow( private fun ChecklistRow(
note: Note,
item: BodyItem, item: BodyItem,
onToggle: () -> Unit, onToggle: () -> Unit,
) { ) {
@@ -176,7 +190,7 @@ private fun ChecklistRow(
.padding(end = 6.dp), .padding(end = 6.dp),
) )
Text( Text(
text = item.text, text = tintTags(item.text, note),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
textDecoration = if (item.checked) TextDecoration.LineThrough else null, textDecoration = if (item.checked) TextDecoration.LineThrough else null,
color = color =
@@ -191,6 +205,58 @@ private fun ChecklistRow(
} }
} }
/**
* One string of a note's own words, with every `#tag` in it drawn in that tag's colour.
*
* This is what replaced the chip for a tag still living in the prose. The card used to
* print such a tag twice — once where it was typed and once in the row above — and the
* duplicate was the loud copy, which made a tagged note read as "tag, then some text
* that happens to start with the same word". Colouring it in place says the same thing
* with no furniture, and says it more honestly: the token you can see IS the text you
* would delete to remove the tag.
*
* WHICH characters are a tag is asked of the core, exactly as [NoteBody] asks it which
* lines are checklist items. The grammar already exists three times (Rust, Python,
* TypeScript); a fourth in Compose would be a fourth thing to disagree — and this one
* would fail silently, as the wrong characters tinted rather than an error anywhere.
* The core's offsets are UTF-16 code units for this call site specifically, which is
* the only unit `addStyle` can take.
*
* Called per rendered STRING rather than once per body so a checklist item's text can
* be handled with no arithmetic: an item is a line minus a `- [ ] ` prefix of a length
* nothing carries, and shifting spans by a guessed prefix is the kind of off-by-one
* that shows up only on the one note that had a tag in a list.
*/
@Composable
private fun tintTags(
text: String,
note: Note,
): AnnotatedString {
val dark = isSystemInDarkTheme()
return remember(text, note.labels, dark) {
val spans = bodyTags(text)
if (spans.isEmpty()) {
AnnotatedString(text)
} else {
// A tag the note does not carry as a label yet — just typed, not yet
// derived — still gets a colour: `labelTint` falls back to deriving one
// from the name, which is what the chip would have shown anyway.
val picked = note.labels.associate { it.name.lowercase() to it.color }
buildAnnotatedString {
append(text)
spans.forEach { tag ->
val tint = labelTint(tag.name, picked[tag.name.lowercase()].orEmpty())
addStyle(
SpanStyle(color = tint.tagInk(dark), fontWeight = FontWeight.Medium),
tag.start.toInt(),
tag.end.toInt(),
)
}
}
}
}
}
@Composable @Composable
private fun LabelChips(labels: List<NoteLabel>) { private fun LabelChips(labels: List<NoteLabel>) {
val dark = isSystemInDarkTheme() val dark = isSystemInDarkTheme()
@@ -200,7 +266,11 @@ private fun LabelChips(labels: List<NoteLabel>) {
labels.take(MAX_LABEL_CHIPS).forEach { label -> labels.take(MAX_LABEL_CHIPS).forEach { label ->
val tint = labelTintFor(label.name, label.color) val tint = labelTintFor(label.name, label.color)
Text( Text(
text = label.name, // The `#` is carried on every chip, because everything that reaches
// this row is a tag — a tag lifted off its own line, or one attached
// through the picker — and the hash is how you would type either. It
// also keeps a lifted chip reading as the `#todo` somebody wrote.
text = "#${label.name}",
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = tint.chipForeground(dark), color = tint.chipForeground(dark),
maxLines = 1, maxLines = 1,
@@ -34,6 +34,20 @@ data class NoteTint(
val lightChipForeground: Color, val lightChipForeground: Color,
val darkChipBackground: Color, val darkChipBackground: Color,
val darkChipForeground: Color, val darkChipForeground: Color,
/**
* The colour a `#tag` is drawn in when it is left in the prose — 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 in the palette and
* every generated fill, so it is one rule rather than three exceptions.
*/
val lightTagInk: Color,
val darkTagInk: Color,
/** /**
* False only for `default`, which is the ABSENCE of a colour rather than one of * False only for `default`, which is the ABSENCE of a colour rather than one of
* them. A tint can be drawn at the chosen weight (see [chosenBackground]); * them. A tint can be drawn at the chosen weight (see [chosenBackground]);
@@ -81,6 +95,16 @@ data class NoteTint(
fun chipForeground(dark: Boolean): Color = if (dark) darkChipForeground else lightChipForeground fun chipForeground(dark: Boolean): Color = if (dark) darkChipForeground else lightChipForeground
/**
* The colour for a `#tag` still sitting in the note's own words.
*
* 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
* tinted in place instead, which is both less furniture and a more honest card:
* the thing you see IS the thing you would delete to remove the tag.
*/
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 text colour at low alpha.
* *
@@ -128,6 +152,8 @@ val NOTE_TINTS: Map<String, NoteTint> =
lightChipForeground = Color(0xFF525252), lightChipForeground = Color(0xFF525252),
darkChipBackground = Color(0x1AFFFFFF), darkChipBackground = Color(0x1AFFFFFF),
darkChipForeground = Color(0xFFD4D4D4), darkChipForeground = Color(0xFFD4D4D4),
lightTagInk = Color(0xFF404040),
darkTagInk = Color(0xFFD4D4D4),
tintable = false, tintable = false,
), ),
"red" to "red" to
@@ -141,6 +167,8 @@ val NOTE_TINTS: Map<String, NoteTint> =
lightChipForeground = Color(0xFFB91C1C), lightChipForeground = Color(0xFFB91C1C),
darkChipBackground = Color(0x80450A0A), darkChipBackground = Color(0x80450A0A),
darkChipForeground = Color(0xFFFCA5A5), darkChipForeground = Color(0xFFFCA5A5),
lightTagInk = Color(0xFF991B1B),
darkTagInk = Color(0xFFFCA5A5),
), ),
"orange" to "orange" to
NoteTint( NoteTint(
@@ -153,6 +181,8 @@ val NOTE_TINTS: Map<String, NoteTint> =
lightChipForeground = Color(0xFFC2410C), lightChipForeground = Color(0xFFC2410C),
darkChipBackground = Color(0x80431407), darkChipBackground = Color(0x80431407),
darkChipForeground = Color(0xFFFDBA74), darkChipForeground = Color(0xFFFDBA74),
lightTagInk = Color(0xFF9A3412),
darkTagInk = Color(0xFFFDBA74),
), ),
"yellow" to "yellow" to
NoteTint( NoteTint(
@@ -165,6 +195,8 @@ val NOTE_TINTS: Map<String, NoteTint> =
lightChipForeground = Color(0xFF92400E), lightChipForeground = Color(0xFF92400E),
darkChipBackground = Color(0x80451A03), darkChipBackground = Color(0x80451A03),
darkChipForeground = Color(0xFFFCD34D), darkChipForeground = Color(0xFFFCD34D),
lightTagInk = Color(0xFF92400E),
darkTagInk = Color(0xFFFCD34D),
), ),
"green" to "green" to
NoteTint( NoteTint(
@@ -177,6 +209,8 @@ val NOTE_TINTS: Map<String, NoteTint> =
lightChipForeground = Color(0xFF15803D), lightChipForeground = Color(0xFF15803D),
darkChipBackground = Color(0x80052E16), darkChipBackground = Color(0x80052E16),
darkChipForeground = Color(0xFF86EFAC), darkChipForeground = Color(0xFF86EFAC),
lightTagInk = Color(0xFF166534),
darkTagInk = Color(0xFF86EFAC),
), ),
"teal" to "teal" to
NoteTint( NoteTint(
@@ -189,6 +223,8 @@ val NOTE_TINTS: Map<String, NoteTint> =
lightChipForeground = Color(0xFF0F766E), lightChipForeground = Color(0xFF0F766E),
darkChipBackground = Color(0x80042F2E), darkChipBackground = Color(0x80042F2E),
darkChipForeground = Color(0xFF5EEAD4), darkChipForeground = Color(0xFF5EEAD4),
lightTagInk = Color(0xFF115E59),
darkTagInk = Color(0xFF5EEAD4),
), ),
"blue" to "blue" to
NoteTint( NoteTint(
@@ -201,6 +237,8 @@ val NOTE_TINTS: Map<String, NoteTint> =
lightChipForeground = Color(0xFF1D4ED8), lightChipForeground = Color(0xFF1D4ED8),
darkChipBackground = Color(0x80172554), darkChipBackground = Color(0x80172554),
darkChipForeground = Color(0xFF93C5FD), darkChipForeground = Color(0xFF93C5FD),
lightTagInk = Color(0xFF1E40AF),
darkTagInk = Color(0xFF93C5FD),
), ),
"purple" to "purple" to
NoteTint( NoteTint(
@@ -213,6 +251,8 @@ val NOTE_TINTS: Map<String, NoteTint> =
lightChipForeground = Color(0xFF7E22CE), lightChipForeground = Color(0xFF7E22CE),
darkChipBackground = Color(0x803B0764), darkChipBackground = Color(0x803B0764),
darkChipForeground = Color(0xFFD8B4FE), darkChipForeground = Color(0xFFD8B4FE),
lightTagInk = Color(0xFF6B21A8),
darkTagInk = Color(0xFFD8B4FE),
), ),
"pink" to "pink" to
NoteTint( NoteTint(
@@ -225,6 +265,8 @@ val NOTE_TINTS: Map<String, NoteTint> =
lightChipForeground = Color(0xFFBE185D), lightChipForeground = Color(0xFFBE185D),
darkChipBackground = Color(0x80500724), darkChipBackground = Color(0x80500724),
darkChipForeground = Color(0xFFF9A8D4), darkChipForeground = Color(0xFFF9A8D4),
lightTagInk = Color(0xFF9D174D),
darkTagInk = Color(0xFFF9A8D4),
), ),
"gray" to "gray" to
NoteTint( NoteTint(
@@ -237,6 +279,8 @@ val NOTE_TINTS: Map<String, NoteTint> =
lightChipForeground = Color(0xFF404040), lightChipForeground = Color(0xFF404040),
darkChipBackground = Color(0xFF404040), darkChipBackground = Color(0xFF404040),
darkChipForeground = Color(0xFFE5E5E5), darkChipForeground = Color(0xFFE5E5E5),
lightTagInk = Color(0xFF262626),
darkTagInk = Color(0xFFE5E5E5),
), ),
) )
@@ -321,4 +365,17 @@ private fun firstLabelColor(note: Note): String {
fun labelTintFor( fun labelTintFor(
name: String, name: String,
color: String, color: String,
): NoteTint = noteTint(resolvedLabelColor(name, color, NOTE_TINTS.keys)) ): NoteTint = labelTint(name, color)
/**
* [labelTintFor] with no composable context, for a caller building its value inside
* `remember` — where a `@Composable` call is not allowed. The card's inline tag
* colours are computed there, once per body rather than once per recomposition.
*
* One implementation, two entry points: the composable one delegates here rather than
* repeating the lookup, so the chip and the inline token cannot resolve differently.
*/
fun labelTint(
name: String,
color: String,
): NoteTint = NOTE_TINTS[resolvedLabelColor(name, color, NOTE_TINTS.keys)] ?: NOTE_TINTS.getValue("default")
+17 -2
View File
@@ -43,8 +43,8 @@ use thoughtsync_core::sync::blobs::BlobStore;
use thoughtsync_core::sync::{client, compat, engine, push, state}; use thoughtsync_core::sync::{client, compat, engine, push, state};
use models::{ use models::{
patch_from, BodyItem, ClientUpdate, Identity, Label, Note, NoteDraft, NoteEdit, NoteQuery, patch_from, BodyItem, BodyTag, ClientUpdate, Identity, Label, Note, NoteDraft, NoteEdit,
ProbeResult, RevokeOutcome, SyncOutcome, SyncStatus, NoteQuery, ProbeResult, RevokeOutcome, SyncOutcome, SyncStatus,
}; };
uniffi::setup_scaffolding!(); uniffi::setup_scaffolding!();
@@ -530,6 +530,21 @@ pub fn checklist_items(body: String) -> Vec<BodyItem> {
.collect() .collect()
} }
/// Every `#tag` in a body, with the line and the UTF-16 span each one occupies — so
/// a card can colour the tag where it was typed instead of printing it twice.
///
/// The same argument as `checklist_items` above, and the same answer: the grammar for
/// what a `#tag` is already exists in Rust, Python and TypeScript. Matching it a
/// fourth time in Compose would be a fourth place for a tag to change shape when it
/// syncs — and this one would fail silently, as the wrong characters tinted.
#[uniffi::export]
pub fn body_tags(body: String) -> Vec<BodyTag> {
local::derive::extract_tag_spans(&body)
.into_iter()
.map(BodyTag::from)
.collect()
}
/// Helpers, deliberately NOT exported — uniffi only binds what an `#[uniffi::export]` /// Helpers, deliberately NOT exported — uniffi only binds what an `#[uniffi::export]`
/// block names, so these stay Rust-side. /// block names, so these stay Rust-side.
impl ThoughtSync { impl ThoughtSync {
+30
View File
@@ -76,6 +76,36 @@ impl From<thoughtsync_core::local::derive::DerivedItem> for BodyItem {
} }
} }
/// One `#tag` and where it sits in a note's body.
///
/// Mirrors `derive::DerivedTag`. The card colours the tag where it was typed rather
/// than repeating it as a chip, so it needs the SPAN — and the offsets are UTF-16
/// code units precisely because Kotlin's `AnnotatedString` counts that way.
#[derive(Debug, Clone, uniffi::Record)]
pub struct BodyTag {
pub line: u32,
pub start: u32,
pub end: u32,
pub name: String,
}
impl From<thoughtsync_core::local::derive::DerivedTag> for BodyTag {
fn from(t: thoughtsync_core::local::derive::DerivedTag) -> Self {
let thoughtsync_core::local::derive::DerivedTag {
line,
start,
end,
name,
} = t;
BodyTag {
line,
start,
end,
name,
}
}
}
/// An Android build the linked server is offering, already judged to be newer. /// An Android build the linked server is offering, already judged to be newer.
/// ///
/// A mirror rather than a re-export of `client::ClientRelease`, for the same /// A mirror rather than a re-export of `client::ClientRelease`, for the same
+86
View File
@@ -62,6 +62,62 @@ pub fn extract_tags(body: &str) -> Vec<String> {
out out
} }
/// One `#tag` and exactly where it sits, for a renderer drawing the body itself.
///
/// The card no longer prints a chip for a tag whose text is still in the note — it
/// colours the token where it was typed instead. To do that a renderer needs the
/// SPAN, not just the name, and asking it to find the name again would be a second
/// grammar quietly disagreeing with this one about what `##a` or `#1` is.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DerivedTag {
/// Which body line it sits on, like [`DerivedItem::line`].
pub line: u32,
/// Offsets into that line, in UTF-16 code units — INCLUDING the leading `#`.
///
/// UTF-16 rather than chars or bytes because the two languages that consume this
/// both index strings that way: Kotlin's `AnnotatedString` and JavaScript. A char
/// index is right up until somebody puts an emoji before a tag, and then it lands
/// mid-token with no error anywhere.
pub start: u32,
pub end: u32,
pub name: String,
}
/// Every `#tag` in `body` with its position — the same scan [`extract_tags`] does,
/// keeping the spans instead of throwing them away.
///
/// Not deduped, unlike `extract_tags`: two mentions of `#todo` are two pieces of text
/// to colour. Fences are not skipped either, and that is deliberate — `extract_tags`
/// does not skip them, so a `#tag` inside a code block IS a label on the note, and a
/// renderer that left it plain would be the only surface disagreeing.
pub fn extract_tag_spans(body: &str) -> Vec<DerivedTag> {
let mut out = Vec::new();
for (n, line) in body.split('\n').enumerate() {
let chars: Vec<char> = line.chars().collect();
let spans = line_tags(&chars);
if spans.is_empty() {
continue;
}
// Prefix sums, built once per tagged line: char index -> UTF-16 offset.
let mut units: Vec<u32> = Vec::with_capacity(chars.len() + 1);
let mut total: u32 = 0;
units.push(0);
for c in &chars {
total += c.len_utf16() as u32;
units.push(total);
}
for (start, end, name) in spans {
out.push(DerivedTag {
line: n as u32,
start: units[start],
end: units[end],
name,
});
}
}
out
}
/// Whether a line opens or closes a fenced code block. /// Whether a line opens or closes a fenced code block.
fn is_fence(line: &str) -> bool { fn is_fence(line: &str) -> bool {
let trimmed = line.trim_start(); let trimmed = line.trim_start();
@@ -438,6 +494,36 @@ mod tests {
assert!(extract_tags("").is_empty()); assert!(extract_tags("").is_empty());
} }
// ── tag spans, for the renderer that draws them in place ─────────────────
#[test]
fn tag_spans_carry_the_hash_and_the_line() {
let spans = extract_tag_spans("buy milk #grocery\nand call #mom about #mom");
assert_eq!(spans.len(), 3);
assert_eq!((spans[0].line, spans[0].start, spans[0].end), (0, 9, 17));
assert_eq!(spans[0].name, "grocery");
// Not deduped: two mentions are two pieces of text to colour.
assert_eq!(spans[1].line, 1);
assert_eq!(spans[2].name, "mom");
assert_eq!((spans[2].start, spans[2].end), (20, 24));
}
#[test]
fn tag_spans_are_utf16_offsets_not_char_indices() {
// The emoji is ONE char and TWO UTF-16 code units. Kotlin and JS both index
// the second way, so a char index would highlight one character too early.
let spans = extract_tag_spans("🎁 #gift");
assert_eq!(spans.len(), 1);
assert_eq!((spans[0].start, spans[0].end), (3, 8));
}
#[test]
fn tag_spans_agree_with_extract_tags_about_what_a_tag_is() {
let body = "#1 nope a#b no but #Yes ##no";
let names: Vec<String> = extract_tag_spans(body).into_iter().map(|t| t.name).collect();
assert_eq!(names, extract_tags(body));
}
// ── lifting standalone tags ────────────────────────────────────────────── // ── lifting standalone tags ──────────────────────────────────────────────
// //
// The MIRROR of `split_body_tags` in the server's notes/tags.py, case for case. // The MIRROR of `split_body_tags` in the server's notes/tags.py, case for case.
+10 -2
View File
@@ -1,10 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import type { InlineToken } from "../notes/markdown"; import type { InlineToken } from "../notes/markdown";
import { tagTextClasses } from "../notes/colors";
// Emphasis and code only. `[[wiki-links]]` were the one token type that needed a // Emphasis, code, and `#tags`. `[[wiki-links]]` were the one token type that needed a
// router, a store and a resolver behind it; they are gone (note 2897), and so is all // router, a store and a resolver behind it; they are gone (note 2897), and so is all
// of that. // of that.
defineProps<{ tokens: InlineToken[] }>(); //
// `tagColors` maps a lowercased tag name to the colour stored on that label. Threaded
// down from the card rather than looked up here, because this component renders text
// and has no idea which note the text belongs to — and a tag the operator recoloured
// must read the same here as it does on a chip.
defineProps<{ tokens: InlineToken[]; tagColors?: Record<string, string> }>();
</script> </script>
<!-- Rendered tightly (no whitespace between tokens) so a token's own leading/trailing <!-- Rendered tightly (no whitespace between tokens) so a token's own leading/trailing
@@ -17,6 +23,8 @@ defineProps<{ tokens: InlineToken[] }>();
v-else-if="t.type === 'code'" v-else-if="t.type === 'code'"
class="rounded bg-black/5 px-1 py-0.5 font-mono text-[0.85em] dark:bg-white/10" class="rounded bg-black/5 px-1 py-0.5 font-mono text-[0.85em] dark:bg-white/10"
>{{ t.value }}</code >{{ t.value }}</code
><span v-else-if="t.type === 'tag'" class="font-medium" :class="tagTextClasses(t.value, tagColors)"
>#{{ t.value }}</span
><template v-else>{{ t.value }}</template></template ><template v-else>{{ t.value }}</template></template
></template ></template
> >
+21 -9
View File
@@ -3,7 +3,13 @@ import { computed } from "vue";
import { parseMarkdown } from "../notes/markdown"; import { parseMarkdown } from "../notes/markdown";
import MarkdownInline from "./MarkdownInline.vue"; import MarkdownInline from "./MarkdownInline.vue";
const props = defineProps<{ text: string; toggleable?: boolean }>(); // `tagColors` is passed straight through to MarkdownInline — see there for why the
// card owns the lookup rather than the renderer.
const props = defineProps<{
text: string;
toggleable?: boolean;
tagColors?: Record<string, string>;
}>();
// Ticking a box rewrites a line of the note's body, which is a thing only the owner // Ticking a box rewrites a line of the note's body, which is a thing only the owner
// of that note can do — so this renders the checkbox and hands the intent up rather // of that note can do — so this renders the checkbox and hands the intent up rather
// than reaching for the store itself. The card wires it; a read-only render does not // than reaching for the store itself. The card wires it; a read-only render does not
@@ -15,14 +21,20 @@ const blocks = computed(() => parseMarkdown(props.text));
<template> <template>
<div class="space-y-1.5 break-words"> <div class="space-y-1.5 break-words">
<template v-for="(b, i) in blocks" :key="i"> <template v-for="(b, i) in blocks" :key="i">
<h3 v-if="b.type === 'h1'" class="text-base font-bold"><MarkdownInline :tokens="b.inline ?? []" /></h3> <h3 v-if="b.type === 'h1'" class="text-base font-bold">
<h4 v-else-if="b.type === 'h2'" class="text-sm font-bold"><MarkdownInline :tokens="b.inline ?? []" /></h4> <MarkdownInline :tokens="b.inline ?? []" :tag-colors="tagColors" />
<h5 v-else-if="b.type === 'h3'" class="text-sm font-semibold"><MarkdownInline :tokens="b.inline ?? []" /></h5> </h3>
<h4 v-else-if="b.type === 'h2'" class="text-sm font-bold">
<MarkdownInline :tokens="b.inline ?? []" :tag-colors="tagColors" />
</h4>
<h5 v-else-if="b.type === 'h3'" class="text-sm font-semibold">
<MarkdownInline :tokens="b.inline ?? []" :tag-colors="tagColors" />
</h5>
<blockquote <blockquote
v-else-if="b.type === 'quote'" v-else-if="b.type === 'quote'"
class="whitespace-pre-wrap border-l-2 border-neutral-300 pl-2 text-neutral-600 dark:border-neutral-600 dark:text-neutral-400" class="whitespace-pre-wrap border-l-2 border-neutral-300 pl-2 text-neutral-600 dark:border-neutral-600 dark:text-neutral-400"
> >
<MarkdownInline :tokens="b.inline ?? []" /> <MarkdownInline :tokens="b.inline ?? []" :tag-colors="tagColors" />
</blockquote> </blockquote>
<div v-else-if="b.type === 'task'" class="flex flex-col gap-1"> <div v-else-if="b.type === 'task'" class="flex flex-col gap-1">
<div v-for="(it, j) in b.items ?? []" :key="j" class="flex items-start gap-2"> <div v-for="(it, j) in b.items ?? []" :key="j" class="flex items-start gap-2">
@@ -42,22 +54,22 @@ const blocks = computed(() => parseMarkdown(props.text));
class="min-w-0 flex-1" class="min-w-0 flex-1"
:class="b.tasks?.[j]?.checked ? 'text-neutral-400 line-through' : ''" :class="b.tasks?.[j]?.checked ? 'text-neutral-400 line-through' : ''"
> >
<MarkdownInline :tokens="it" /> <MarkdownInline :tokens="it" :tag-colors="tagColors" />
</span> </span>
</div> </div>
</div> </div>
<ul v-else-if="b.type === 'ul'" class="list-disc space-y-0.5 pl-5"> <ul v-else-if="b.type === 'ul'" class="list-disc space-y-0.5 pl-5">
<li v-for="(it, j) in b.items ?? []" :key="j"><MarkdownInline :tokens="it" /></li> <li v-for="(it, j) in b.items ?? []" :key="j"><MarkdownInline :tokens="it" :tag-colors="tagColors" /></li>
</ul> </ul>
<ol v-else-if="b.type === 'ol'" class="list-decimal space-y-0.5 pl-5"> <ol v-else-if="b.type === 'ol'" class="list-decimal space-y-0.5 pl-5">
<li v-for="(it, j) in b.items ?? []" :key="j"><MarkdownInline :tokens="it" /></li> <li v-for="(it, j) in b.items ?? []" :key="j"><MarkdownInline :tokens="it" :tag-colors="tagColors" /></li>
</ol> </ol>
<pre <pre
v-else-if="b.type === 'pre'" v-else-if="b.type === 'pre'"
class="overflow-x-auto whitespace-pre-wrap rounded-md bg-black/5 p-2 font-mono text-xs dark:bg-white/10" class="overflow-x-auto whitespace-pre-wrap rounded-md bg-black/5 p-2 font-mono text-xs dark:bg-white/10"
>{{ b.value ?? "" }}</pre >{{ b.value ?? "" }}</pre
> >
<p v-else class="whitespace-pre-wrap"><MarkdownInline :tokens="b.inline ?? []" /></p> <p v-else class="whitespace-pre-wrap"><MarkdownInline :tokens="b.inline ?? []" :tag-colors="tagColors" /></p>
</template> </template>
</div> </div>
</template> </template>
+27 -4
View File
@@ -197,6 +197,23 @@ function labelChip(label: { name: string; color: string }): string {
return LABEL_CHIP_CLASSES[resolveLabelColor(label)] ?? LABEL_CHIP_CLASSES.default; return LABEL_CHIP_CLASSES[resolveLabelColor(label)] ?? LABEL_CHIP_CLASSES.default;
} }
// Only the tags the BODY is not already showing. `via_tag` means exactly "backed by
// text still in the note" since M311, so a chip for one printed the same tag twice —
// once where it was typed, once in this row — and the loud copy was the duplicate. A
// tag left in prose is tinted where it sits instead (MarkdownInline). What survives
// here is what the body cannot say: a tag lifted off its own line, and a label added
// through the picker.
const chipLabels = computed(() => props.note.labels.filter((lb) => !lb.via_tag));
// The colour the operator stored for each of this note's tags, keyed by lowercased
// name — what MarkdownInline needs to tint a `#tag` the same as its chip would be.
// Lowercased because tags dedupe case-insensitively, so `#Todo` and `#todo` are one.
const tagColors = computed<Record<string, string>>(() => {
const map: Record<string, string> = {};
for (const lb of props.note.labels) map[lb.name.toLowerCase()] = lb.color;
return map;
});
// Per-card color popover (recolor without opening the editor). // Per-card color popover (recolor without opening the editor).
const colorOpen = ref(false); const colorOpen = ref(false);
@@ -267,13 +284,19 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
Above the image and the body rather than beside them, because the body's first Above the image and the body rather than beside them, because the body's first
line is the note's NAME (M13 steps 3 and 4) and a chip floated next to it would line is the note's NAME (M13 steps 3 and 4) and a chip floated next to it would
compete with the thing that identifies the note. --> compete with the thing that identifies the note. -->
<div v-if="note.labels.length" class="mb-2 flex flex-wrap gap-1"> <div v-if="chipLabels.length" class="mb-2 flex flex-wrap gap-1">
<!-- Every chip carries the `#`, not just the ones derived from body text. That
branch used to distinguish a `#tag` from a picker label; it cannot any more,
because a tag whose text is still in the body no longer reaches this row at
all. What is left is all the same thing to the eye and to the vocabulary
and the hash is what keeps a lifted chip reading as the `#todo` somebody
typed. Android's row says the same, which it did not before. -->
<span <span
v-for="lb in note.labels" v-for="lb in chipLabels"
:key="lb.id" :key="lb.id"
class="rounded-full px-2 py-0.5 text-xs" class="rounded-full px-2 py-0.5 text-xs"
:class="labelChip(lb)" :class="labelChip(lb)"
>{{ lb.via_tag ? "#" + lb.name : lb.name }}</span >#{{ lb.name }}</span
> >
</div> </div>
@@ -314,7 +337,7 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
blank and the link is never unreachable. --> blank and the link is never unreachable. -->
<LinkPreview v-if="loneUrlPreview" :preview="loneUrlPreview" /> <LinkPreview v-if="loneUrlPreview" :preview="loneUrlPreview" />
<div v-else-if="note.body" class="text-sm text-neutral-700 dark:text-neutral-300"> <div v-else-if="note.body" class="text-sm text-neutral-700 dark:text-neutral-300">
<MarkdownText :text="bodyPreview" toggleable @toggle="toggleTask" /> <MarkdownText :text="bodyPreview" :tag-colors="tagColors" toggleable @toggle="toggleTask" />
</div> </div>
<p <p
v-if="!note.body && !note.items.length && !note.attachments.length" v-if="!note.body && !note.items.length && !note.attachments.length"
+4 -1
View File
@@ -613,7 +613,10 @@ function revPreview(rev: NoteRevision): string {
class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs" class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs"
:class="labelChip(lb)" :class="labelChip(lb)"
> >
{{ lb.via_tag ? "#" + lb.name : lb.name }} <!-- `#` on every chip, matching the card. This row still lists the tags
the BODY owns too — it is the control surface, and `via_tag` is what
decides whether there is a cross to remove one with. -->
#{{ lb.name }}
<button <button
v-if="!lb.via_tag" v-if="!lb.via_tag"
type="button" type="button"
+37
View File
@@ -57,6 +57,43 @@ export const LABEL_CHIP_CLASSES: Record<NoteColor, string> = {
gray: "bg-neutral-200 text-neutral-700 dark:bg-neutral-700 dark:text-neutral-200 ring-1 ring-inset ring-neutral-700/60 dark:ring-neutral-200/60", gray: "bg-neutral-200 text-neutral-700 dark:bg-neutral-700 dark:text-neutral-200 ring-1 ring-inset ring-neutral-700/60 dark:ring-neutral-200/60",
}; };
// The ink for a `#tag` drawn where it was typed, rather than repeated as a chip.
//
// ONE Tailwind step deeper than LABEL_CHIP_CLASSES' text, and the difference is not a
// stylistic one. A chip carries its own `-100` fill, so its text has exactly one
// background to read against. Inline text sits on whatever the CARD is — which
// includes a gray-tagged card at `neutral-200`, where the chip's `-700` measured 3.98
// (green), 4.11 (orange) and 4.34 (teal), all under the 4.5 body text needs. At `-800`
// every hue lands between 5.63 and 12.01 in light, and `-300` gives 7.20 to 10.84 in
// dark, measured against every fill in NOTE_CARD_CLASSES_STRONG and every generated
// fill. One step for all ten beats three per-hue exceptions.
//
// Mirrored in NoteTint.kt as `lightTagInk` / `darkTagInk`.
export const TAG_TEXT_CLASSES: Record<NoteColor, string> = {
default: "text-neutral-700 dark:text-neutral-300",
red: "text-red-800 dark:text-red-300",
orange: "text-orange-800 dark:text-orange-300",
yellow: "text-amber-800 dark:text-amber-300",
green: "text-green-800 dark:text-green-300",
teal: "text-teal-800 dark:text-teal-300",
blue: "text-blue-800 dark:text-blue-300",
purple: "text-purple-800 dark:text-purple-300",
pink: "text-pink-800 dark:text-pink-300",
gray: "text-neutral-800 dark:text-neutral-200",
};
/**
* The classes for one `#tag` in a note's own words.
*
* `picked` maps a lowercased tag name to the colour stored on that label, so a tag the
* operator has recoloured reads the same inline as it does on a chip. A tag the note
* does not carry as a label yet — just typed, not yet derived — is not in the map, and
* `resolveLabelColor` derives one from the name exactly as the chip would have.
*/
export function tagTextClasses(name: string, picked?: Record<string, string>): string {
return TAG_TEXT_CLASSES[resolveLabelColor({ name, color: picked?.[name.toLowerCase()] })];
}
// Solid fills for graph nodes (SVG needs concrete colors, not Tailwind bg classes). // Solid fills for graph nodes (SVG needs concrete colors, not Tailwind bg classes).
// Mid-tone hues read on both the light and dark graph background. // Mid-tone hues read on both the light and dark graph background.
export const NOTE_NODE_FILL: Record<NoteColor, string> = { export const NOTE_NODE_FILL: Record<NoteColor, string> = {
+20 -2
View File
@@ -7,7 +7,9 @@
// a heading. // a heading.
export interface InlineToken { export interface InlineToken {
type: "text" | "bold" | "italic" | "code"; /** `tag` carries the NAME, without the leading `#` — it is both what gets looked up
* for a colour and what is rendered, so the renderer puts the `#` back. */
type: "text" | "bold" | "italic" | "code" | "tag";
value: string; value: string;
} }
@@ -37,7 +39,22 @@ export interface TaskMeta {
// `[[wiki-links]]` used to lead this alternation. They are gone (note 2897) — this is // `[[wiki-links]]` used to lead this alternation. They are gone (note 2897) — this is
// a capture-and-recall surface, and a linking system is organization. `[[text]]` now // a capture-and-recall surface, and a linking system is organization. `[[text]]` now
// renders as the literal characters someone typed, which is what it always was. // renders as the literal characters someone typed, which is what it always was.
const INLINE_RE = /(`[^`]+`)|(\*\*[^*]+\*\*)|(\*[^*]+\*)|(_[^_]+_)/g; // `#tag` is LAST in the alternation and that is load-bearing twice over. JS tries
// alternatives left to right, so a `#tag` inside backticks is claimed by `code` first
// and stays literal — matching the core, where a fenced block's contents are code.
// And a tag is the one token here that is not delimiter-based, so it must not get a
// chance to start inside `**bold #x**`.
//
// The grammar MIRRORS `line_tags` in core/src/local/derive.rs, which is the definition:
// a `#` at a word boundary (the preceding character is neither a tag character nor
// another `#`, so `a#b` and `##x` are not tags), a letter immediately after it, then
// alphanumerics, `_` and `-`. Rust's `is_alphanumeric` is `Alphabetic | N`, hence the
// property escapes rather than `\w` — and hence the `u` flag.
//
// A heading cannot collide with this: `parseMarkdown` requires a space after the `#`s,
// which `#tag` by definition does not have.
const INLINE_RE =
/(`[^`]+`)|(\*\*[^*]+\*\*)|(\*[^*]+\*)|(_[^_]+_)|((?<![\p{Alphabetic}\p{N}_#-])#\p{Alphabetic}[\p{Alphabetic}\p{N}_-]*)/gu;
export function parseInline(text: string): InlineToken[] { export function parseInline(text: string): InlineToken[] {
const tokens: InlineToken[] = []; const tokens: InlineToken[] = [];
@@ -49,6 +66,7 @@ export function parseInline(text: string): InlineToken[] {
const raw = m[0]; const raw = m[0];
if (m[1]) tokens.push({ type: "code", value: raw.slice(1, -1) }); if (m[1]) tokens.push({ type: "code", value: raw.slice(1, -1) });
else if (m[2]) tokens.push({ type: "bold", value: raw.slice(2, -2) }); else if (m[2]) tokens.push({ type: "bold", value: raw.slice(2, -2) });
else if (m[5]) tokens.push({ type: "tag", value: raw.slice(1) });
else tokens.push({ type: "italic", value: raw.slice(1, -1) }); else tokens.push({ type: "italic", value: raw.slice(1, -1) });
last = m.index + raw.length; last = m.index + raw.length;
} }
+7 -2
View File
@@ -21,8 +21,13 @@ export interface NoteLabel {
id: string; id: string;
name: string; name: string;
color: string; color: string;
// True when this label is attached because of a #tag in the note body (kept in // True when the label is backed by text STILL IN THE BODY — a `#tag` written
// sync with the text); false = added manually via the picker. // mid-sentence, kept in sync with those words. False covers both a label added
// through the picker and a tag lifted off a line of its own (M311), which is why
// it is also what decides whether a chip can be removed with a cross.
//
// The card reads it the other way round: a true here means the body is already
// showing this tag, so the chip would be the second copy and is not drawn.
via_tag: boolean; via_tag: boolean;
} }