Files
FabledScribe/frontend/src/components/WorkspaceNoteEditor.vue
T
bvandeusen f422d4158c style(workspace-notes): larger, more spacious note title
Title input bumps to 1.4rem with Fraunces serif (matches the
design language's heading treatment) and gains 0.9/1.1rem padding
on the title row for breathing room.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 14:52:58 -04:00

883 lines
24 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";
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-confirm" :disabled="creatingNote || !newNoteTitle.trim()" @click="createNote">
{{ creatingNote ? '…' : '+' }}
</button>
<button class="btn-cancel" aria-label="Cancel new note" @click="cancelNewNote"></button>
</template>
<template v-else>
<span class="rail-title">Notes</span>
<button class="btn-new-note" @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-search-clear" aria-label="Clear search" @click="searchQuery = ''"></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-confirm-delete" :disabled="pendingDelete === note.id" @click="requestDelete(note.id, $event)">
{{ pendingDelete === note.id ? '…' : 'Delete?' }}
</button>
<button class="btn-cancel-delete" aria-label="Cancel delete" @click="cancelDelete($event)"></button>
</template>
<button v-else class="btn-delete" title="Delete note" @click="requestDelete(note.id, $event)">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>
</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-save" :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-suggest-tags"
: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-dismiss-suggestions" aria-label="Dismiss tag suggestions" @click="tagSuggestions.dismissTagSuggestions()"></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-link-all" @click="applyAllLinks" title="Link all suggestions">All</button>
<button class="btn-dismiss-suggestions" aria-label="Dismiss link suggestions" @click="linkSuggestions = []"></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, var(--color-bg-secondary));
}
.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: 600;
flex: 1;
}
.btn-new-note {
background: none;
border: 1px solid var(--color-border);
border-radius: 4px;
padding: 0.15rem 0.4rem;
font-size: 0.7rem;
color: var(--color-text-muted);
cursor: pointer;
white-space: nowrap;
}
.btn-new-note:hover { border-color: var(--color-primary); color: var(--color-primary); }
.rail-search {
display: flex;
align-items: center;
gap: 0.2rem;
padding: 0.3rem 0.5rem;
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
}
.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; }
.btn-search-clear {
background: none;
border: none;
color: var(--color-text-muted);
font-size: 0.68rem;
cursor: pointer;
padding: 0;
line-height: 1;
flex-shrink: 0;
}
.btn-search-clear:hover { color: var(--color-text); }
.rail-state {
padding: 1rem 0.65rem;
font-size: 0.78rem;
color: var(--color-text-muted);
font-style: italic;
}
/* 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;
}
.btn-delete {
background: none;
border: none;
color: var(--color-text-muted);
cursor: pointer;
padding: 0.1rem;
border-radius: 3px;
opacity: 0;
transition: opacity 0.1s, color 0.1s;
display: flex;
align-items: center;
}
.note-row:hover .btn-delete { opacity: 1; }
.btn-delete:hover { color: var(--color-danger, #e74c3c); }
.btn-confirm-delete {
background: none;
border: 1px solid var(--color-danger, #e74c3c);
color: var(--color-danger, #e74c3c);
font-size: 0.65rem;
font-weight: 600;
cursor: pointer;
padding: 0.1rem 0.3rem;
border-radius: 3px;
}
.btn-confirm-delete:disabled { opacity: 0.5; cursor: default; }
.btn-cancel-delete {
background: none;
border: none;
color: var(--color-text-muted);
font-size: 0.7rem;
cursor: pointer;
padding: 0.1rem;
}
.btn-cancel-delete:hover { color: var(--color-text); }
/* Inline new note */
.new-note-input {
flex: 1;
background: var(--color-input-bg, var(--color-bg));
border: 1px solid var(--color-primary);
border-radius: 4px;
padding: 0.15rem 0.35rem;
font-size: 0.78rem;
color: var(--color-text);
min-width: 0;
}
.new-note-input:focus { outline: none; }
.btn-confirm {
background: var(--color-primary);
color: #fff;
border: none;
border-radius: 4px;
padding: 0.15rem 0.35rem;
font-size: 0.85rem;
cursor: pointer;
flex-shrink: 0;
line-height: 1;
}
.btn-confirm:disabled { opacity: 0.4; cursor: default; }
.btn-cancel {
background: none;
border: none;
color: var(--color-text-muted);
font-size: 0.72rem;
cursor: pointer;
padding: 0.1rem;
flex-shrink: 0;
}
.btn-cancel:hover { color: var(--color-text); }
/* ── Right editor pane ── */
.note-editor-pane {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 0;
}
.editor-empty-state {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: var(--color-text-muted);
font-size: 0.85rem;
font-style: italic;
}
/* 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); }
.btn-save {
background: var(--color-primary);
color: #fff;
border: none;
border-radius: 5px;
padding: 0.25rem 0.7rem;
font-size: 0.8rem;
cursor: pointer;
}
.btn-save:disabled { opacity: 0.4; cursor: default; }
.note-title-row {
padding: 0.9rem 1.1rem 0.5rem;
flex-shrink: 0;
}
.note-title-input {
width: 100%;
background: transparent;
border: none;
font-size: 1.4rem;
font-weight: 600;
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);
font-style: italic;
}
.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 {
background: none;
border: 1px solid var(--color-border);
border-radius: 5px;
padding: 0.25rem 0.55rem;
font-size: 0.75rem;
color: var(--color-text-muted);
cursor: pointer;
white-space: nowrap;
flex-shrink: 0;
align-self: center;
}
.btn-suggest-tags:hover:not(:disabled) { border-color: var(--color-primary); color: var(--color-primary); }
.btn-suggest-tags:disabled { opacity: 0.5; cursor: default; }
.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);
}
.btn-dismiss-suggestions {
background: none;
border: none;
color: var(--color-text-muted);
cursor: pointer;
font-size: 0.75rem;
margin-left: auto;
padding: 0.1rem 0.3rem;
}
.btn-dismiss-suggestions:hover { color: var(--color-text); }
.toolbar-row {
padding: 0.3rem 0.6rem;
flex-shrink: 0;
}
.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: 600;
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); }
.btn-link-all {
background: none;
border: 1px solid var(--color-border);
border-radius: 4px;
padding: 0.1rem 0.4rem;
font-size: 0.7rem;
color: var(--color-text-muted);
cursor: pointer;
margin-left: 0.1rem;
}
.btn-link-all:hover { border-color: var(--color-primary); color: var(--color-primary); }
.editor-area {
flex: 1;
overflow-y: auto;
padding: 0.5rem 0.6rem;
}
</style>