ZDDC/shared/icons.js
ZDDC e2179d167b feat(browse): capability/role/tier-driven, context-correct menu system
Reworks the browse menu/tree interaction into a declarative, contextually
honest model and moves view settings onto a toolbar — the menu is the UI to
the system, so it should be familiar, inviting, and only ever offer what
applies.

New declarative menu model (browse/js/menu-model.js):
- Every action is one descriptor with a TYPE predicate (appliesTo) and a
  CAPABILITY predicate (enabled)+tooltip. Row/pane menus are projections over
  it; separators are derived from group changes. Designed data-shaped so a
  future server-sourced manifest (zddc.zip) can supply/extend it.
- Hybrid visibility: type-inapplicable actions are OMITTED (New folder on a
  file, Expand on a file); permission/role/tier-gated actions are SHOWN
  DISABLED with a reason — so a lower tier sees what a higher role unlocks.
- Roles are NOT hardcoded: ordinary actions gate on the verbs the server
  returns (node.verbs / path_verbs), so any operator-defined role works. Only
  the two intrinsically-special tiers are recognised by name — site admin
  (is_super_admin) and project/subtree admin (path_is_admin), surfaced as the
  "Edit access rules…" item; both come from the existing /.profile/access.
- The headline fix: New folder / New markdown file no longer appear on file
  rows (they target a folder or the current dir).

events.js: deletes the ~350-line inline buildTreeRowMenu/buildPaneMenu/
SORT_BY_ITEMS; opens menus via menuModel projections through one openRowMenuFor
/openPaneMenu path shared by right-click, the hover kebab, and the keyboard
menu key (ContextMenu / Shift+F10). Injects action impls via menuModel.configure
to avoid a circular dep. Prefetches the scope /.profile/access (memoised) on
load/rescope/refresh/popstate so menus never fetch at open time.

Discoverability + a11y: a per-row ⋯ kebab (tree.js + new icon-ellipsis sprite,
revealed on hover/selection/focus) opens the same menu; keyboard menu key
supported.

Toolbar: Sort + Show-hidden moved OUT of per-row right-click menus into the
tree-pane toolbar, plus New folder / New file buttons (act on the current dir,
greyed with a reason when create access is lacking). Help copy updated.

Icons: dropped the 3 stray emoji from menu items (consistent, VS Code/Finder
style); only new sprite is the kebab's icon-ellipsis.

Tests: +5 browse specs (file row omits New-folder; folder row shows it; a
read-only server node greys Rename with a "write access" tooltip via a pure
menuModel unit; toolbar Sort/Show-hidden drive state + New buttons present;
kebab and Shift+F10 both open the menu). All 23 browse+conflict+diff green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 07:21:02 -05:00

168 lines
9.1 KiB
JavaScript
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.

// shared/icons.js — minimal outline SVG sprite for ZDDC tools.
//
// Vendored from Lucide (https://lucide.dev, ISC). Only the 16
// file-type glyphs the browse tree maps to are bundled; total weight
// is ~4.5 KB of SVG path data. Each symbol viewBox is 0 0 24 24 with
// no stroke/fill attributes — those are applied at the call site via
// CSS so the icons inherit `currentColor` and tint with the theme.
//
// API:
// window.zddc.icons.inject() // mount sprite into <body> once
// window.zddc.icons.html('icon-foo') // → '<svg viewBox="0 0 24 24"><use href="#icon-foo"/></svg>'
// window.zddc.icons.ID // string set of valid symbol ids
//
// Callers concat html() output into innerHTML the same way they
// previously concat'd emoji glyphs. The injected sprite is hidden
// (`display:none` on the outer <svg>) so it costs zero layout.
//
// Why a sprite (rather than per-row inline paths): a hundred tree
// rows × 300 bytes of duplicated path data is 30 KB of churn on
// every re-render. With <use>, each row carries only a ~60-byte
// reference. The sprite is parsed once.
(function () {
'use strict';
if (!window.zddc) window.zddc = {};
if (window.zddc.icons) return;
// ── Sprite (Lucide outline glyphs, viewBox 24×24) ──────────────────────
// Concatenated from upstream lucide-static@1.16.0 SVGs; class/style
// attributes stripped. Order matches the icons-mapped block below
// so a diff against Lucide's source stays readable.
var SYMBOLS = ''
+ '<symbol id="icon-folder" viewBox="0 0 24 24">'
+ '<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/>'
+ '</symbol>'
+ '<symbol id="icon-folder-archive" viewBox="0 0 24 24">'
+ '<circle cx="15" cy="19" r="2"/>'
+ '<path d="M20.9 19.8A2 2 0 0 0 22 18V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2h5.1"/>'
+ '<path d="M15 11v-1"/>'
+ '<path d="M15 17v-2"/>'
+ '</symbol>'
+ '<symbol id="icon-file" viewBox="0 0 24 24">'
+ '<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/>'
+ '<path d="M14 2v5a1 1 0 0 0 1 1h5"/>'
+ '</symbol>'
+ '<symbol id="icon-file-text" viewBox="0 0 24 24">'
+ '<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/>'
+ '<path d="M14 2v5a1 1 0 0 0 1 1h5"/>'
+ '<path d="M10 9H8"/><path d="M16 13H8"/><path d="M16 17H8"/>'
+ '</symbol>'
+ '<symbol id="icon-file-image" viewBox="0 0 24 24">'
+ '<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/>'
+ '<path d="M14 2v5a1 1 0 0 0 1 1h5"/>'
+ '<circle cx="10" cy="12" r="2"/>'
+ '<path d="m20 17-1.296-1.296a2.41 2.41 0 0 0-3.408 0L9 22"/>'
+ '</symbol>'
+ '<symbol id="icon-file-video" viewBox="0 0 24 24">'
+ '<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/>'
+ '<path d="M14 2v5a1 1 0 0 0 1 1h5"/>'
+ '<path d="M15.033 13.44a.647.647 0 0 1 0 1.12l-4.065 2.352a.645.645 0 0 1-.968-.56v-4.704a.645.645 0 0 1 .967-.56z"/>'
+ '</symbol>'
+ '<symbol id="icon-file-audio" viewBox="0 0 24 24">'
+ '<path d="M4 6.835V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.706.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2h-.343"/>'
+ '<path d="M14 2v5a1 1 0 0 0 1 1h5"/>'
+ '<path d="M2 19a2 2 0 0 1 4 0v1a2 2 0 0 1-4 0v-4a6 6 0 0 1 12 0v4a2 2 0 0 1-4 0v-1a2 2 0 0 1 4 0"/>'
+ '</symbol>'
+ '<symbol id="icon-file-archive" viewBox="0 0 24 24">'
+ '<path d="M13.659 22H18a2 2 0 0 0 2-2V8a2.4 2.4 0 0 0-.706-1.706l-3.588-3.588A2.4 2.4 0 0 0 14 2H6a2 2 0 0 0-2 2v11.5"/>'
+ '<path d="M14 2v5a1 1 0 0 0 1 1h5"/>'
+ '<path d="M8 12v-1"/><path d="M8 18v-2"/><path d="M8 7V6"/>'
+ '<circle cx="8" cy="20" r="2"/>'
+ '</symbol>'
+ '<symbol id="icon-file-spreadsheet" viewBox="0 0 24 24">'
+ '<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/>'
+ '<path d="M14 2v5a1 1 0 0 0 1 1h5"/>'
+ '<path d="M8 13h2"/><path d="M14 13h2"/><path d="M8 17h2"/><path d="M14 17h2"/>'
+ '</symbol>'
+ '<symbol id="icon-file-code" viewBox="0 0 24 24">'
+ '<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/>'
+ '<path d="M14 2v5a1 1 0 0 0 1 1h5"/>'
+ '<path d="M10 12.5 8 15l2 2.5"/>'
+ '<path d="m14 12.5 2 2.5-2 2.5"/>'
+ '</symbol>'
+ '<symbol id="icon-file-cog" viewBox="0 0 24 24">'
+ '<path d="M15 8a1 1 0 0 1-1-1V2a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8z"/>'
+ '<path d="M20 8v12a2 2 0 0 1-2 2h-4.182"/>'
+ '<path d="m3.305 19.53.923-.382"/>'
+ '<path d="M4 10.592V4a2 2 0 0 1 2-2h8"/>'
+ '<path d="m4.228 16.852-.924-.383"/>'
+ '<path d="m5.852 15.228-.383-.923"/>'
+ '<path d="m5.852 20.772-.383.924"/>'
+ '<path d="m8.148 15.228.383-.923"/>'
+ '<path d="m8.53 21.696-.382-.924"/>'
+ '<path d="m9.773 16.852.922-.383"/>'
+ '<path d="m9.773 19.148.922.383"/>'
+ '<circle cx="7" cy="18" r="3"/>'
+ '</symbol>'
+ '<symbol id="icon-file-pen" viewBox="0 0 24 24">'
+ '<path d="M12.659 22H18a2 2 0 0 0 2-2V8a2.4 2.4 0 0 0-.706-1.706l-3.588-3.588A2.4 2.4 0 0 0 14 2H6a2 2 0 0 0-2 2v9.34"/>'
+ '<path d="M14 2v5a1 1 0 0 0 1 1h5"/>'
+ '<path d="M10.378 12.622a1 1 0 0 1 3 3.003L8.36 20.637a2 2 0 0 1-.854.506l-2.867.837a.5.5 0 0 1-.62-.62l.836-2.869a2 2 0 0 1 .506-.853z"/>'
+ '</symbol>'
+ '<symbol id="icon-book-marked" viewBox="0 0 24 24">'
+ '<path d="M10 2v8l3-3 3 3V2"/>'
+ '<path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H19a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H6.5a1 1 0 0 1 0-5H20"/>'
+ '</symbol>'
+ '<symbol id="icon-presentation" viewBox="0 0 24 24">'
+ '<path d="M2 3h20"/>'
+ '<path d="M21 3v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V3"/>'
+ '<path d="m7 21 5-5 5 5"/>'
+ '</symbol>'
+ '<symbol id="icon-ruler" viewBox="0 0 24 24">'
+ '<path d="M21.3 15.3a2.4 2.4 0 0 1 0 3.4l-2.6 2.6a2.4 2.4 0 0 1-3.4 0L2.7 8.7a2.41 2.41 0 0 1 0-3.4l2.6-2.6a2.41 2.41 0 0 1 3.4 0Z"/>'
+ '<path d="m14.5 12.5 2-2"/>'
+ '<path d="m11.5 9.5 2-2"/>'
+ '<path d="m8.5 6.5 2-2"/>'
+ '<path d="m17.5 15.5 2-2"/>'
+ '</symbol>'
+ '<symbol id="icon-globe" viewBox="0 0 24 24">'
+ '<circle cx="12" cy="12" r="10"/>'
+ '<path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/>'
+ '<path d="M2 12h20"/>'
+ '</symbol>'
// Lightweight outline chevron — used by the tree as the
// expand/collapse affordance. The single glyph rotates 90°
// via CSS to indicate the expanded state, so we only ship
// one path instead of two.
+ '<symbol id="icon-chevron-right" viewBox="0 0 24 24">'
+ '<path d="m9 18 6-6-6-6"/>'
+ '</symbol>'
// Horizontal three-dot "kebab" — the per-row actions affordance.
+ '<symbol id="icon-ellipsis" viewBox="0 0 24 24">'
+ '<circle cx="12" cy="12" r="1"/>'
+ '<circle cx="19" cy="12" r="1"/>'
+ '<circle cx="5" cy="12" r="1"/>'
+ '</symbol>';
var injected = false;
function inject() {
if (injected) return;
// insertAdjacentHTML on body parses the SVG namespace correctly
// across all modern browsers (innerHTML on a <div> wrapper has
// historically tripped over <symbol> in some engines).
var sprite = '<svg xmlns="http://www.w3.org/2000/svg" '
+ 'aria-hidden="true" style="position:absolute;width:0;height:0;'
+ 'overflow:hidden" focusable="false">'
+ SYMBOLS
+ '</svg>';
if (document.body) {
document.body.insertAdjacentHTML('afterbegin', sprite);
injected = true;
} else {
document.addEventListener('DOMContentLoaded', inject, { once: true });
}
}
// Produces the per-row markup callers concat into innerHTML.
// Bundles the size + stroke defaults inline so the SVG renders
// correctly even before the page CSS runs (e.g. mid-paint).
function html(symbolId) {
return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" '
+ 'stroke-width="2" stroke-linecap="round" stroke-linejoin="round" '
+ 'aria-hidden="true"><use href="#' + symbolId + '"/></svg>';
}
window.zddc.icons = { inject: inject, html: html };
})();