CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / integration (push) Successful in 17s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 48s
CI & Build / Build & push image (push) Successful in 37s
#2533. theme.css claimed "removing this block is a rename sweep across the
components, tracked separately" — written in 67a529a, never filed, which made
the comment itself an instance of the survey's presence-without-reference
pattern. This is that sweep.
73 alias declarations deleted; 69 files rewritten; every --color-*-style name
now references its --fs-* token directly. Mechanical by construction: the map
IS the alias block, applied longest-name-first with a boundary guard so
--color-text never matched inside --color-text-muted. Zero survivors outside
theme.css, verified by grep rather than assumed.
One deliberate survivor: --color-shadow stays DECLARED, because it was never
an alias — it is a literal value the design system has no token for. Marked
in place as a recorded gap: promote it to an --fs-* token when a second app
needs it, don't copy the line.
Nothing is lost mode-wise: the aliases' resolve-at-use-time trick (which
absorbed 48 dark-mode overrides) lives one layer down in the --fs-* tokens'
own derivations, which is why the sweep is a pure rename. Both CSS checkers
green.
Why now rather than never: check_snippets_against_design_system reports every
--color-* reference as "unknown — renders as NOTHING", and nine recipe
snippets recorded from components.css carried the deprecated names, making
them prior art pointing the wrong way. With the sweep in, the checker's
report over re-recorded snippets should be EMPTY — the acceptance test that
proves the checker was right all along (#2517's correction).
Refs #2533
703 lines
20 KiB
Vue
703 lines
20 KiB
Vue
<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(--fs-surface-hover);
|
||
border-left: 1px solid var(--fs-border-color);
|
||
}
|
||
|
||
/* ── Left rail ── */
|
||
.note-rail {
|
||
width: 200px;
|
||
flex-shrink: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
background: var(--fs-surface-raised);
|
||
}
|
||
|
||
.rail-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.3rem;
|
||
padding: 0.5rem 0.6rem;
|
||
border-bottom: 1px solid var(--fs-border-color);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.rail-title {
|
||
color: var(--fs-text-tertiary);
|
||
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(--fs-text-primary);
|
||
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(--fs-text-tertiary);
|
||
}
|
||
|
||
/* 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(--fs-border-color) 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(--fs-accent) 5%, var(--fs-surface-hover)); }
|
||
.note-row.active {
|
||
background: color-mix(in srgb, var(--fs-accent) 8%, var(--fs-surface-hover));
|
||
border-right-color: var(--fs-accent);
|
||
}
|
||
|
||
.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(--fs-text-primary);
|
||
}
|
||
|
||
.note-row-age {
|
||
font-size: 0.62rem;
|
||
color: var(--fs-text-tertiary);
|
||
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(--fs-accent);
|
||
background: color-mix(in srgb, var(--fs-accent) 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(--fs-accent) 22%, transparent);
|
||
outline: 1px solid var(--fs-accent);
|
||
}
|
||
|
||
.note-tag-more {
|
||
font-size: 0.58rem;
|
||
color: var(--fs-text-tertiary);
|
||
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(--fs-text-tertiary); }
|
||
.saving-txt { font-size: 0.72rem; color: var(--fs-accent); }
|
||
|
||
/* 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(--fs-text-primary);
|
||
padding: 0;
|
||
font-family: 'Fraunces', serif;
|
||
letter-spacing: -0.01em;
|
||
}
|
||
.note-title-input:focus { outline: none; }
|
||
.note-title-input::placeholder {
|
||
color: var(--fs-text-tertiary);
|
||
}
|
||
|
||
.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(--fs-accent) 5%, var(--fs-surface-hover));
|
||
flex-shrink: 0;
|
||
}
|
||
.tag-suggestions-label { font-size: 0.72rem; color: var(--fs-text-tertiary); flex-shrink: 0; }
|
||
|
||
.btn-tag-suggestion {
|
||
background: none;
|
||
border: 1px solid var(--fs-border-color);
|
||
border-radius: 999px;
|
||
padding: 0.15rem 0.55rem;
|
||
font-size: 0.75rem;
|
||
color: var(--fs-text-primary);
|
||
cursor: pointer;
|
||
}
|
||
.btn-tag-suggestion:hover { border-color: var(--fs-accent); color: var(--fs-accent); }
|
||
.btn-tag-suggestion.applied {
|
||
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
|
||
border-color: var(--fs-accent);
|
||
color: var(--fs-accent);
|
||
}
|
||
|
||
|
||
.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(--fs-accent) 5%, var(--fs-surface-hover));
|
||
flex-shrink: 0;
|
||
}
|
||
.link-suggest-label {
|
||
font-size: 0.7rem;
|
||
color: var(--fs-text-tertiary);
|
||
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(--fs-accent);
|
||
border-radius: 999px;
|
||
padding: 0.1rem 0.45rem;
|
||
font-size: 0.7rem;
|
||
color: var(--fs-accent);
|
||
cursor: pointer;
|
||
font-family: monospace;
|
||
white-space: nowrap;
|
||
}
|
||
.btn-chip-link:hover { background: color-mix(in srgb, var(--fs-accent) 15%, transparent); }
|
||
|
||
</style>
|