Files
FabledScribe/frontend/src/components/WorkspaceNoteEditor.vue
T
bvandeusenandClaude Opus 5 bd60d679d9
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 16s
CI & Build / integration (push) Successful in 23s
CI & Build / TypeScript typecheck (push) Successful in 25s
CI & Build / Python tests (push) Successful in 57s
CI & Build / Build & push image (push) Successful in 43s
refactor(theme): remove 184 var() fallbacks — every one was unreachable
#2277 counted ~150 "raw colour literals bypassing the tokens". Measuring them
told a different story: 184 sat in `var(--token, #fallback)` position, and a
check against theme.css shows every one of those tokens IS declared. So the
fallbacks could not render. Not drift — vestigial.

They were also not this palette. The most common were Tailwind and Flat-UI
defaults — #6366f1 indigo, #22c55e green, #f59e0b amber, #3b82f6 blue,
#e74c3c and #27ae60 — a second, unsanctioned colour scheme sitting in the
codebase looking like the app's colours to anyone reading it.

Removing them is not tidying. #2319's lesson is that a fallback is WORSE than a
missing token: a missing token renders as nothing and someone eventually
notices, while a fallback renders something plausible forever. These 184 were
one token rename away from silently repainting the app in Tailwind. The design
token check would catch the rename — but the fallback is precisely the thing
that would make it invisible if the check were ever bypassed.

Literal count 152 -> 45, which matters beyond the number: a report that is
mostly unreachable noise is one people stop reading, and then it stops working
while still passing. What remains should be genuinely worth looking at.

Done with a paren-aware transform, not a regex — `var(--x, rgba(0,0,0,.5))`
nests parens and `[^)]+` would cut at the first one and leave `))` behind.
Verified after: every changed line is a fallback strip and nothing else, and
every var() reference still resolves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-08-03 11:52:04 -04:00

703 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from "vue";
import type { Editor } from "@tiptap/vue-3";
import { apiGet, apiPatch, apiPost, apiDelete } from "@/api/client";
import { useToastStore } from "@/stores/toast";
import { useAutoSave } from "@/composables/useAutoSave";
import { useTagSuggestions } from "@/composables/useTagSuggestions";
import { useNotesStore } from "@/stores/notes";
import TiptapEditor from "@/components/TiptapEditor.vue";
import TagInput from "@/components/TagInput.vue";
import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
import WordCount from "@/components/WordCount.vue";
import { Trash2, X } from "lucide-vue-next";
const props = defineProps<{
projectId: number;
activeNoteId: number | null;
}>();
const emit = defineEmits<{ "note-loaded": [id: number] }>();
interface NoteItem {
id: number;
title: string;
tags: string[];
updated_at: string;
}
const toast = useToastStore();
const notesStore = useNotesStore();
// List state
const projectNotes = ref<NoteItem[]>([]);
const listLoading = ref(false);
const deletingId = ref<number | null>(null);
const pendingDelete = ref<number | null>(null);
const searchQuery = ref("");
const filteredNotes = computed(() => {
const q = searchQuery.value.trim().toLowerCase();
if (!q) return projectNotes.value;
return projectNotes.value.filter(
(n) => n.title.toLowerCase().includes(q) || n.tags?.some((t) => t.toLowerCase().includes(q))
);
});
function matchedTags(note: NoteItem): string[] {
const q = searchQuery.value.trim().toLowerCase();
if (!q) return [];
return (note.tags ?? []).filter(
(t) => t.toLowerCase().includes(q) && !note.title.toLowerCase().includes(q)
);
}
// Editor state
const editingId = ref<number | null>(null);
const noteTitle = ref("");
const noteBody = ref("");
const noteTags = ref<string[]>([]);
const dirty = ref(false);
const saving = ref(false);
const tagSuggestions = useTagSuggestions(noteTitle, noteBody, noteTags, () => { dirty.value = true; });
// New note inline creation
const showNewNoteInput = ref(false);
const newNoteTitle = ref("");
const creatingNote = ref(false);
const newNoteTitleRef = ref<HTMLInputElement | null>(null);
function _noteKey(pid: number) { return `workspace_note_${pid}`; }
async function startNewNote() {
showNewNoteInput.value = true;
newNoteTitle.value = "";
await nextTick();
newNoteTitleRef.value?.focus();
}
function cancelNewNote() {
showNewNoteInput.value = false;
newNoteTitle.value = "";
}
async function createNote() {
const title = newNoteTitle.value.trim();
if (!title || creatingNote.value) return;
creatingNote.value = true;
try {
const note = await apiPost<{ id: number; title: string; body: string; tags: string[]; updated_at: string }>(
"/api/notes",
{ title, project_id: props.projectId, body: "", is_task: false }
);
showNewNoteInput.value = false;
newNoteTitle.value = "";
await loadProjectNotes();
await openNote(note.id);
} catch {
toast.show("Failed to create note", "error");
} finally {
creatingNote.value = false;
}
}
// TipTap editor ref
const titleRef = ref<HTMLInputElement | null>(null);
const editorRef = ref<InstanceType<typeof TiptapEditor> | null>(null);
const tiptapEditor = computed<Editor | null>(() =>
(editorRef.value?.editor as Editor | undefined) ?? null
);
// Link suggestions
interface LinkSuggestion { note_id: number; title: string; count: number; }
const linkSuggestions = ref<LinkSuggestion[]>([]);
let linkCheckTimer: ReturnType<typeof setTimeout> | null = null;
async function fetchLinkSuggestions() {
if (!editingId.value || !noteBody.value) { linkSuggestions.value = []; return; }
try {
const res = await apiPost<{ suggestions: LinkSuggestion[] }>('/api/notes/link-suggestions', {
body: noteBody.value,
project_id: props.projectId,
exclude_note_id: editingId.value,
});
linkSuggestions.value = res.suggestions;
} catch {
linkSuggestions.value = [];
}
}
function scheduleLinkCheck() {
if (linkCheckTimer) clearTimeout(linkCheckTimer);
linkCheckTimer = setTimeout(fetchLinkSuggestions, 2500);
}
function applyWikilinkToText(text: string, title: string): string {
const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const parts = text.split(/(\[\[[^\]]*\]\])/);
const pattern = new RegExp(`\\b${escaped}\\b`, 'gi');
return parts.map((part, i) =>
i % 2 === 0 ? part.replace(pattern, `[[${title}]]`) : part
).join('');
}
function applyLink(s: LinkSuggestion) {
noteBody.value = applyWikilinkToText(noteBody.value, s.title);
dirty.value = true;
linkSuggestions.value = linkSuggestions.value.filter(x => x.note_id !== s.note_id);
}
function applyAllLinks() {
let text = noteBody.value;
for (const s of linkSuggestions.value) text = applyWikilinkToText(text, s.title);
noteBody.value = text;
dirty.value = true;
linkSuggestions.value = [];
}
async function loadProjectNotes() {
listLoading.value = true;
try {
const data = await apiGet<{ notes: NoteItem[] }>(
`/api/projects/${props.projectId}/notes?type=note&limit=200`
);
projectNotes.value = data.notes ?? [];
} catch {
toast.show("Failed to load notes", "error");
} finally {
listLoading.value = false;
}
}
async function openNote(id: number) {
try {
const note = await apiGet<{ id: number; title: string; body: string; tags: string[]; updated_at: string }>(
`/api/notes/${id}`
);
editingId.value = note.id;
noteTitle.value = note.title;
noteBody.value = note.body ?? "";
noteTags.value = note.tags ?? [];
dirty.value = false;
tagSuggestions.dismissTagSuggestions();
linkSuggestions.value = [];
localStorage.setItem(_noteKey(props.projectId), String(id));
fetchLinkSuggestions();
emit("note-loaded", note.id);
} catch {
toast.show("Failed to load note", "error");
}
}
async function saveNote() {
if (!editingId.value) return;
saving.value = true;
try {
await apiPatch(`/api/notes/${editingId.value}`, {
title: noteTitle.value,
body: noteBody.value,
tags: noteTags.value,
});
dirty.value = false;
const idx = projectNotes.value.findIndex((n) => n.id === editingId.value);
if (idx !== -1) {
projectNotes.value[idx] = {
...projectNotes.value[idx],
title: noteTitle.value,
tags: noteTags.value,
updated_at: new Date().toISOString(),
};
projectNotes.value.sort((a, b) =>
new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()
);
}
} catch {
toast.show("Failed to save note", "error");
} finally {
saving.value = false;
}
}
function requestDelete(id: number, e: Event) {
e.stopPropagation();
if (deletingId.value === id) {
confirmDelete(id);
} else {
deletingId.value = id;
}
}
function cancelDelete(e: Event) {
e.stopPropagation();
deletingId.value = null;
}
async function confirmDelete(id: number) {
pendingDelete.value = id;
try {
await apiDelete(`/api/notes/${id}`);
projectNotes.value = projectNotes.value.filter((n) => n.id !== id);
if (editingId.value === id) {
editingId.value = null;
noteTitle.value = "";
noteBody.value = "";
localStorage.removeItem(_noteKey(props.projectId));
}
} catch {
toast.show("Failed to delete note", "error");
} finally {
pendingDelete.value = null;
deletingId.value = null;
}
}
function formatDate(iso: string): string {
const d = new Date(iso);
const now = new Date();
const diffMs = now.getTime() - d.getTime();
const diffMin = Math.floor(diffMs / 60_000);
const diffHrs = Math.floor(diffMs / 3_600_000);
const diffDays = Math.floor(diffMs / 86_400_000);
if (diffMin < 1) return "just now";
if (diffMin < 60) return `${diffMin}m ago`;
if (diffHrs < 24) return `${diffHrs}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
}
watch(noteTitle, () => { dirty.value = true; });
watch(noteBody, () => { dirty.value = true; if (editingId.value) scheduleLinkCheck(); });
watch(noteTags, () => { dirty.value = true; });
watch(
() => props.activeNoteId,
async (id) => {
if (id) {
await openNote(id);
await loadProjectNotes();
}
}
);
useAutoSave(dirty, saving, saveNote, 60_000);
onMounted(async () => {
await loadProjectNotes();
const stored = localStorage.getItem(_noteKey(props.projectId));
if (stored) {
const id = Number(stored);
if (projectNotes.value.find((n) => n.id === id)) {
openNote(id).catch(() => {});
}
}
});
onUnmounted(() => { if (linkCheckTimer) clearTimeout(linkCheckTimer); });
defineExpose({ reload: loadProjectNotes });
</script>
<template>
<div class="ws-note-editor">
<!-- Left rail: always-visible note list -->
<div class="note-rail">
<div class="rail-header">
<template v-if="showNewNoteInput">
<input
ref="newNoteTitleRef"
v-model="newNoteTitle"
class="new-note-input"
placeholder="Note title..."
:disabled="creatingNote"
@keydown.enter="createNote"
@keydown.escape="cancelNewNote"
/>
<button class="btn-primary btn-inline" :disabled="creatingNote || !newNoteTitle.trim()" @click="createNote">
{{ creatingNote ? '…' : '+' }}
</button>
<button class="btn-text" aria-label="Cancel new note" @click="cancelNewNote"><X :size="16" /></button>
</template>
<template v-else>
<span class="rail-title">Notes</span>
<button class="btn-ghost btn-inline" @click="startNewNote" title="New note">+ New</button>
</template>
</div>
<div class="rail-search">
<input
v-model="searchQuery"
class="rail-search-input"
placeholder="Search…"
type="search"
aria-label="Search notes"
/>
<button v-if="searchQuery" class="btn-text btn-search-clear" aria-label="Clear search" @click="searchQuery = ''"><X :size="16" /></button>
</div>
<div v-if="listLoading" class="rail-state">Loading</div>
<ul v-else class="note-list">
<li
v-for="note in filteredNotes"
:key="note.id"
:class="['note-row', { active: editingId === note.id }]"
@click="openNote(note.id)"
>
<div class="note-row-main">
<span class="note-row-title">{{ note.title || 'Untitled' }}</span>
<span class="note-row-age">{{ formatDate(note.updated_at) }}</span>
</div>
<div v-if="note.tags?.length" class="note-row-tags">
<span
v-for="tag in note.tags.slice(0, 2)"
:key="tag"
:class="['note-tag-pill', { 'tag-match': matchedTags(note).includes(tag) }]"
>#{{ tag }}</span>
<span v-if="note.tags.length > 2" class="note-tag-more">+{{ note.tags.length - 2 }}</span>
</div>
<div class="note-row-actions" @click.stop>
<template v-if="deletingId === note.id">
<button class="btn-danger-outline btn-inline" :disabled="pendingDelete === note.id" @click="requestDelete(note.id, $event)">
{{ pendingDelete === note.id ? '…' : 'Delete?' }}
</button>
<button class="btn-text" aria-label="Cancel delete" @click="cancelDelete($event)"><X :size="16" /></button>
</template>
<button v-else class="btn-text" title="Delete note" @click="requestDelete(note.id, $event)">
<Trash2 :size="16" />
</button>
</div>
</li>
<li v-if="filteredNotes.length === 0" class="rail-state">
{{ searchQuery.trim() ? 'No matches.' : 'No notes yet.' }}
</li>
</ul>
</div>
<!-- Right pane: editor -->
<div class="note-editor-pane">
<template v-if="editingId">
<div class="panel-header">
<div class="editor-header-right">
<WordCount :body="noteBody" />
<span v-if="dirty && !saving" class="unsaved">Unsaved</span>
<span v-if="saving" class="saving-txt">Saving</span>
<button class="btn-primary btn-compact" :disabled="saving || !dirty" @click="saveNote">Save</button>
</div>
</div>
<div class="note-title-row">
<input
ref="titleRef"
v-model="noteTitle"
class="note-title-input"
placeholder="Note title"
@keydown.ctrl.s.prevent="saveNote"
@keydown.ctrl.e.prevent="editorRef?.editor?.commands.focus()"
/>
</div>
<div class="tag-row">
<TagInput v-model="noteTags" :fetchTags="(q: string) => notesStore.fetchAllTags(q)" />
<button
class="btn-ghost btn-compact"
:disabled="tagSuggestions.suggestingTags.value"
title="Auto-suggest tags from title and body"
@click="tagSuggestions.fetchTagSuggestions()"
>
{{ tagSuggestions.suggestingTags.value ? '…' : 'Suggest' }}
</button>
</div>
<div v-if="tagSuggestions.suggestedTags.value.length" class="tag-suggestions">
<span class="tag-suggestions-label">Suggested:</span>
<button
v-for="tag in tagSuggestions.suggestedTags.value"
:key="tag"
:class="['btn-tag-suggestion', { applied: tagSuggestions.appliedTags.value.has(tag) }]"
@click="tagSuggestions.applyTagSuggestion(tag)"
>#{{ tag }}{{ tagSuggestions.appliedTags.value.has(tag) ? ' ✓' : '' }}</button>
<button class="btn-text" aria-label="Dismiss tag suggestions" @click="tagSuggestions.dismissTagSuggestions()"><X :size="16" /></button>
</div>
<div class="toolbar-row">
<MarkdownToolbar :editor="tiptapEditor" />
</div>
<div v-if="linkSuggestions.length" class="link-suggest-strip">
<span class="link-suggest-label">Links:</span>
<span v-for="s in linkSuggestions" :key="s.note_id" class="link-suggest-chip" :title="`Appears ${s.count}× unlinked`">
<button class="btn-chip-link" @click="applyLink(s)">[[{{ s.title }}]]</button>
</span>
<button class="btn-ghost btn-inline" @click="applyAllLinks" title="Link all suggestions">All</button>
<button class="btn-text" aria-label="Dismiss link suggestions" @click="linkSuggestions = []"><X :size="16" /></button>
</div>
<div class="editor-area" @keydown.ctrl.s.prevent="saveNote" @keydown.ctrl.e.prevent="editorRef?.editor?.commands.focus()">
<TiptapEditor ref="editorRef" v-model="noteBody" @escape="titleRef?.focus()" />
</div>
</template>
<div v-else class="editor-empty-state">
<p>Select a note or create a new one</p>
</div>
</div>
</div>
</template>
<style scoped>
.ws-note-editor {
display: flex;
flex-direction: row;
height: 100%;
overflow: hidden;
background: var(--color-surface);
border-left: 1px solid var(--color-border);
}
/* ── Left rail ── */
.note-rail {
width: 200px;
flex-shrink: 0;
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--color-bg-card);
}
.rail-header {
display: flex;
align-items: center;
gap: 0.3rem;
padding: 0.5rem 0.6rem;
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
}
.rail-title {
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.72rem;
font-weight: 500;
flex: 1;
}
.rail-search-input {
flex: 1;
background: transparent;
border: none;
font-size: 0.78rem;
color: var(--color-text);
min-width: 0;
padding: 0;
}
.rail-search-input:focus { outline: none; }
.rail-search-input::-webkit-search-cancel-button { display: none; }
/* Sits inside the search field: no padding, and must not flex. */
.btn-search-clear { padding: 0; flex-shrink: 0; }
.rail-state {
padding: 1rem 0.65rem;
font-size: 0.78rem;
color: var(--color-text-muted);
}
/* Note list */
.note-list {
list-style: none;
margin: 0;
padding: 0;
overflow-y: auto;
flex: 1;
}
.note-row {
display: flex;
flex-direction: column;
padding: 0.4rem 0.6rem;
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 60%, transparent);
cursor: pointer;
gap: 0.15rem;
border-right: 2px solid transparent;
transition: background 0.12s;
}
.note-row:hover { background: color-mix(in srgb, var(--color-primary) 5%, var(--color-surface)); }
.note-row.active {
background: color-mix(in srgb, var(--color-primary) 8%, var(--color-surface));
border-right-color: var(--color-primary);
}
.note-row-main {
display: flex;
align-items: baseline;
gap: 0.25rem;
}
.note-row-title {
flex: 1;
font-size: 0.78rem;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--color-text);
}
.note-row-age {
font-size: 0.62rem;
color: var(--color-text-muted);
white-space: nowrap;
flex-shrink: 0;
}
.note-row-tags {
display: flex;
gap: 0.15rem;
overflow: hidden;
}
.note-tag-pill {
font-size: 0.58rem;
color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
border-radius: 999px;
padding: 0 0.3rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 5rem;
}
.note-tag-pill.tag-match {
background: color-mix(in srgb, var(--color-primary) 22%, transparent);
outline: 1px solid var(--color-primary);
}
.note-tag-more {
font-size: 0.58rem;
color: var(--color-text-muted);
white-space: nowrap;
}
.note-row-actions {
display: flex;
gap: 0.2rem;
align-items: center;
}
.note-row:hover .btn-delete { opacity: 1; }
/* Editor UI */
.panel-header {
display: flex;
align-items: center;
padding: 0.5rem 0.75rem;
flex-shrink: 0;
gap: 0.5rem;
}
.editor-header-right {
display: flex;
align-items: center;
gap: 0.5rem;
margin-left: auto;
}
.unsaved { font-size: 0.72rem; color: var(--color-text-muted); }
.saving-txt { font-size: 0.72rem; color: var(--color-primary); }
/* Moss action-primary per Hybrid */
.note-title-input {
width: 100%;
background: transparent;
border: none;
font-size: 1.4rem;
font-weight: 500;
line-height: 1.25;
color: var(--color-text);
padding: 0;
font-family: 'Fraunces', serif;
letter-spacing: -0.01em;
}
.note-title-input:focus { outline: none; }
.note-title-input::placeholder {
color: var(--color-text-muted);
}
.tag-row {
display: flex;
align-items: flex-start;
gap: 0.4rem;
padding: 0.4rem 0.6rem;
flex-shrink: 0;
}
.tag-row > :first-child { flex: 1; min-width: 0; }
.btn-suggest-tags { flex-shrink: 0; align-self: center; }
.tag-suggestions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.3rem;
padding: 0.35rem 0.6rem;
background: color-mix(in srgb, var(--color-primary) 5%, var(--color-surface));
flex-shrink: 0;
}
.tag-suggestions-label { font-size: 0.72rem; color: var(--color-text-muted); flex-shrink: 0; }
.btn-tag-suggestion {
background: none;
border: 1px solid var(--color-border);
border-radius: 999px;
padding: 0.15rem 0.55rem;
font-size: 0.75rem;
color: var(--color-text);
cursor: pointer;
}
.btn-tag-suggestion:hover { border-color: var(--color-primary); color: var(--color-primary); }
.btn-tag-suggestion.applied {
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
border-color: var(--color-primary);
color: var(--color-primary);
}
.link-suggest-strip {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.25rem;
padding: 0.3rem 0.6rem;
background: color-mix(in srgb, var(--color-primary) 5%, var(--color-surface));
flex-shrink: 0;
}
.link-suggest-label {
font-size: 0.7rem;
color: var(--color-text-muted);
flex-shrink: 0;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.link-suggest-chip { display: inline-flex; }
.btn-chip-link {
background: none;
border: 1px solid var(--color-primary);
border-radius: 999px;
padding: 0.1rem 0.45rem;
font-size: 0.7rem;
color: var(--color-primary);
cursor: pointer;
font-family: monospace;
white-space: nowrap;
}
.btn-chip-link:hover { background: color-mix(in srgb, var(--color-primary) 15%, transparent); }
</style>