1
Fork 0
mirror of https://github.com/thegeneralist01/archivr synced 2026-07-22 03:05:32 +02:00

feat(ui/tags): tooltips, entry counts (direct + N Total), Create/Move flows, Esc handling

- Tooltips on all action buttons (pencil, ×, + New, Move, picker close/nodes)
- Tag rows show 'Name (own) (N Total)': own = direct assignments,
  Total = unique entries on this tag or any descendant (COUNT DISTINCT)
- + New flow: header button → TagPickerModal → inline CreateInput at chosen
  level; Enter saves, Escape cancels; identical UX to rename
- Move flow (2-step): Move button → source-select mode → click source →
  TagPickerModal minus the moving subtree → pick destination or root;
  onTagRenamed(oldPath, updated.full_path) keeps tag filter coherent
- Esc: document keydown in TagPickerModal + separate useEffect in TagsView
  for source-select mode only; both preventDefault+stopPropagation so
  App's window-level handler does not also clear selected entries
- TagsView.stories.jsx: 6 stories, fetch stub scoped to useEffect
- Rebuilt static assets
This commit is contained in:
TheGeneralist 2026-07-19 10:56:59 +02:00
parent af9db0e50d
commit 506d23f05f
Signed by: thegeneralist01
SSH key fingerprint: SHA256:pp9qddbCNmVNoSjevdvQvM5z0DHN7LTa8qBMbcMq/R4
8 changed files with 712 additions and 106 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -4,8 +4,8 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Archivr</title>
<script type="module" crossorigin src="/assets/index-Db35_tmT.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C0COQCCD.css">
<script type="module" crossorigin src="/assets/index-BpGwC-YY.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DLdY9nrw.css">
</head>
<body>
<div id="root"></div>

View file

@ -111,6 +111,26 @@ export async function deleteTag(archiveId, tagUid) {
if (!res.ok) throw new Error(await res.text());
}
export async function createTag(archiveId, path) {
const res = await fetch(`/api/archives/${archiveId}/tags`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path }),
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function moveTag(archiveId, tagUid, parentUid) {
const res = await fetch(`/api/archives/${archiveId}/tags/${tagUid}/move`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ parent_uid: parentUid ?? null }),
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function fetchRuns(archiveId) {
return getJson(`/api/archives/${archiveId}/runs`);
}

View file

@ -1,14 +1,156 @@
import { useState, useRef } from 'react';
import { renameTag, deleteTag } from '../api';
import { useState, useRef, useEffect } from 'react';
import { renameTag, deleteTag, createTag, moveTag } from '../api';
function TagNode({ node, archiveId, tagFilter, onTagFilterSet, onViewChange, onTagRenamed, onTagDeleted, onTagsRefresh, humanizeTags }) {
// TagPickerNode
// A node inside the destination picker modal.
function TagPickerNode({ node, onPick }) {
return (
<li>
<button
className="tag-picker-node-btn"
title={node.tag.full_path}
onClick={() => onPick(node.tag)}
>
{node.tag.slug}
</button>
{node.children?.length > 0 && (
<div className="tag-children">
<ul className="tag-tree-list">
{node.children.map(child => (
<TagPickerNode key={child.tag.tag_uid} node={child} onPick={onPick} />
))}
</ul>
</div>
)}
</li>
);
}
// TagPickerModal
// Shared modal for "create under" and "move under" destination selection.
// `excludeUid` hides that tag and all its descendants (used for move).
// `onPick(tag | null)` null means "make root / no parent".
function TagPickerModal({ title, tagNodes, excludeUid, onPick, onCancel }) {
useEffect(() => {
function onKey(e) { if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); onCancel(); } }
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [onCancel]);
function filterTree(nodes) {
return nodes
.filter(n => n.tag.tag_uid !== excludeUid)
.map(n => ({ ...n, children: filterTree(n.children) }));
}
const visibleNodes = excludeUid ? filterTree(tagNodes) : tagNodes;
return (
<div
className="tag-picker-backdrop"
onClick={e => { if (e.target === e.currentTarget) onCancel(); }}
>
<div className="tag-picker-modal" role="dialog" aria-modal="true">
<div className="tag-picker-header">
<span className="tag-picker-title">{title}</span>
<button
className="tag-picker-close"
onClick={onCancel}
title="Cancel"
aria-label="Cancel"
>×</button>
</div>
<div className="tag-picker-body">
<button
className="tag-picker-root-btn"
onClick={() => onPick(null)}
title="Place at root level (no parent)"
>
Root tag (no parent)
</button>
{visibleNodes.length > 0 ? (
<ul className="tag-tree-list tag-picker-tree">
{visibleNodes.map(node => (
<TagPickerNode key={node.tag.tag_uid} node={node} onPick={onPick} />
))}
</ul>
) : (
<p className="tag-picker-empty">No other tags available.</p>
)}
</div>
</div>
</div>
);
}
// CreateInput
// Inline text input that appears in the tag tree when creating a new tag.
// Matches the rename-input UX: Enter saves, Escape cancels, blur saves.
function CreateInput({ parentPath, archiveId, onDone, onCancel }) {
const [draft, setDraft] = useState('');
const cancelRef = useRef(false);
async function submit() {
const name = draft.trim();
if (!name) { onCancel(); return; }
const path = parentPath ? `${parentPath}/${name}` : `/${name}`;
try {
await createTag(archiveId, path);
onDone();
} catch (err) {
alert(err.message || 'Create failed');
onCancel();
}
}
return (
<input
className="tag-rename-input"
autoFocus
placeholder="tag name"
value={draft}
onChange={e => setDraft(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') e.currentTarget.blur();
if (e.key === 'Escape') { cancelRef.current = true; e.currentTarget.blur(); }
}}
onBlur={() => {
if (cancelRef.current) { cancelRef.current = false; onCancel(); }
else { submit(); }
}}
/>
);
}
// TagNode
function TagNode({
node,
archiveId,
tagFilter,
onTagFilterSet,
onViewChange,
onTagRenamed,
onTagDeleted,
onTagsRefresh,
humanizeTags,
// Move source-selection mode: clicking a tag selects it as the thing to move.
moveSelectMode,
onMoveSourceSelect,
// Pending create: the tag_uid of the parent that should render an inline input,
// or '__root__' for the root list (handled in TagsView, not here).
pendingCreateParentUid,
onCreateDone,
onCreateCancel,
}) {
const isActive = tagFilter === node.tag.full_path;
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState('');
const cancelRef = useRef(false);
function handleFilterClick() {
function handleClick() {
if (editing) return;
if (moveSelectMode) {
onMoveSourceSelect(node);
return;
}
const next = isActive ? null : node.tag.full_path;
onTagFilterSet(next);
onViewChange('archive');
@ -16,16 +158,14 @@ function TagNode({ node, archiveId, tagFilter, onTagFilterSet, onViewChange, onT
function startEdit(e) {
e.stopPropagation();
if (moveSelectMode) return;
setDraft(node.tag.slug);
setEditing(true);
}
async function handleRenameSave() {
const value = draft.trim();
if (!value || value === node.tag.slug) {
setEditing(false);
return;
}
if (!value || value === node.tag.slug) { setEditing(false); return; }
try {
const updated = await renameTag(archiveId, node.tag.tag_uid, value);
onTagRenamed(node.tag.full_path, updated.full_path);
@ -52,11 +192,18 @@ function TagNode({ node, archiveId, tagFilter, onTagFilterSet, onViewChange, onT
}
}
const childProps = { archiveId, tagFilter, onTagFilterSet, onViewChange, onTagRenamed, onTagDeleted, onTagsRefresh, humanizeTags };
const childProps = {
archiveId, tagFilter, onTagFilterSet, onViewChange, onTagRenamed, onTagDeleted,
onTagsRefresh, humanizeTags, moveSelectMode, onMoveSourceSelect,
pendingCreateParentUid, onCreateDone, onCreateCancel,
};
const showCreateInput = pendingCreateParentUid === node.tag.tag_uid;
const hasChildren = node.children?.length > 0;
return (
<li>
<div className="tag-node-row">
<div className={`tag-node-row${moveSelectMode ? ' tag-node-row--move-select' : ''}`}>
{editing ? (
<input
className="tag-rename-input"
@ -68,22 +215,24 @@ function TagNode({ node, archiveId, tagFilter, onTagFilterSet, onViewChange, onT
if (e.key === 'Escape') { cancelRef.current = true; e.currentTarget.blur(); }
}}
onBlur={() => {
if (cancelRef.current) {
cancelRef.current = false;
setEditing(false);
} else {
handleRenameSave();
}
if (cancelRef.current) { cancelRef.current = false; setEditing(false); }
else { handleRenameSave(); }
}}
/>
) : (
<button
className={`tag-node-btn${isActive ? ' is-active' : ''}`}
title={node.tag.full_path}
onClick={handleFilterClick}
onDoubleClick={startEdit}
className={`tag-node-btn${isActive ? ' is-active' : ''}${moveSelectMode ? ' tag-node-btn--move-select' : ''}`}
title={moveSelectMode ? `Select "${node.tag.full_path}" to move` : node.tag.full_path}
onClick={handleClick}
onDoubleClick={moveSelectMode ? undefined : startEdit}
>
{humanizeTags ? node.tag.name : node.tag.slug}
<span className="tag-node-label">{humanizeTags ? node.tag.name : node.tag.slug}</span>
<span className="tag-node-count">
{node.children.length === 0
? `(${node.entry_count})`
: `(${node.entry_count}) (${node.subtree_count} Total)`}
</span>
{!moveSelectMode && (
<svg
className="edit-icon"
viewBox="0 0 16 16"
@ -93,25 +242,39 @@ function TagNode({ node, archiveId, tagFilter, onTagFilterSet, onViewChange, onT
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
title="Rename tag"
onClick={e => { e.stopPropagation(); startEdit(e); }}
>
<path d="M11.5 2.5a1.5 1.5 0 0 1 2 2L5 13l-3 1 1-3 8.5-8.5z"/>
</svg>
)}
</button>
)}
{!editing && !moveSelectMode && (
<button
className="remove tag-node-delete"
title={`Delete tag ${node.tag.full_path}`}
title={`Delete "${node.tag.full_path}"`}
onClick={handleDelete}
aria-label={`Delete tag ${node.tag.full_path}`}
aria-label={`Delete "${node.tag.full_path}"`}
>×</button>
)}
</div>
{node.children?.length > 0 && (
{(hasChildren || showCreateInput) && (
<div className="tag-children">
<ul className="tag-tree-list">
{node.children.map(child => (
<TagNode key={child.tag.tag_uid} node={child} {...childProps} />
))}
{showCreateInput && (
<li>
<CreateInput
parentPath={node.tag.full_path}
archiveId={archiveId}
onDone={onCreateDone}
onCancel={onCreateCancel}
/>
</li>
)}
</ul>
</div>
)}
@ -119,35 +282,162 @@ function TagNode({ node, archiveId, tagFilter, onTagFilterSet, onViewChange, onT
);
}
export default function TagsView({ archiveId, tagNodes, tagFilter, onTagFilterSet, onViewChange, onTagRenamed, onTagDeleted, onTagsRefresh, humanizeTags }) {
// TagsView
export default function TagsView({
archiveId, tagNodes, tagFilter, onTagFilterSet, onViewChange,
onTagRenamed, onTagDeleted, onTagsRefresh, humanizeTags,
}) {
// Move state
// moveStep: null | 'select-source' | 'select-dest'
const [moveStep, setMoveStep] = useState(null);
const [moveSourceNode, setMoveSourceNode] = useState(null);
function startMoveMode() { setMoveStep('select-source'); setMoveSourceNode(null); }
function cancelMove() { setMoveStep(null); setMoveSourceNode(null); }
// Esc while in source-selection mode cancels the move.
useEffect(() => {
if (moveStep !== 'select-source') return;
function onKey(e) { if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); cancelMove(); } }
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [moveStep]);
function handleMoveSourceSelect(node) {
setMoveSourceNode(node);
setMoveStep('select-dest');
}
async function handleMoveDest(destTag) {
// destTag: Tag object | null (null = make root)
const sourceOldPath = moveSourceNode.tag.full_path;
const sourceUid = moveSourceNode.tag.tag_uid;
const destUid = destTag?.tag_uid ?? null;
cancelMove();
try {
const updated = await moveTag(archiveId, sourceUid, destUid);
// Keep the active tag filter consistent when the moved tag/subtree was active.
onTagRenamed(sourceOldPath, updated.full_path);
onTagsRefresh();
} catch (err) {
alert(err.message || 'Move failed');
}
}
// Create state
// createStep: null | 'select-parent' | 'input'
const [createStep, setCreateStep] = useState(null);
const [createParentTag, setCreateParentTag] = useState(undefined);
function startCreateMode() { setCreateStep('select-parent'); setCreateParentTag(undefined); }
function cancelCreate() { setCreateStep(null); setCreateParentTag(undefined); }
function handleCreateParentPick(tag) {
// tag: Tag | null (null = root)
setCreateParentTag(tag ?? null);
setCreateStep('input');
}
function handleCreateDone() {
setCreateStep(null);
setCreateParentTag(undefined);
onTagsRefresh();
}
// Derived
const moveSelectMode = moveStep === 'select-source';
// The tag_uid of the parent that should render a create input inside its
// children list. '__root__' means the root-level list handles it.
const pendingCreateParentUid = createStep === 'input'
? (createParentTag ? createParentTag.tag_uid : '__root__')
: undefined;
const nodeProps = {
archiveId, tagFilter, onTagFilterSet, onViewChange, onTagRenamed, onTagDeleted,
onTagsRefresh, humanizeTags, moveSelectMode, onMoveSourceSelect: handleMoveSourceSelect,
pendingCreateParentUid, onCreateDone: handleCreateDone, onCreateCancel: cancelCreate,
};
const showRootCreateInput = pendingCreateParentUid === '__root__';
return (
<section id="tags-view" className="view is-active">
<div className="tag-tree">
<div className="tag-tree-header">
{moveSelectMode ? (
<>
<span className="tag-tree-title tag-tree-title--move">Select a tag to move</span>
<button
className="tag-tree-action-btn tag-tree-action-btn--cancel"
onClick={cancelMove}
>Cancel</button>
</>
) : (
<>
<span className="tag-tree-title">Tags</span>
{tagFilter && (
<span className="tag-tree-active">Filtering: {tagFilter}</span>
<span className="tag-tree-active" title={tagFilter}>Filtering: {tagFilter}</span>
)}
<div className="tag-tree-actions">
<button
className="tag-tree-action-btn"
onClick={startCreateMode}
title="Create a new tag"
disabled={!!createStep || !!moveStep}
>+ New</button>
<button
className="tag-tree-action-btn"
onClick={startMoveMode}
title="Move a tag to a different parent"
disabled={!!createStep || !!moveStep || tagNodes.length === 0}
>Move</button>
</div>
</>
)}
</div>
{tagNodes.length === 0 ? (
{tagNodes.length === 0 && !showRootCreateInput ? (
<p className="muted" style={{ padding: '8px 0' }}>No tags yet.</p>
) : (
<ul className="tag-tree-list">
{tagNodes.map(node => (
<TagNode key={node.tag.tag_uid} node={node}
archiveId={archiveId}
tagFilter={tagFilter}
onTagFilterSet={onTagFilterSet}
onViewChange={onViewChange}
onTagRenamed={onTagRenamed}
onTagDeleted={onTagDeleted}
onTagsRefresh={onTagsRefresh}
humanizeTags={humanizeTags}
/>
<TagNode key={node.tag.tag_uid} node={node} {...nodeProps} />
))}
{showRootCreateInput && (
<li>
<CreateInput
parentPath={null}
archiveId={archiveId}
onDone={handleCreateDone}
onCancel={cancelCreate}
/>
</li>
)}
</ul>
)}
</div>
{/* Create: pick parent modal */}
{createStep === 'select-parent' && (
<TagPickerModal
title="Create tag under…"
tagNodes={tagNodes}
excludeUid={null}
onPick={handleCreateParentPick}
onCancel={cancelCreate}
/>
)}
{/* Move: pick destination modal (shown after source is selected) */}
{moveStep === 'select-dest' && moveSourceNode && (
<TagPickerModal
title={`Move "${moveSourceNode.tag.slug}" under…`}
tagNodes={tagNodes}
excludeUid={moveSourceNode.tag.tag_uid}
onPick={handleMoveDest}
onCancel={cancelMove}
/>
)}
</section>
);
}

View file

@ -0,0 +1,167 @@
import { useState, useEffect } from 'react';
import TagsView from './TagsView';
// Installs a per-story fetch stub that intercepts /api/ tag mutations and
// returns realistic Tag shapes so renameTag/moveTag/createTag don't throw.
// Installed in useEffect so it never leaks into other stories and won't
// double-wrap on re-renders.
function ApiStub({ children }) {
useEffect(() => {
const realFetch = window.fetch.bind(window);
window.fetch = (url, opts) => {
if (typeof url === 'string' && url.startsWith('/api/')) {
const method = (opts?.method ?? 'GET').toUpperCase();
if (method === 'DELETE') {
return Promise.resolve(new Response(null, { status: 204 }));
}
// Parse request body to construct a realistic Tag shape.
// renameTag sends { name }, moveTag/createTag send path info.
// full_path must be present or callers throw on updated.full_path.
let body = {};
try { body = JSON.parse(opts?.body ?? '{}'); } catch { /* ignore */ }
const slug = (body.name ?? body.path ?? 'stub')
.trim().replace(/\s+/g, '-').replace(/[^a-zA-Z0-9-]/g, '').replace(/^-+|-+$/g, '') || 'stub';
const stubTag = {
tag_uid: 'stub-uid',
name: slug.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase()),
slug,
full_path: `/${slug}`,
};
return Promise.resolve(
new Response(JSON.stringify(stubTag), {
status: method === 'POST' ? 201 : 200,
headers: { 'Content-Type': 'application/json' },
})
);
}
return realFetch(url, opts);
};
return () => { window.fetch = realFetch; };
}, []);
return children;
}
function withApiStub(Story) {
return <ApiStub><Story /></ApiStub>;
}
export default {
component: TagsView,
tags: ['autodocs'],
parameters: { layout: 'padded' },
decorators: [withApiStub],
};
// Shared fixtures
const noop = () => {};
function tag(tag_uid, name, slug, full_path, entry_count = 0, children = [], subtree_count = null) {
return { tag: { tag_uid, name, slug, full_path }, entry_count, subtree_count: subtree_count ?? entry_count, children };
}
const sampleTree = [
tag('t1', 'Science', 'science', '/science', 12, [
tag('t2', 'Computer Science', 'computer-science', '/science/computer-science', 7, [
tag('t3', 'Algorithms', 'algorithms', '/science/computer-science/algorithms', 3),
tag('t4', 'Compilers', 'compilers', '/science/computer-science/compilers', 1),
], 11),
tag('t5', 'Physics', 'physics', '/science/physics', 4),
], 23),
tag('t6', 'History', 'history', '/history', 5, [
tag('t7', 'Ancient', 'ancient', '/history/ancient', 2),
], 7),
tag('t8', 'Reading List', 'reading-list', '/reading-list', 0),
];
// Wrapper that wires local state so onTagsRefresh/onTagRenamed callbacks
// keep the tree consistent within a story session.
function TagsViewSandbox({ initialNodes = sampleTree, tagFilter = null, humanizeTags = false }) {
const [nodes, setNodes] = useState(initialNodes);
const [filter, setFilter] = useState(tagFilter);
function handleTagRenamed(oldPath, newPath) {
if (filter === oldPath) setFilter(newPath);
else if (filter?.startsWith(oldPath + '/')) setFilter(newPath + filter.slice(oldPath.length));
}
function handleTagDeleted(deletedPath) {
if (filter === deletedPath || filter?.startsWith(deletedPath + '/')) setFilter(null);
}
// onTagsRefresh is a no-op here: in a real app it re-fetches; the stub
// tag returned from fetch won't match our fixture tree, so the tree stays
// as-is after mutations. That is acceptable for visual QA purposes.
return (
<div style={{ maxWidth: 340, fontFamily: 'Helvetica Neue, sans-serif' }}>
<TagsView
archiveId="demo"
tagNodes={nodes}
tagFilter={filter}
onTagFilterSet={setFilter}
onViewChange={noop}
onTagRenamed={handleTagRenamed}
onTagDeleted={handleTagDeleted}
onTagsRefresh={noop}
humanizeTags={humanizeTags}
/>
</div>
);
}
// Stories
/** Default view with a nested tag tree. All interactions are exercisable:
* click + New / Move to open the picker modal; click a tag to filter;
* double-click or pencil to rename; × to delete.
* API mutations are stubbed the tree won't re-fetch after saves,
* but no network errors will occur. */
export const Default = {
render: () => <TagsViewSandbox />,
};
/** Empty archive — "No tags yet." shown; + New still works. */
export const Empty = {
render: () => <TagsViewSandbox initialNodes={[]} />,
};
/** Humanize mode: slugs displayed as Title Case names. */
export const HumanizedNames = {
render: () => <TagsViewSandbox humanizeTags />,
};
/** Active tag filter — header shows the current filter path. */
export const WithActiveFilter = {
render: () => (
<TagsViewSandbox tagFilter="/science/computer-science" />
),
};
/** Flat list with no nesting. */
export const FlatList = {
render: () => (
<TagsViewSandbox
initialNodes={[
tag('a1', 'Books', 'books', '/books', 8),
tag('a2', 'Films', 'films', '/films', 3),
tag('a3', 'Music', 'music', '/music', 0),
tag('a4', 'Podcasts', 'podcasts', '/podcasts', 14),
]}
/>
),
};
/** Tags with large entry counts — verifies count badge layout. */
export const HighCounts = {
render: () => (
<TagsViewSandbox
initialNodes={[
tag('h1', 'All', 'all', '/all', 9999, [
tag('h2', 'Starred', 'starred', '/all/starred', 432),
tag('h3', 'Archive', 'archive', '/all/archive', 1204),
]),
]}
/>
),
};

View file

@ -1279,6 +1279,135 @@ select {
outline: none;
}
/* entry count badge next to tag name */
.tag-node-label { flex-shrink: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tag-node-count {
font-size: 11px;
color: var(--muted-2);
font-weight: 400;
flex-shrink: 0;
margin-left: 3px;
}
/* header action buttons (+ New, Move) */
.tag-tree-header { align-items: center; }
.tag-tree-actions { margin-left: auto; display: flex; gap: 4px; }
.tag-tree-action-btn {
font-size: 11px;
padding: 2px 7px;
border: 1px solid var(--line);
border-radius: var(--r);
background: transparent;
color: var(--muted);
cursor: pointer;
line-height: 1.5;
white-space: nowrap;
transition: background 0.1s, color 0.1s;
}
.tag-tree-action-btn:hover:not(:disabled) { background: var(--paper-2); color: var(--ink); }
.tag-tree-action-btn:disabled { opacity: 0.4; cursor: default; }
.tag-tree-action-btn--cancel { border-color: var(--accent); color: var(--accent); }
.tag-tree-action-btn--cancel:hover { background: color-mix(in srgb, var(--accent) 8%, transparent); }
.tag-tree-title--move { font-style: italic; color: var(--accent-2); }
/* move source-select mode: nodes act as clickable targets */
.tag-node-row--move-select .tag-node-btn { cursor: crosshair; color: var(--ink); }
.tag-node-row--move-select .tag-node-btn:hover {
background: color-mix(in srgb, var(--link) 10%, transparent);
text-decoration: none;
border-radius: var(--r);
}
/* ── Tag picker modal (create/move destination) ──────────────────────────── */
.tag-picker-backdrop {
position: fixed;
inset: 0;
z-index: 200;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.tag-picker-modal {
background: var(--paper);
border-radius: var(--r3);
box-shadow: 0 16px 56px rgba(0, 0, 0, 0.24);
width: 300px;
max-width: 100%;
display: flex;
flex-direction: column;
max-height: 60vh;
}
.tag-picker-header {
display: flex;
align-items: center;
padding: 12px 14px;
border-bottom: 1px solid var(--line);
flex-shrink: 0;
gap: 8px;
}
.tag-picker-title {
flex: 1;
font-size: 13px;
font-weight: 600;
color: var(--ink);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tag-picker-close {
width: 26px;
height: 26px;
border-radius: 50%;
border: none;
background: transparent;
color: var(--muted-2);
font-size: 18px;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.tag-picker-close:hover { background: var(--paper-2); color: var(--ink); }
.tag-picker-body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 8px 12px 12px;
}
.tag-picker-root-btn {
display: block;
width: 100%;
text-align: left;
padding: 5px 8px;
margin-bottom: 6px;
border: 1px dashed var(--line);
border-radius: var(--r);
background: transparent;
color: var(--muted);
font-size: 12px;
cursor: pointer;
}
.tag-picker-root-btn:hover { background: var(--paper-2); color: var(--ink); border-color: var(--muted-2); }
.tag-picker-tree { border-top: 1px solid var(--line-soft); padding-top: 6px; margin-top: 2px; }
.tag-picker-node-btn {
border: 0;
background: transparent;
color: var(--link);
cursor: pointer;
padding: 3px 6px;
text-align: left;
font-size: 13px;
display: block;
width: 100%;
border-radius: var(--r);
}
.tag-picker-node-btn:hover { background: var(--paper-2); text-decoration: none; }
.tag-picker-empty { font-size: 13px; color: var(--muted-2); margin: 6px 0 0; }
/* ── Collections page (sidebar layout) ──────────────────────────────────── */
.collections-view {
padding: 24px;