Files
inkwell/frontend/src/components/NoteCard.vue
T
bvandeusenandClaude Opus 5 d9e5753dc2
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
board: a tag in the prose is coloured where it sits, not printed twice
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>
2026-08-26 22:20:17 -04:00

539 lines
24 KiB
Vue

<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from "vue";
import { useNotesStore } from "../stores/notes";
import {
LABEL_CHIP_CLASSES,
NOTE_COLOR_KEYS,
NOTE_COLOR_LABELS,
NOTE_SWATCH_CLASSES,
noteCardClasses,
noteTintVars,
resolveLabelColor,
type NoteColor,
} from "../notes/colors";
import type { Note } from "../stores/notes";
import Icon from "./Icon.vue";
import LinkPreview from "./LinkPreview.vue";
import MarkdownText from "./MarkdownText.vue";
import {
cardIdAt,
draggingId,
DRAG_THRESHOLD_PX,
endCardDrag,
overId,
} from "../composables/useCardDrag";
import { formatReminder, formatTrashCountdown, isOverdue, trashDaysLeft } from "../notes/datetime";
import { useConfigStore } from "../stores/config";
const props = defineProps<{ note: Note; reorderable?: boolean; active?: boolean }>();
const emit = defineEmits<{
(e: "open", note: Note): void;
(e: "dragstart", note: Note): void;
(e: "dragend", note: Note): void;
/** The card this one was dropped ON, by id — the dragged card hit-tests the DOM
* for it, so an id is all it can know without looking the note up again. */
(e: "drop", targetId: string): void;
/** A reminder was completed or snoozed. The store reconciles itself, so the board
* needs nothing — this is for hosts holding their OWN list (the reminders lens
* fetches separately) that must re-query to drop the note they just cleared. */
(e: "reminder-changed"): void;
}>();
const notes = useNotesStore();
const config = useConfigStore();
// --- Retention countdown. A note in Trash is on a clock, and the card is the only
// place someone browsing Trash would ever find that out in time to restore it.
// Null whenever nothing is going to happen: not trashed, or retention turned off. ---
const trashDays = computed(() =>
props.note.trashed ? trashDaysLeft(props.note.deleted_at, config.trashRetentionDays) : null,
);
const trashCountdown = computed(() => formatTrashCountdown(trashDays.value));
// Same red the overdue reminder uses — the last few days are worth noticing.
const trashUrgent = computed(() => trashDays.value !== null && trashDays.value <= 3);
// The card previews the first image inline; non-image files show as compact chips.
const firstImage = computed(() => props.note.attachments.find((a) => a.mime.startsWith("image/")));
const otherAttachments = computed(() => props.note.attachments.filter((a) => !a.mime.startsWith("image/")));
// How much of a note the CARD shows. Android has always clamped to 8
// (`MAX_PREVIEW_LINES`); the web rendered the whole body, so one long note could
// produce a card taller than the screen and push everything else off the board.
//
// It matters more now that the title is gone (M13 step 4). The first line used to be
// the thing your eye caught; with one weight throughout, an unbounded card is just a
// wall, and the note next to it is the one you were looking for.
//
// Clamped in the STRING rather than with CSS `line-clamp`, which needs a
// `-webkit-box` and behaves unreliably around the block elements MarkdownText emits
// (lists, quotes, fenced code). This is deterministic, matches Android's semantics
// exactly, and skips parsing a body the card was never going to show.
const PREVIEW_LINES = 8;
// --- Links ------------------------------------------------------------------
//
// A note that is NOTHING but a URL is a link, and its preview is the whole card —
// showing the raw URL underneath a card that already says where it goes is saying the
// same thing twice, badly. A URL mentioned *inside* a note is a footnote to it, and
// gets a compact strip at the bottom instead.
//
// Whitespace either side still counts as lone: someone pasting a link rarely trims it.
const LONE_URL_RE = /^\s*(https?:\/\/[^\s<>"'\])]+)\s*$/;
const isLoneUrl = computed(() => LONE_URL_RE.test(props.note.body) && !props.note.items.length);
/** The preview for a lone-URL note — null while it is still being fetched, or if it
* could never be fetched at all. */
const loneUrlPreview = computed(() => {
if (!isLoneUrl.value) return null;
const url = props.note.body.trim();
return props.note.previews.find((p) => p.url === url) ?? null;
});
const bodyPreview = computed(() => {
const lines = props.note.body.split("\n");
if (lines.length <= PREVIEW_LINES) return props.note.body;
return lines.slice(0, PREVIEW_LINES).join("\n") + "\n…";
});
/** Tick a box without opening the note — the common gesture on a board.
*
* The index is the item's ordinal in the WHOLE body, which survives the preview
* clamp above because that only ever drops lines from the end. `updateItem` takes
* it as the item id, which is exactly what an id is now (M304). */
function toggleTask(index: number, checked: boolean) {
void notes.updateItem(props.note.id, String(index), { checked });
}
const root = ref<HTMLElement | null>(null);
// --- Drag-to-reorder. Pointer Events, gated behind an explicit grip handle so a
// plain tap/click/select never starts a drag by accident. `dragging` dims the
// source card; `dragOver` shows where the drop will land.
//
// Pointer rather than native HTML5 drag-and-drop because that API never fires from
// touch — on a phone this did nothing at all. One code path now covers mouse, touch
// and stylus. See composables/useCardDrag.ts for why the state is shared. ---
const canDrag = () => !!props.reorderable && !props.note.trashed;
const dragging = computed(() => draggingId.value === props.note.id);
const dragOver = computed(() => overId.value === props.note.id);
/** The pointer that owns the current gesture; null when no press is in flight. */
let activePointer: number | null = null;
let startX = 0;
let startY = 0;
/** A press becomes a drag only after the threshold — below it, it's still a tap. */
let moved = false;
function onGripDown(e: PointerEvent) {
if (!canDrag()) return;
activePointer = e.pointerId;
startX = e.clientX;
startY = e.clientY;
moved = false;
// Capture so the gesture keeps reporting to this grip even once the finger has
// travelled onto another card — without it the stream stops at the first
// boundary crossing.
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
// Claims the gesture from the browser's own scroll/text-selection handling.
e.preventDefault();
}
function onGripMove(e: PointerEvent) {
if (activePointer !== e.pointerId) return;
if (!moved) {
if (Math.hypot(e.clientX - startX, e.clientY - startY) < DRAG_THRESHOLD_PX) return;
moved = true;
draggingId.value = props.note.id;
emit("dragstart", props.note);
}
overId.value = cardIdAt(e.clientX, e.clientY, props.note.id);
}
function onGripUp(e: PointerEvent) {
if (activePointer !== e.pointerId) return;
const target = moved ? overId.value : null;
const wasDragging = moved;
activePointer = null;
moved = false;
endCardDrag();
if (target) emit("drop", target);
// Always paired with dragstart, dropped or not, so the parent can clear its own
// tracked source rather than leaking it into the next gesture.
if (wasDragging) emit("dragend", props.note);
}
// Fires when the system takes the gesture away (an incoming call, a browser-level
// scroll taking over). Treated as a cancel: no reorder, no half-set state left.
function onGripCancel(e: PointerEvent) {
if (activePointer !== e.pointerId) return;
const wasDragging = moved;
activePointer = null;
moved = false;
endCardDrag();
if (wasDragging) emit("dragend", props.note);
}
// When this card becomes the keyboard-focused card, scroll it into view.
watch(
() => props.active,
(a) => {
if (a) root.value?.scrollIntoView({ block: "nearest", behavior: "smooth" });
},
);
async function completeReminder(): Promise<void> {
await notes.completeReminder(props.note.id);
emit("reminder-changed");
}
async function snoozeReminder(minutes: number): Promise<void> {
await notes.snoozeReminder(props.note.id, minutes);
emit("reminder-changed");
}
// Takes the label, not its colour: a tag nobody has coloured derives one from its
// name, so chips carry the tag's identity rather than all being the same grey.
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);
function swatch(color: string): string {
return NOTE_SWATCH_CLASSES[color as NoteColor] ?? NOTE_SWATCH_CLASSES.default;
}
function pickColor(color: NoteColor) {
colorOpen.value = false;
void notes.setColor(props.note.id, color);
}
function onDocMousedown(e: MouseEvent) {
if (colorOpen.value && root.value && !root.value.contains(e.target as Node)) colorOpen.value = false;
}
// Only listen for outside clicks while the popover is actually open.
watch(colorOpen, (open) => {
if (open) document.addEventListener("mousedown", onDocMousedown);
else document.removeEventListener("mousedown", onDocMousedown);
});
onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown));
</script>
<template>
<div
ref="root"
class="group relative mb-4 break-inside-avoid rounded-xl border border-[#b8b8b8] p-3 shadow-sm transition hover:shadow-md dark:border-[#404040]"
:class="[
noteCardClasses(note),
dragging ? 'opacity-40' : '',
dragOver
? 'scale-[1.02] shadow-lg ring-2 ring-brand ring-offset-2 ring-offset-white dark:ring-offset-neutral-950'
: '',
active ? 'ring-2 ring-brand' : '',
]"
:style="noteTintVars(note)"
:data-note-id="note.id"
>
<!-- THE EDGE LIVES HERE, NOT IN THE PALETTE, and that is the whole point of it.
Every card gets the same grey hairline whatever colour it is; the fill is the
only thing that varies. The version of this that was a `{hue}-900` border
failed because the line was both the loudest element on the card (1.56-2.09
against its own fill, where the fill managed 1.03-1.05 against the board) AND
carried the same information the fill did, so a board of them read as a grid
of outlines. A neutral line carries no information at all, which is exactly
what lets it be structure.
The two values are MATCHED, not picked by eye: each measures ~1.6-1.7 against
the card it edges (light 1.57-1.98, dark 1.58-1.73), so the edge has the same
authority in either theme. #404040 is `neutral-700`, which is what the default
card's border always was — promoted from one entry in the palette to the rule
for all of them. #b8b8b8 sits between `neutral-300` and `neutral-400`, neither
of which lands in range: 300 fades out at 1.18 on a gray-tagged card, 400
jumps to 2.52 and reads as a wireframe.
NOT a translucent black/white edge, which is the tidier-looking way to do this
and was measured and rejected: a border composites over the card's own fill,
so `border-white/20` comes out #56396d on a purple card and #a3c9c1 on a teal
one. Hue-coded edges are the thing being removed.
`shadow-sm`, back down from `shadow`: the border is the boundary again, so the
shadow is only depth. -->
<!-- TAGS FIRST. They used to sit under everything else, which on a tall note put
the one thing that says what a note IS below the fold of a glance. A board is
scanned, not read, and the answer to "which of these is about the thing I am
looking for" should be the first thing the eye lands on rather than the last.
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="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 chipLabels"
:key="lb.id"
class="rounded-full px-2 py-0.5 text-xs"
:class="labelChip(lb)"
>#{{ lb.name }}</span
>
</div>
<img
v-if="firstImage"
:src="firstImage.url"
alt=""
loading="lazy"
decoding="async"
class="mb-2 max-h-48 w-full cursor-pointer rounded-lg object-cover"
@click="emit('open', note)"
/>
<div v-if="otherAttachments.length" class="mb-2 flex flex-wrap gap-1">
<span
v-for="att in otherAttachments"
:key="att.id"
class="inline-flex max-w-full items-center gap-1 rounded-md bg-black/5 px-1.5 py-0.5 text-xs text-neutral-500 dark:bg-white/10 dark:text-neutral-400"
>
<Icon name="paperclip" />
<span class="max-w-[140px] truncate">{{ att.filename || "file" }}</span>
</span>
</div>
<!-- One render path: every note is a body plus, maybe, checkable items.
A focusable div rather than a <button>, because a checklist nests interactive
controls and those cannot live inside a button — and the card is the same
shape whether or not it happens to carry items today. -->
<div
role="button"
tabindex="0"
class="cursor-text rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-brand"
@click="emit('open', note)"
@keydown.enter="emit('open', note)"
>
<!-- A lone URL renders as its preview and nothing else. Until the fetch lands
— or if it never does — the URL itself stands in, so the card is never
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" :tag-colors="tagColors" toggleable @toggle="toggleTask" />
</div>
<p
v-if="!note.body && !note.items.length && !note.attachments.length"
class="text-sm italic text-neutral-400"
>
Empty note
</p>
</div>
<!-- Inline links: a compact strip at the FOOT of the card, under the note's own
words rather than stacked on top of them. They were above the body until
M13 — which put a stranger's headline where the note's first line should be. -->
<div v-if="!isLoneUrl && note.previews.length" class="mt-2 flex flex-col gap-1">
<LinkPreview v-for="p in note.previews" :key="p.id" :preview="p" compact />
</div>
<!-- No separate checklist block any more. A checklist is lines of the body (M304),
so MarkdownText above draws it in place — which is what lets a list sit between
two paragraphs instead of always after them. Rendering both would have shown
every list twice. -->
<div v-if="note.remind_at" class="mt-2 flex flex-wrap items-center gap-1.5">
<span
class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs"
:class="
isOverdue(note.remind_at)
? 'bg-red-100 text-red-700 dark:bg-red-950/50 dark:text-red-300'
: 'bg-black/5 text-neutral-600 dark:bg-white/10 dark:text-neutral-300'
"
>
<svg
class="h-3 w-3"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
{{ formatReminder(note.remind_at) }}
<!-- Recurrence was only ever shown in the reminders list; carried onto the
card so unifying them didn't quietly drop it, and so a repeating note
reads as repeating on the board too. -->
<span v-if="note.recurrence" :title="`Repeats ${note.recurrence}`">· ↻ {{ note.recurrence }}</span>
</span>
<!-- Clearing a reminder is UPKEEP, and upkeep that costs three clicks is
upkeep people stop doing — so it happens right here rather than by
opening the note. Shown wherever a note carries a reminder, the board
included, not only in the reminders lens.
Always visible rather than hover-revealed: a finger cannot hover, and
these are the primary action for a due note (task 2697). -->
<button type="button" class="chip-btn" title="Mark this reminder done" @click.stop="completeReminder">
Done
</button>
<button type="button" class="chip-btn" title="Snooze 1 hour" @click.stop="snoozeReminder(60)">1h</button>
<button type="button" class="chip-btn" title="Snooze 1 day" @click.stop="snoozeReminder(1440)">1d</button>
</div>
<div v-if="trashCountdown" class="mt-2">
<span
class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs"
:class="
trashUrgent
? 'bg-red-100 text-red-700 dark:bg-red-950/50 dark:text-red-300'
: 'bg-black/5 text-neutral-600 dark:bg-white/10 dark:text-neutral-300'
"
:title="`Permanently deleted ${config.trashRetentionDays} days after it was trashed`"
>
<svg
class="h-3 w-3"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
{{ trashCountdown }}
</span>
</div>
<!-- The card's controls: the drag grip, then the action set.
Placement is CSS's job, not the markup's — `.note-actions` in style.css puts
them where the device can actually use them. Where a pointer hovers they lift
out of flow into the floating top-corner pills they have always been; where
nothing can hover they stay here, in flow, as a footer row. The alternative
(a permanently visible overlay) sat on top of the note's own title, which is
what a phone was showing.
`hover-reveal` still handles the visibility half — see style.css. -->
<div class="note-actions">
<!-- Reorder is gated behind this grip so a normal click or tap never starts a
drag. Board views only (reorderable). A pointer-only affordance — there is
no keyboard equivalent, hence tabindex="-1". `touch-none` hands the gesture
to us instead of the browser's scrolling. -->
<button
v-if="canDrag()"
type="button"
tabindex="-1"
class="note-grip hover-reveal pointer-events-none flex touch-none cursor-grab items-center rounded-full bg-white/85 p-1 text-neutral-500 opacity-0 shadow-sm ring-1 ring-black/5 backdrop-blur-sm transition hover:text-neutral-800 active:cursor-grabbing group-hover:pointer-events-auto group-hover:opacity-100 dark:bg-neutral-900/85 dark:text-neutral-400 dark:ring-white/10 dark:hover:text-neutral-100"
title="Drag to reorder"
aria-label="Drag to reorder"
@pointerdown="onGripDown"
@pointermove="onGripMove"
@pointerup="onGripUp"
@pointercancel="onGripCancel"
>
<Icon name="grip" />
</button>
<div
class="note-actions-set hover-reveal pointer-events-none flex items-center gap-0.5 rounded-full bg-white/85 p-0.5 opacity-0 shadow-sm ring-1 ring-black/5 backdrop-blur-sm transition focus-within:pointer-events-auto focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100 dark:bg-neutral-900/85 dark:ring-white/10"
>
<template v-if="note.trashed">
<button type="button" class="icon-btn" title="Restore" aria-label="Restore" @click="notes.restore(note.id)">
<Icon name="restore" />
</button>
<button
type="button"
class="icon-btn"
title="Delete forever"
aria-label="Delete forever"
@click="notes.deleteForever(note.id)"
>
<Icon name="trash" />
</button>
</template>
<template v-else>
<button
type="button"
class="icon-btn"
title="Change color"
aria-label="Change color"
:aria-expanded="colorOpen"
@click.stop="colorOpen = !colorOpen"
>
<span
class="h-4 w-4 rounded-full border border-black/10 dark:border-white/20"
:class="swatch(note.color)"
></span>
</button>
<button
type="button"
class="icon-btn"
:class="note.pinned ? 'text-brand-700 dark:text-brand' : ''"
:title="note.pinned ? 'Unpin' : 'Pin'"
:aria-label="note.pinned ? 'Unpin' : 'Pin'"
:aria-pressed="note.pinned"
@click="notes.setPinned(note.id, !note.pinned)"
>
<Icon name="pin" />
</button>
<button
type="button"
class="icon-btn"
:title="note.archived ? 'Unarchive' : 'Archive'"
:aria-label="note.archived ? 'Unarchive' : 'Archive'"
@click="notes.setArchived(note.id, !note.archived)"
>
<Icon name="archive" />
</button>
<button
type="button"
class="icon-btn"
title="Move to trash"
aria-label="Move to trash"
@click="notes.trash(note.id)"
>
<Icon name="trash" />
</button>
</template>
<!-- Inside the action set rather than beside it, so it follows the set to
whichever corner or footer the device put it in. -->
<div
v-if="colorOpen"
class="note-swatches flex w-40 flex-wrap gap-1.5 rounded-lg border border-neutral-200 bg-white p-2 shadow-lg dark:border-neutral-700 dark:bg-neutral-800"
>
<button
v-for="key in NOTE_COLOR_KEYS"
:key="key"
type="button"
:title="NOTE_COLOR_LABELS[key]"
:aria-label="NOTE_COLOR_LABELS[key]"
class="h-6 w-6 rounded-full border border-black/10 transition hover:scale-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand"
:class="[NOTE_SWATCH_CLASSES[key], note.color === key ? 'ring-2 ring-brand' : '']"
@click.stop="pickColor(key)"
/>
</div>
</div>
</div>
</div>
</template>