1
Fork 0
mirror of https://github.com/thegeneralist01/archivr synced 2026-07-21 18:55:36 +02:00

feat(tags): revamp tags tab (#29)

feat(tags): revamp tags tab — tooltips, entry counts, Create/Move flows, Esc handling (#29)
This commit is contained in:
TheGeneralist 2026-07-19 11:02:57 +02:00 committed by GitHub
parent a4de506495
commit 289037235c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 2942 additions and 847 deletions

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,50 +215,66 @@ 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}
<svg
className="edit-icon"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
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>
<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"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
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>
)}
<button
className="remove tag-node-delete"
title={`Delete tag ${node.tag.full_path}`}
onClick={handleDelete}
aria-label={`Delete tag ${node.tag.full_path}`}
>×</button>
{!editing && !moveSelectMode && (
<button
className="remove tag-node-delete"
title={`Delete "${node.tag.full_path}"`}
onClick={handleDelete}
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">
<span className="tag-tree-title">Tags</span>
{tagFilter && (
<span className="tag-tree-active">Filtering: {tagFilter}</span>
{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" 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;