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
+10 -2
View File
@@ -1,10 +1,16 @@
<script setup lang="ts">
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
// 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>
<!-- 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'"
class="rounded bg-black/5 px-1 py-0.5 font-mono text-[0.85em] dark:bg-white/10"
>{{ 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
>
+21 -9
View File
@@ -3,7 +3,13 @@ import { computed } from "vue";
import { parseMarkdown } from "../notes/markdown";
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
// 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
@@ -15,14 +21,20 @@ const blocks = computed(() => parseMarkdown(props.text));
<template>
<div class="space-y-1.5 break-words">
<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>
<h4 v-else-if="b.type === 'h2'" class="text-sm font-bold"><MarkdownInline :tokens="b.inline ?? []" /></h4>
<h5 v-else-if="b.type === 'h3'" class="text-sm font-semibold"><MarkdownInline :tokens="b.inline ?? []" /></h5>
<h3 v-if="b.type === 'h1'" class="text-base font-bold">
<MarkdownInline :tokens="b.inline ?? []" :tag-colors="tagColors" />
</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
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"
>
<MarkdownInline :tokens="b.inline ?? []" />
<MarkdownInline :tokens="b.inline ?? []" :tag-colors="tagColors" />
</blockquote>
<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">
@@ -42,22 +54,22 @@ const blocks = computed(() => parseMarkdown(props.text));
class="min-w-0 flex-1"
:class="b.tasks?.[j]?.checked ? 'text-neutral-400 line-through' : ''"
>
<MarkdownInline :tokens="it" />
<MarkdownInline :tokens="it" :tag-colors="tagColors" />
</span>
</div>
</div>
<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>
<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>
<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"
>{{ 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>
</div>
</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;
}
// 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).
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
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. -->
<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
v-for="lb in note.labels"
v-for="lb in chipLabels"
:key="lb.id"
class="rounded-full px-2 py-0.5 text-xs"
:class="labelChip(lb)"
>{{ lb.via_tag ? "#" + lb.name : lb.name }}</span
>#{{ lb.name }}</span
>
</div>
@@ -314,7 +337,7 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
blank and the link is never unreachable. -->
<LinkPreview v-if="loneUrlPreview" :preview="loneUrlPreview" />
<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>
<p
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="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
v-if="!lb.via_tag"
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",
};
// 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).
// Mid-tone hues read on both the light and dark graph background.
export const NOTE_NODE_FILL: Record<NoteColor, string> = {
+20 -2
View File
@@ -7,7 +7,9 @@
// a heading.
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;
}
@@ -37,7 +39,22 @@ export interface TaskMeta {
// `[[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
// 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[] {
const tokens: InlineToken[] = [];
@@ -49,6 +66,7 @@ export function parseInline(text: string): InlineToken[] {
const raw = m[0];
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[5]) tokens.push({ type: "tag", value: raw.slice(1) });
else tokens.push({ type: "italic", value: raw.slice(1, -1) });
last = m.index + raw.length;
}
+7 -2
View File
@@ -21,8 +21,13 @@ export interface NoteLabel {
id: string;
name: string;
color: string;
// True when this label is attached because of a #tag in the note body (kept in
// sync with the text); false = added manually via the picker.
// True when the label is backed by text STILL IN THE BODY — a `#tag` written
// 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;
}