chore(embedded): cut v0.0.27-beta
Some checks failed
Notify chart dev on beta cut / notify-chart-dev (push) Failing after 4s

This commit is contained in:
ZDDC 2026-06-13 12:17:16 -05:00
parent 51f5947716
commit 605f4ab3e0
7 changed files with 477 additions and 90 deletions

View file

@ -2717,7 +2717,7 @@ td[data-field="trackingNumber"] {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC Archive</span> <span class="app-header__title">ZDDC Archive</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-12 16:07:31 · f66b9c5</span></span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-13 17:17:09 · 51f5947</span></span>
</div> </div>
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button> <button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data"></button> <button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data"></button>
@ -11523,6 +11523,48 @@ window.app.modules.filtering = {
a: 'edit access rules' a: 'edit access rules'
}; };
// ── "who can?" — tell a denied user who to ask, subtly ──────────────────
// The PATTERN: gate an affordance on cap.has(node, verb) / a path view's
// path_verbs; when the verb is ABSENT, don't just disable — render
// cap.denyHint(view, verb) as the disabled control's tooltip/subtitle (or
// a small inline note) so the user learns who can do it instead of acting
// and bouncing off a 403. handleForbidden() does the same on a denial that
// slips past a pre-check.
function humanizeRole(name) { return String(name || '').replace(/[_-]+/g, ' ').trim(); }
// whoCan(view, verb) → the Authority {roles, people} the server computed for
// a verb the caller lacks at view's path, or null. `view` is a /.profile/
// access?path= result (from cap.at) OR a 403 body carrying who_can.
function whoCan(view, verb) {
if (!view) return null;
var map = view.path_who_can;
if (map && map[verb]) return map[verb];
if (view.who_can && view.missing_verb === verb) return view.who_can;
return null;
}
// denyHint(view, verb) → { text, title } for a subtle "who can" line.
// Role-first: "Only the document controller can create here", with the
// specific people (admins / role members) as the tooltip detail. Falls back
// to naming people, then to "Ask an administrator". Returns null when the
// verb is actually granted (nothing to hint) and a generic hint when no
// authority is known.
function denyHint(view, verb) {
var a = whoCan(view, verb);
var doing = VERB_LABELS[verb] || verb || 'do that';
if (!a || (!(a.roles && a.roles.length) && !(a.people && a.people.length))) {
return { text: 'Ask an administrator to ' + doing + ' here.', title: '' };
}
var people = (a.people || []).slice();
var detail = people.length ? people.join(', ') : '';
if (a.roles && a.roles.length) {
return { text: 'Only the ' + humanizeRole(a.roles[0]) + ' can ' + doing + ' here.', title: detail };
}
var shown = people.slice(0, 2).join(', ') + (people.length > 2 ? ', …' : '');
return { text: 'Ask ' + shown + ' to ' + doing + ' here.', title: detail };
}
// handleForbidden(resp, opts) — render a 403 toast naming the // handleForbidden(resp, opts) — render a 403 toast naming the
// missing verb. opts.path (optional) is the URL the failed request // missing verb. opts.path (optional) is the URL the failed request
// hit; when provided, the helper consults /.profile/access?path= to // hit; when provided, the helper consults /.profile/access?path= to
@ -11536,8 +11578,9 @@ window.app.modules.filtering = {
async function handleForbidden(resp, opts) { async function handleForbidden(resp, opts) {
opts = opts || {}; opts = opts || {};
var missing = ''; var missing = '';
var body = null;
try { try {
var body = await resp.clone().json(); body = await resp.clone().json();
if (body && typeof body.missing_verb === 'string') { if (body && typeof body.missing_verb === 'string') {
missing = body.missing_verb; missing = body.missing_verb;
} }
@ -11552,6 +11595,16 @@ window.app.modules.filtering = {
msg = prefix + 'Forbidden.'; msg = prefix + 'Forbidden.';
} }
// Append the subtle "who can" hint: prefer who_can from the 403 body,
// else fall back to the path-scoped access view. So even a denial the
// UI didn't pre-check still tells the user who to ask.
if (missing) {
var src = (body && body.who_can) ? { who_can: body.who_can, missing_verb: missing } : null;
if (!src && opts.path) src = await at(opts.path);
var hint = src ? denyHint(src, missing) : null;
if (hint && hint.text) msg += ' ' + hint.text;
}
// Optional elevate offer: only when the caller supplied a // Optional elevate offer: only when the caller supplied a
// path AND the path-scoped access view reports an elevation // path AND the path-scoped access view reports an elevation
// grant covering the missing verb. Render as a clickable // grant covering the missing verb. Render as a clickable
@ -11584,7 +11637,7 @@ window.app.modules.filtering = {
} }
} }
window.zddc.cap = { at: at, has: has, handleForbidden: handleForbidden }; window.zddc.cap = { at: at, has: has, handleForbidden: handleForbidden, whoCan: whoCan, denyHint: denyHint };
})(); })();
</script> </script>

View file

@ -2824,7 +2824,7 @@ li.CodeMirror-hint-active {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC Browse</span> <span class="app-header__title">ZDDC Browse</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-12 16:07:31 · f66b9c5</span></span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-13 17:17:09 · 51f5947</span></span>
</div> </div>
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button> <button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh listing" aria-label="Refresh listing"></button> <button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh listing" aria-label="Refresh listing"></button>
@ -7022,6 +7022,48 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
a: 'edit access rules' a: 'edit access rules'
}; };
// ── "who can?" — tell a denied user who to ask, subtly ──────────────────
// The PATTERN: gate an affordance on cap.has(node, verb) / a path view's
// path_verbs; when the verb is ABSENT, don't just disable — render
// cap.denyHint(view, verb) as the disabled control's tooltip/subtitle (or
// a small inline note) so the user learns who can do it instead of acting
// and bouncing off a 403. handleForbidden() does the same on a denial that
// slips past a pre-check.
function humanizeRole(name) { return String(name || '').replace(/[_-]+/g, ' ').trim(); }
// whoCan(view, verb) → the Authority {roles, people} the server computed for
// a verb the caller lacks at view's path, or null. `view` is a /.profile/
// access?path= result (from cap.at) OR a 403 body carrying who_can.
function whoCan(view, verb) {
if (!view) return null;
var map = view.path_who_can;
if (map && map[verb]) return map[verb];
if (view.who_can && view.missing_verb === verb) return view.who_can;
return null;
}
// denyHint(view, verb) → { text, title } for a subtle "who can" line.
// Role-first: "Only the document controller can create here", with the
// specific people (admins / role members) as the tooltip detail. Falls back
// to naming people, then to "Ask an administrator". Returns null when the
// verb is actually granted (nothing to hint) and a generic hint when no
// authority is known.
function denyHint(view, verb) {
var a = whoCan(view, verb);
var doing = VERB_LABELS[verb] || verb || 'do that';
if (!a || (!(a.roles && a.roles.length) && !(a.people && a.people.length))) {
return { text: 'Ask an administrator to ' + doing + ' here.', title: '' };
}
var people = (a.people || []).slice();
var detail = people.length ? people.join(', ') : '';
if (a.roles && a.roles.length) {
return { text: 'Only the ' + humanizeRole(a.roles[0]) + ' can ' + doing + ' here.', title: detail };
}
var shown = people.slice(0, 2).join(', ') + (people.length > 2 ? ', …' : '');
return { text: 'Ask ' + shown + ' to ' + doing + ' here.', title: detail };
}
// handleForbidden(resp, opts) — render a 403 toast naming the // handleForbidden(resp, opts) — render a 403 toast naming the
// missing verb. opts.path (optional) is the URL the failed request // missing verb. opts.path (optional) is the URL the failed request
// hit; when provided, the helper consults /.profile/access?path= to // hit; when provided, the helper consults /.profile/access?path= to
@ -7035,8 +7077,9 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
async function handleForbidden(resp, opts) { async function handleForbidden(resp, opts) {
opts = opts || {}; opts = opts || {};
var missing = ''; var missing = '';
var body = null;
try { try {
var body = await resp.clone().json(); body = await resp.clone().json();
if (body && typeof body.missing_verb === 'string') { if (body && typeof body.missing_verb === 'string') {
missing = body.missing_verb; missing = body.missing_verb;
} }
@ -7051,6 +7094,16 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
msg = prefix + 'Forbidden.'; msg = prefix + 'Forbidden.';
} }
// Append the subtle "who can" hint: prefer who_can from the 403 body,
// else fall back to the path-scoped access view. So even a denial the
// UI didn't pre-check still tells the user who to ask.
if (missing) {
var src = (body && body.who_can) ? { who_can: body.who_can, missing_verb: missing } : null;
if (!src && opts.path) src = await at(opts.path);
var hint = src ? denyHint(src, missing) : null;
if (hint && hint.text) msg += ' ' + hint.text;
}
// Optional elevate offer: only when the caller supplied a // Optional elevate offer: only when the caller supplied a
// path AND the path-scoped access view reports an elevation // path AND the path-scoped access view reports an elevation
// grant covering the missing verb. Render as a clickable // grant covering the missing verb. Render as a clickable
@ -7083,7 +7136,7 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
} }
} }
window.zddc.cap = { at: at, has: has, handleForbidden: handleForbidden }; window.zddc.cap = { at: at, has: has, handleForbidden: handleForbidden, whoCan: whoCan, denyHint: denyHint };
})(); })();
// shared/icons.js — minimal outline SVG sprite for ZDDC tools. // shared/icons.js — minimal outline SVG sprite for ZDDC tools.
@ -16209,6 +16262,13 @@ window.__ZDDC_SCHEMA__ = {
function openPartyPicker(opts) { function openPartyPicker(opts) {
return new Promise(function (resolve) { return new Promise(function (resolve) {
var kindWord = opts.kind === 'folder' ? 'folder' : 'file'; var kindWord = opts.kind === 'folder' ? 'folder' : 'file';
// The "+ New party" affordance is gated on create authority over ssr/
// (pre-checked in createInAggregator). When denied, disable it and say
// who can — role-first text inline, the specific people in the tooltip.
var newPartyAllowed = opts.canNewParty !== false;
var newPartyNote = newPartyAllowed ? '(registers a new party)'
: (opts.newPartyHint && opts.newPartyHint.text) || 'You cant register a new party here.';
var newPartyTitle = (!newPartyAllowed && opts.newPartyHint && opts.newPartyHint.title) || '';
var overlay = document.createElement('div'); var overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.4);display:flex;align-items:center;justify-content:center;z-index:9000;'; overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.4);display:flex;align-items:center;justify-content:center;z-index:9000;';
var box = document.createElement('div'); var box = document.createElement('div');
@ -16227,10 +16287,10 @@ window.__ZDDC_SCHEMA__ = {
'Pick the party this ' + kindWord + ' belongs to — it lands under <code>' + escapeHtml(opts.slot) + '/&lt;party&gt;/</code>.' + 'Pick the party this ' + kindWord + ' belongs to — it lands under <code>' + escapeHtml(opts.slot) + '/&lt;party&gt;/</code>.' +
'</p>' + '</p>' +
'<div style="max-height:14rem;overflow-y:auto;border:1px solid rgba(0,0,0,0.1);padding:0.3rem 0.6rem;margin-bottom:0.5rem;">' + '<div style="max-height:14rem;overflow-y:auto;border:1px solid rgba(0,0,0,0.1);padding:0.3rem 0.6rem;margin-bottom:0.5rem;">' +
(partyList || '<em style="color:#888;">No parties yet — create one below.</em>') + (partyList || '<em style="color:#888;">No parties yet.</em>') +
'<label style="display:flex;align-items:center;gap:0.5rem;padding:0.25rem 0;cursor:pointer;border-top:1px solid rgba(0,0,0,0.05);margin-top:0.3rem;padding-top:0.5rem;">' + '<label title="' + escapeHtml(newPartyTitle) + '" style="display:flex;align-items:center;gap:0.5rem;padding:0.25rem 0;border-top:1px solid rgba(0,0,0,0.05);margin-top:0.3rem;padding-top:0.5rem;' + (newPartyAllowed ? 'cursor:pointer;' : 'opacity:0.6;') + '">' +
'<input type="radio" name="pp-party" value="__new__">' + '<input type="radio" name="pp-party" value="__new__"' + (newPartyAllowed ? '' : ' disabled') + '>' +
'<span><strong>+ New party…</strong> <span style="color:#888;font-size:0.8rem;">(document controller only)</span></span></label>' + '<span><strong>+ New party…</strong> <span style="color:#888;font-size:0.8rem;">' + escapeHtml(newPartyNote) + '</span></span></label>' +
'</div>' + '</div>' +
'<div id="pp-newparty-row" style="display:none;margin-bottom:0.5rem;font-size:0.9rem;">' + '<div id="pp-newparty-row" style="display:none;margin-bottom:0.5rem;font-size:0.9rem;">' +
'<label for="pp-newparty">New party name</label><br>' + '<label for="pp-newparty">New party name</label><br>' +
@ -16287,8 +16347,28 @@ window.__ZDDC_SCHEMA__ = {
async function createInAggregator(agg, kind) { async function createInAggregator(agg, kind) {
var up = window.app.modules.upload; var up = window.app.modules.upload;
if (!up) return; if (!up) return;
var cap = window.zddc && window.zddc.cap;
var ssrPath = '/' + agg.project + '/ssr/';
var slotPath = '/' + agg.project + '/' + agg.slot + '/';
// Pre-check create authority BEFORE any data entry: registering a new
// party needs `c` on ssr/, creating under an existing party needs `c` on
// the slot. If the user can do neither, don't open the form just to deny
// them — say (subtly) who can. If they can only use existing parties,
// open the picker with the "+ New party" option disabled + explained.
var canNewParty = true, ssrView = null, slotView = null;
if (cap && cap.at) {
slotView = await cap.at(slotPath);
ssrView = await cap.at(ssrPath);
var canSlot = !slotView || cap.has({ verbs: slotView.path_verbs }, 'c');
canNewParty = !ssrView || cap.has({ verbs: ssrView.path_verbs }, 'c');
if (slotView && !canSlot && !canNewParty) {
statusError(cap.denyHint(slotView, 'c').text);
return;
}
}
var parties = await fetchParties(agg.project); var parties = await fetchParties(agg.project);
var choice = await openPartyPicker({ project: agg.project, slot: agg.slot, kind: kind, parties: parties }); var newPartyHint = (!canNewParty && ssrView && cap.denyHint) ? cap.denyHint(ssrView, 'c') : null;
var choice = await openPartyPicker({ project: agg.project, slot: agg.slot, kind: kind, parties: parties, canNewParty: canNewParty, newPartyHint: newPartyHint });
if (!choice) return; if (!choice) return;
// Party names are validated to a URL-safe charset, so no encoding // Party names are validated to a URL-safe charset, so no encoding
// needed for the party segment; makeDir/makeFile encode the leaf. // needed for the party segment; makeDir/makeFile encode the leaf.
@ -16311,7 +16391,10 @@ window.__ZDDC_SCHEMA__ = {
} catch (e) { } catch (e) {
var msg = (e && e.message) || String(e); var msg = (e && e.message) || String(e);
if (/\b403\b/.test(msg)) { if (/\b403\b/.test(msg)) {
statusError('Not allowed — registering a new party requires the document-controller role.'); // Name who can — best-effort, for the path the denial came from.
var denied = choice.isNew ? ssrPath : ('/' + agg.project + '/' + agg.slot + '/' + choice.party + '/');
var v = (cap && cap.at) ? await cap.at(denied) : null;
statusError(v && cap.denyHint ? cap.denyHint(v, 'c').text : 'Not allowed — you dont have create access here.');
} else if (/\b409\b/.test(msg)) { } else if (/\b409\b/.test(msg)) {
statusError('Unknown party — register it first (document controller).'); statusError('Unknown party — register it first (document controller).');
} else { } else {

View file

@ -1168,6 +1168,54 @@ body.is-elevated::after {
/* Toast notifications come from shared/toast.css (.zddc-toast); the /* Toast notifications come from shared/toast.css (.zddc-toast); the
classifier-local .toast block was promoted there. */ classifier-local .toast block was promoted there. */
/* ── Shared selectable + autofilter table (seltable) + its hosting overlay ───
Used by the tables tool's "Add from archive". The classifier carries an
equivalent copy inline in its layout.css for the catalog. */
.seltable { display: flex; flex-direction: column; min-height: 0; height: 100%; }
.seltable__bar { display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 0.5rem; border-bottom: 1px solid var(--border); flex: 0 0 auto; }
.seltable__count { color: var(--text-muted); font-size: 0.78rem; white-space: nowrap; }
.seltable__scroll { flex: 1; min-height: 0; overflow: auto; }
/* width:auto + nowrap cells → each column shrinks to fit its header/longest cell. */
.seltable__table { border-collapse: separate; border-spacing: 0; width: auto; font-size: 0.82rem; }
.seltable__table th, .seltable__table td { border-bottom: 1px solid var(--border); padding: 0.25rem 0.5rem; text-align: left; white-space: nowrap; }
.seltable__table thead th {
position: sticky; top: 0; z-index: 2; background: var(--bg-secondary, var(--bg));
color: var(--text-muted); font-size: 0.68rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase;
}
.seltable__table thead tr.seltable__filters th { top: 1.55rem; padding: 0.15rem 0.35rem; }
.seltable__colfilter {
width: 100%; min-width: 2rem; box-sizing: border-box; padding: 0.15rem 0.35rem;
border: 1px solid var(--border); border-radius: var(--radius);
background: var(--bg); color: var(--text); font-size: 0.74rem; font-weight: 400; text-transform: none; letter-spacing: 0;
}
.seltable__row { cursor: pointer; user-select: none; }
.seltable__row:hover { background: var(--bg-hover); }
.seltable__row.is-selected { background: var(--primary-light, rgba(37,99,235,0.12)); }
.seltable__row.is-selected:hover { background: var(--primary-light, rgba(37,99,235,0.18)); }
.seltable__row.drop-hover { outline: 2px solid var(--primary); outline-offset: -2px; }
/* ── "Add deliverables from archive" overlay (project MDL rollup) ─────────── */
.mdlarch-overlay {
position: fixed; inset: 0; z-index: 1000;
background: rgba(0, 0, 0, 0.45);
display: flex; align-items: center; justify-content: center; padding: 1.5rem;
}
.mdlarch-overlay__box {
display: flex; flex-direction: column; min-height: 0;
width: min(960px, 95vw); height: min(80vh, 760px);
background: var(--bg); color: var(--text);
border: 1px solid var(--border); border-radius: var(--radius);
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
}
.mdlarch-overlay__head { display: flex; align-items: center; gap: 0.75rem; padding: 0.85rem 1.1rem; border-bottom: 1px solid var(--border); flex: 0 0 auto; }
.mdlarch-overlay__head h2 { margin: 0; font-size: 1.05rem; flex: 1; }
.mdlarch-overlay__close { border: none; background: none; color: var(--text-muted); font-size: 1.4rem; line-height: 1; cursor: pointer; padding: 0 0.25rem; }
.mdlarch-overlay__close:hover { color: var(--text); }
.mdlarch-overlay__status { padding: 0.5rem 1.1rem; color: var(--text-muted); font-size: 0.82rem; border-bottom: 1px solid var(--border); flex: 0 0 auto; }
.mdlarch-overlay__table { flex: 1; min-height: 0; display: flex; }
.mdlarch-overlay__table .seltable { height: 100%; flex: 1; }
.mdlarch-overlay__foot { display: flex; justify-content: flex-end; gap: 0.6rem; padding: 0.75rem 1.1rem; border-top: 1px solid var(--border); flex: 0 0 auto; }
/* Classifier layout — tokens from shared/base.css */ /* Classifier layout — tokens from shared/base.css */
/* .empty-state / .empty-state__inner / .welcome-list live in /* .empty-state / .empty-state__inner / .welcome-list live in
@ -1798,47 +1846,10 @@ input.tfile__name:focus { border-color: var(--primary); background: var(--bg); o
.scratch-match__tn { font-family: var(--mono, monospace); } .scratch-match__tn { font-family: var(--mono, monospace); }
.scratch-match__conf { color: var(--text-muted); font-size: 0.72rem; width: 3rem; text-align: right; } .scratch-match__conf { color: var(--text-muted); font-size: 0.72rem; width: 3rem; text-align: right; }
/* ── MDL-from-archive overlay ───────────────────────────────────────────── */ /* The base seltable rules live in shared/seltable.css (bundled by build.sh and
.mdl-overlay { position: fixed; inset: 0; z-index: 1100; background: rgba(0,0,0,0.45); display: flex; align-items: center; justify-content: center; padding: 2rem 1rem; } shared with the tables tool); only the classifier-specific catalog bits
.mdl-overlay__box { background: var(--bg); color: var(--text); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: 0 10px 40px rgba(0,0,0,0.3); width: 100%; max-width: 1000px; height: 80vh; display: flex; flex-direction: column; } (.seltable__extra, .mdl-rev__input, .fromlist-*, .src-badge, #mdlTree) are
.mdl-overlay__head { display: flex; align-items: center; justify-content: space-between; padding: 0.75rem 1rem; border-bottom: 1px solid var(--border); } here. */
.mdl-overlay__head h2 { margin: 0; font-size: 1.1rem; }
.mdl-overlay__close { background: none; border: none; font-size: 1.6rem; line-height: 1; color: var(--text-muted); cursor: pointer; padding: 0 0.4rem; }
.mdl-overlay__close:hover { color: var(--text); }
.mdl-overlay__status { padding: 0.4rem 1rem; color: var(--text-muted); font-size: 0.82rem; border-bottom: 1px solid var(--border); }
.mdl-overlay__table { flex: 1; min-height: 0; }
.mdl-overlay__foot { display: flex; justify-content: flex-end; gap: 0.5rem; padding: 0.75rem 1rem; border-top: 1px solid var(--border); }
/* ── Shared selectable + autofilter table (seltable) ────────────────────── */
.seltable { display: flex; flex-direction: column; min-height: 0; height: 100%; }
.seltable__bar { display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 0.5rem; border-bottom: 1px solid var(--border); flex: 0 0 auto; }
.seltable__filter {
flex: 1; min-width: 8rem; padding: 0.3rem 0.5rem;
border: 1px solid var(--border); border-radius: var(--radius);
background: var(--bg-secondary, var(--bg)); color: var(--text); font-size: 0.85rem;
}
.seltable__count { color: var(--text-muted); font-size: 0.78rem; white-space: nowrap; }
.seltable__scroll { flex: 1; min-height: 0; overflow: auto; }
/* width:auto + nowrap cells → each column shrinks to fit its header/longest cell. */
.seltable__table { border-collapse: separate; border-spacing: 0; width: auto; font-size: 0.82rem; }
.seltable__table th, .seltable__table td { border-bottom: 1px solid var(--border); padding: 0.25rem 0.5rem; text-align: left; white-space: nowrap; }
.seltable__table thead th {
position: sticky; top: 0; z-index: 2; background: var(--bg-secondary, var(--bg));
color: var(--text-muted); font-size: 0.68rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase;
}
/* Per-column filter inputs: fill the column (min-width:0-ish) so they never
force a column wider than its header/cells. */
.seltable__table thead tr.seltable__filters th { padding: 0.08rem 0.3rem; }
.seltable__colfilter {
width: 100%; min-width: 2rem; box-sizing: border-box;
padding: 0.1rem 0.3rem; border: 1px solid var(--border); border-radius: var(--radius);
background: var(--bg); color: var(--text); font-size: 0.72rem; font-weight: 400; letter-spacing: 0; text-transform: none;
}
.seltable__row { cursor: pointer; user-select: none; }
.seltable__row:hover { background: var(--bg-hover); }
.seltable__row.is-selected { background: var(--primary-light, rgba(37,99,235,0.12)); }
.seltable__row.is-selected:hover { background: var(--primary-light, rgba(37,99,235,0.18)); }
.seltable__row.drop-hover { outline: 2px solid var(--primary); outline-offset: -2px; }
/* ── Copy destination dialog ────────────────────────────────────────────── */ /* ── Copy destination dialog ────────────────────────────────────────────── */
.copy-choice__backdrop { .copy-choice__backdrop {
@ -2407,7 +2418,7 @@ input.tfile__name:focus { border-color: var(--primary); background: var(--bg); o
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC Classifier</span> <span class="app-header__title">ZDDC Classifier</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-12 16:07:31 · f66b9c5</span></span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-13 17:17:09 · 51f5947</span></span>
</div> </div>
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button> <button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory" aria-label="Refresh" style="font-size:1.1rem;"></button> <button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory" aria-label="Refresh" style="font-size:1.1rem;"></button>
@ -7778,15 +7789,6 @@ X.B(E,Y);return E}return J}())
return out; return out;
} }
// Files currently placed in a node (reverse lookup over all source files).
function filesInNode(nodeId, axis, allFiles) {
var field = axis === 'transmittal' ? 'transmittalNodeId' : 'trackingNodeId';
return (allFiles || []).filter(function (f) {
var a = state.assignments[srcKeyForFile(f)];
return a && a[field] === nodeId;
});
}
// Per-file classification state for the left-tree markers. // Per-file classification state for the left-tree markers.
// 'excluded' | 'done' | 'tracking' | 'transmittal' | 'partial' | 'none' // 'excluded' | 'done' | 'tracking' | 'transmittal' | 'partial' | 'none'
function fileState(file) { function fileState(file) {
@ -7824,7 +7826,11 @@ X.B(E,Y);return E}return J}())
transmittalTree: state.transmittalTree, transmittalTree: state.transmittalTree,
outputName: state.outputName, outputName: state.outputName,
config: state.config, config: state.config,
mdlList: state.mdlList, // Strip the transient row→keys hint (`placed`) — it's rebuilt as
// drops happen and would otherwise bloat every autosave.
mdlList: state.mdlList.map(function (r) {
return { id: r.id, party: r.party, trackingNumber: r.trackingNumber, title: r.title, revisionCell: r.revisionCell, source: r.source, archiveRevisions: r.archiveRevisions };
}),
}; };
} }
function load(obj) { function load(obj) {
@ -8258,7 +8264,7 @@ X.B(E,Y);return E}return J}())
getNode: getNode, getTrackingTree: function () { return state.trackingTree; }, getNode: getNode, getTrackingTree: function () { return state.trackingTree; },
getTransmittalTree: function () { return state.transmittalTree; }, getTransmittalTree: function () { return state.transmittalTree; },
// derive + reverse // derive + reverse
deriveTarget: deriveTarget, filesInNode: filesInNode, deriveTarget: deriveTarget,
fileState: fileState, stats: stats, fileState: fileState, stats: stats,
// persistence // persistence
serialize: serialize, load: load, reset: reset, serialize: serialize, load: load, reset: reset,
@ -11045,7 +11051,14 @@ X.B(E,Y);return E}return J}())
if (els.pasteRowsBtn) els.pasteRowsBtn.addEventListener('click', function () { openPasteDialog(''); }); if (els.pasteRowsBtn) els.pasteRowsBtn.addEventListener('click', function () { openPasteDialog(''); });
if (els.matchNamesBtn) els.matchNamesBtn.addEventListener('click', openMatchDialog); if (els.matchNamesBtn) els.matchNamesBtn.addEventListener('click', openMatchDialog);
if (els.clearListBtn) els.clearListBtn.addEventListener('click', function () { if (els.clearListBtn) els.clearListBtn.addEventListener('click', function () {
if (!C().getMdlList().length) return; var list = C().getMdlList();
if (!list.length) return;
// Warn before stranding files that still need a revision: they stay
// assigned (on a "pending" leaf under By tracking number), but the
// row you'd use to finish them here is about to disappear.
var pending = 0;
list.forEach(function (r) { if (!(r.revisionCell || '').trim()) pending += Object.keys(r.placed || {}).length; });
if (pending && !confirm(pending + ' file' + (pending === 1 ? '' : 's') + ' still need a revision. They stay assigned (a “pending” folder under By tracking number), but the list row to finish them here goes away. Clear anyway?')) return;
C().clearMdlList(); C().clearMdlList();
window.zddc.toast('List cleared — every assignment is kept (see By tracking number).', 'info'); window.zddc.toast('List cleared — every assignment is kept (see By tracking number).', 'info');
}); });
@ -11591,11 +11604,11 @@ X.B(E,Y);return E}return J}())
}); });
} }
// Load the catalog: "Load…" opens a multi-select directory tree (scoped to // "From a list" loader: "Load…" opens a multi-select directory tree (scoped
// the served context); every ticked directory is walked recursively into the // to the served context); every ticked directory is walked recursively into
// union of existing files + MDL deliverables, deduped by tracking number to // the union of existing files + MDL deliverables, deduped by tracking number
// one row at the latest revision. Writes/alters nothing — the revision cell // to one row at the latest revision. Writes/alters nothing — the revision
// is classifier-local and starts blank. // cell is classifier-local and starts blank.
function isRowYaml(nm) { return /\.yaml$/i.test(nm) && nm !== 'table.yaml' && nm !== 'form.yaml'; } function isRowYaml(nm) { return /\.yaml$/i.test(nm) && nm !== 'table.yaml' && nm !== 'form.yaml'; }
// The newest combined "<rev> (<status>)" string in a set, by revision token. // The newest combined "<rev> (<status>)" string in a set, by revision token.
@ -15724,6 +15737,48 @@ X.B(E,Y);return E}return J}())
a: 'edit access rules' a: 'edit access rules'
}; };
// ── "who can?" — tell a denied user who to ask, subtly ──────────────────
// The PATTERN: gate an affordance on cap.has(node, verb) / a path view's
// path_verbs; when the verb is ABSENT, don't just disable — render
// cap.denyHint(view, verb) as the disabled control's tooltip/subtitle (or
// a small inline note) so the user learns who can do it instead of acting
// and bouncing off a 403. handleForbidden() does the same on a denial that
// slips past a pre-check.
function humanizeRole(name) { return String(name || '').replace(/[_-]+/g, ' ').trim(); }
// whoCan(view, verb) → the Authority {roles, people} the server computed for
// a verb the caller lacks at view's path, or null. `view` is a /.profile/
// access?path= result (from cap.at) OR a 403 body carrying who_can.
function whoCan(view, verb) {
if (!view) return null;
var map = view.path_who_can;
if (map && map[verb]) return map[verb];
if (view.who_can && view.missing_verb === verb) return view.who_can;
return null;
}
// denyHint(view, verb) → { text, title } for a subtle "who can" line.
// Role-first: "Only the document controller can create here", with the
// specific people (admins / role members) as the tooltip detail. Falls back
// to naming people, then to "Ask an administrator". Returns null when the
// verb is actually granted (nothing to hint) and a generic hint when no
// authority is known.
function denyHint(view, verb) {
var a = whoCan(view, verb);
var doing = VERB_LABELS[verb] || verb || 'do that';
if (!a || (!(a.roles && a.roles.length) && !(a.people && a.people.length))) {
return { text: 'Ask an administrator to ' + doing + ' here.', title: '' };
}
var people = (a.people || []).slice();
var detail = people.length ? people.join(', ') : '';
if (a.roles && a.roles.length) {
return { text: 'Only the ' + humanizeRole(a.roles[0]) + ' can ' + doing + ' here.', title: detail };
}
var shown = people.slice(0, 2).join(', ') + (people.length > 2 ? ', …' : '');
return { text: 'Ask ' + shown + ' to ' + doing + ' here.', title: detail };
}
// handleForbidden(resp, opts) — render a 403 toast naming the // handleForbidden(resp, opts) — render a 403 toast naming the
// missing verb. opts.path (optional) is the URL the failed request // missing verb. opts.path (optional) is the URL the failed request
// hit; when provided, the helper consults /.profile/access?path= to // hit; when provided, the helper consults /.profile/access?path= to
@ -15737,8 +15792,9 @@ X.B(E,Y);return E}return J}())
async function handleForbidden(resp, opts) { async function handleForbidden(resp, opts) {
opts = opts || {}; opts = opts || {};
var missing = ''; var missing = '';
var body = null;
try { try {
var body = await resp.clone().json(); body = await resp.clone().json();
if (body && typeof body.missing_verb === 'string') { if (body && typeof body.missing_verb === 'string') {
missing = body.missing_verb; missing = body.missing_verb;
} }
@ -15753,6 +15809,16 @@ X.B(E,Y);return E}return J}())
msg = prefix + 'Forbidden.'; msg = prefix + 'Forbidden.';
} }
// Append the subtle "who can" hint: prefer who_can from the 403 body,
// else fall back to the path-scoped access view. So even a denial the
// UI didn't pre-check still tells the user who to ask.
if (missing) {
var src = (body && body.who_can) ? { who_can: body.who_can, missing_verb: missing } : null;
if (!src && opts.path) src = await at(opts.path);
var hint = src ? denyHint(src, missing) : null;
if (hint && hint.text) msg += ' ' + hint.text;
}
// Optional elevate offer: only when the caller supplied a // Optional elevate offer: only when the caller supplied a
// path AND the path-scoped access view reports an elevation // path AND the path-scoped access view reports an elevation
// grant covering the missing verb. Render as a clickable // grant covering the missing verb. Render as a clickable
@ -15785,7 +15851,7 @@ X.B(E,Y);return E}return J}())
} }
} }
window.zddc.cap = { at: at, has: has, handleForbidden: handleForbidden }; window.zddc.cap = { at: at, has: has, handleForbidden: handleForbidden, whoCan: whoCan, denyHint: denyHint };
})(); })();
</script> </script>

View file

@ -1609,6 +1609,21 @@ body {
text-decoration: underline; text-decoration: underline;
} }
/* Subtle standalone-tools footer. */
.landing-apps {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
margin: 2rem auto 1rem;
padding: 0 1rem;
max-width: 60rem;
font-size: 0.82rem;
color: var(--text-muted, #6b7280);
}
.landing-apps__label { color: var(--text-muted, #6b7280); }
.landing-apps .browse-link { margin-top: 0; }
#projectView ol { #projectView ol {
padding-left: 1.5rem; padding-left: 1.5rem;
} }
@ -1778,7 +1793,7 @@ body {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC</span> <span class="app-header__title">ZDDC</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-12 16:07:31 · f66b9c5</span></span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-13 17:17:09 · 51f5947</span></span>
</div> </div>
</div> </div>
<div class="header-right"> <div class="header-right">
@ -1951,6 +1966,15 @@ body {
</div><!-- /projectView --> </div><!-- /projectView -->
</main> </main>
<!-- Subtle links to the standalone tools (run against your own local files,
no server). Served at /_apps/<tool>.html by zddc-server. -->
<footer class="landing-apps">
<span class="landing-apps__label">Standalone tools for your own files:</span>
<a class="browse-link" href="/_apps/browse.html">Browse</a>
<a class="browse-link" href="/_apps/archive.html">Archive</a>
<a class="browse-link" href="/_apps/classifier.html">Classifier</a>
</footer>
<!-- Help Panel --> <!-- Help Panel -->
<aside id="help-panel" class="help-panel" hidden aria-labelledby="help-panel-title"> <aside id="help-panel" class="help-panel" hidden aria-labelledby="help-panel-title">
<div class="help-panel__header"> <div class="help-panel__header">
@ -3372,6 +3396,48 @@ body {
a: 'edit access rules' a: 'edit access rules'
}; };
// ── "who can?" — tell a denied user who to ask, subtly ──────────────────
// The PATTERN: gate an affordance on cap.has(node, verb) / a path view's
// path_verbs; when the verb is ABSENT, don't just disable — render
// cap.denyHint(view, verb) as the disabled control's tooltip/subtitle (or
// a small inline note) so the user learns who can do it instead of acting
// and bouncing off a 403. handleForbidden() does the same on a denial that
// slips past a pre-check.
function humanizeRole(name) { return String(name || '').replace(/[_-]+/g, ' ').trim(); }
// whoCan(view, verb) → the Authority {roles, people} the server computed for
// a verb the caller lacks at view's path, or null. `view` is a /.profile/
// access?path= result (from cap.at) OR a 403 body carrying who_can.
function whoCan(view, verb) {
if (!view) return null;
var map = view.path_who_can;
if (map && map[verb]) return map[verb];
if (view.who_can && view.missing_verb === verb) return view.who_can;
return null;
}
// denyHint(view, verb) → { text, title } for a subtle "who can" line.
// Role-first: "Only the document controller can create here", with the
// specific people (admins / role members) as the tooltip detail. Falls back
// to naming people, then to "Ask an administrator". Returns null when the
// verb is actually granted (nothing to hint) and a generic hint when no
// authority is known.
function denyHint(view, verb) {
var a = whoCan(view, verb);
var doing = VERB_LABELS[verb] || verb || 'do that';
if (!a || (!(a.roles && a.roles.length) && !(a.people && a.people.length))) {
return { text: 'Ask an administrator to ' + doing + ' here.', title: '' };
}
var people = (a.people || []).slice();
var detail = people.length ? people.join(', ') : '';
if (a.roles && a.roles.length) {
return { text: 'Only the ' + humanizeRole(a.roles[0]) + ' can ' + doing + ' here.', title: detail };
}
var shown = people.slice(0, 2).join(', ') + (people.length > 2 ? ', …' : '');
return { text: 'Ask ' + shown + ' to ' + doing + ' here.', title: detail };
}
// handleForbidden(resp, opts) — render a 403 toast naming the // handleForbidden(resp, opts) — render a 403 toast naming the
// missing verb. opts.path (optional) is the URL the failed request // missing verb. opts.path (optional) is the URL the failed request
// hit; when provided, the helper consults /.profile/access?path= to // hit; when provided, the helper consults /.profile/access?path= to
@ -3385,8 +3451,9 @@ body {
async function handleForbidden(resp, opts) { async function handleForbidden(resp, opts) {
opts = opts || {}; opts = opts || {};
var missing = ''; var missing = '';
var body = null;
try { try {
var body = await resp.clone().json(); body = await resp.clone().json();
if (body && typeof body.missing_verb === 'string') { if (body && typeof body.missing_verb === 'string') {
missing = body.missing_verb; missing = body.missing_verb;
} }
@ -3401,6 +3468,16 @@ body {
msg = prefix + 'Forbidden.'; msg = prefix + 'Forbidden.';
} }
// Append the subtle "who can" hint: prefer who_can from the 403 body,
// else fall back to the path-scoped access view. So even a denial the
// UI didn't pre-check still tells the user who to ask.
if (missing) {
var src = (body && body.who_can) ? { who_can: body.who_can, missing_verb: missing } : null;
if (!src && opts.path) src = await at(opts.path);
var hint = src ? denyHint(src, missing) : null;
if (hint && hint.text) msg += ' ' + hint.text;
}
// Optional elevate offer: only when the caller supplied a // Optional elevate offer: only when the caller supplied a
// path AND the path-scoped access view reports an elevation // path AND the path-scoped access view reports an elevation
// grant covering the missing verb. Render as a clickable // grant covering the missing verb. Render as a clickable
@ -3433,7 +3510,7 @@ body {
} }
} }
window.zddc.cap = { at: at, has: has, handleForbidden: handleForbidden }; window.zddc.cap = { at: at, has: has, handleForbidden: handleForbidden, whoCan: whoCan, denyHint: denyHint };
})(); })();
(function() { (function() {

View file

@ -2770,7 +2770,7 @@ dialog.modal--narrow {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC Transmittal</span> <span class="app-header__title">ZDDC Transmittal</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-12 16:07:31 · f66b9c5</span></span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-13 17:17:09 · 51f5947</span></span>
</div> </div>
<span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span> <span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span>
<!-- Publish split-button (Transmittal-specific primary action; <!-- Publish split-button (Transmittal-specific primary action;
@ -14071,6 +14071,48 @@ X.B(E,Y);return E}return J}())
a: 'edit access rules' a: 'edit access rules'
}; };
// ── "who can?" — tell a denied user who to ask, subtly ──────────────────
// The PATTERN: gate an affordance on cap.has(node, verb) / a path view's
// path_verbs; when the verb is ABSENT, don't just disable — render
// cap.denyHint(view, verb) as the disabled control's tooltip/subtitle (or
// a small inline note) so the user learns who can do it instead of acting
// and bouncing off a 403. handleForbidden() does the same on a denial that
// slips past a pre-check.
function humanizeRole(name) { return String(name || '').replace(/[_-]+/g, ' ').trim(); }
// whoCan(view, verb) → the Authority {roles, people} the server computed for
// a verb the caller lacks at view's path, or null. `view` is a /.profile/
// access?path= result (from cap.at) OR a 403 body carrying who_can.
function whoCan(view, verb) {
if (!view) return null;
var map = view.path_who_can;
if (map && map[verb]) return map[verb];
if (view.who_can && view.missing_verb === verb) return view.who_can;
return null;
}
// denyHint(view, verb) → { text, title } for a subtle "who can" line.
// Role-first: "Only the document controller can create here", with the
// specific people (admins / role members) as the tooltip detail. Falls back
// to naming people, then to "Ask an administrator". Returns null when the
// verb is actually granted (nothing to hint) and a generic hint when no
// authority is known.
function denyHint(view, verb) {
var a = whoCan(view, verb);
var doing = VERB_LABELS[verb] || verb || 'do that';
if (!a || (!(a.roles && a.roles.length) && !(a.people && a.people.length))) {
return { text: 'Ask an administrator to ' + doing + ' here.', title: '' };
}
var people = (a.people || []).slice();
var detail = people.length ? people.join(', ') : '';
if (a.roles && a.roles.length) {
return { text: 'Only the ' + humanizeRole(a.roles[0]) + ' can ' + doing + ' here.', title: detail };
}
var shown = people.slice(0, 2).join(', ') + (people.length > 2 ? ', …' : '');
return { text: 'Ask ' + shown + ' to ' + doing + ' here.', title: detail };
}
// handleForbidden(resp, opts) — render a 403 toast naming the // handleForbidden(resp, opts) — render a 403 toast naming the
// missing verb. opts.path (optional) is the URL the failed request // missing verb. opts.path (optional) is the URL the failed request
// hit; when provided, the helper consults /.profile/access?path= to // hit; when provided, the helper consults /.profile/access?path= to
@ -14084,8 +14126,9 @@ X.B(E,Y);return E}return J}())
async function handleForbidden(resp, opts) { async function handleForbidden(resp, opts) {
opts = opts || {}; opts = opts || {};
var missing = ''; var missing = '';
var body = null;
try { try {
var body = await resp.clone().json(); body = await resp.clone().json();
if (body && typeof body.missing_verb === 'string') { if (body && typeof body.missing_verb === 'string') {
missing = body.missing_verb; missing = body.missing_verb;
} }
@ -14100,6 +14143,16 @@ X.B(E,Y);return E}return J}())
msg = prefix + 'Forbidden.'; msg = prefix + 'Forbidden.';
} }
// Append the subtle "who can" hint: prefer who_can from the 403 body,
// else fall back to the path-scoped access view. So even a denial the
// UI didn't pre-check still tells the user who to ask.
if (missing) {
var src = (body && body.who_can) ? { who_can: body.who_can, missing_verb: missing } : null;
if (!src && opts.path) src = await at(opts.path);
var hint = src ? denyHint(src, missing) : null;
if (hint && hint.text) msg += ' ' + hint.text;
}
// Optional elevate offer: only when the caller supplied a // Optional elevate offer: only when the caller supplied a
// path AND the path-scoped access view reports an elevation // path AND the path-scoped access view reports an elevation
// grant covering the missing verb. Render as a clickable // grant covering the missing verb. Render as a clickable
@ -14132,7 +14185,7 @@ X.B(E,Y);return E}return J}())
} }
} }
window.zddc.cap = { at: at, has: has, handleForbidden: handleForbidden }; window.zddc.cap = { at: at, has: has, handleForbidden: handleForbidden, whoCan: whoCan, denyHint: denyHint };
})(); })();
(function (app) { (function (app) {

View file

@ -1,8 +1,8 @@
# Generated by build.sh — do not edit. One <app>=<build label> per line. # Generated by build.sh — do not edit. One <app>=<build label> per line.
archive=v0.0.27-beta · 2026-06-12 16:07:31 · f66b9c5 archive=v0.0.27-beta · 2026-06-13 17:17:09 · 51f5947
transmittal=v0.0.27-beta · 2026-06-12 16:07:31 · f66b9c5 transmittal=v0.0.27-beta · 2026-06-13 17:17:09 · 51f5947
classifier=v0.0.27-beta · 2026-06-12 16:07:31 · f66b9c5 classifier=v0.0.27-beta · 2026-06-13 17:17:09 · 51f5947
landing=v0.0.27-beta · 2026-06-12 16:07:31 · f66b9c5 landing=v0.0.27-beta · 2026-06-13 17:17:09 · 51f5947
form=v0.0.27-beta · 2026-06-12 16:07:31 · f66b9c5 form=v0.0.27-beta · 2026-06-13 17:17:09 · 51f5947
tables=v0.0.27-beta · 2026-06-12 16:07:31 · f66b9c5 tables=v0.0.27-beta · 2026-06-13 17:17:09 · 51f5947
browse=v0.0.27-beta · 2026-06-12 16:07:31 · f66b9c5 browse=v0.0.27-beta · 2026-06-13 17:17:09 · 51f5947

View file

@ -1770,7 +1770,7 @@ body.is-elevated::after {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title" id="table-title">ZDDC Table</span> <span class="app-header__title" id="table-title">ZDDC Table</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-12 16:07:31 · f66b9c5</span></span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-13 17:17:09 · 51f5947</span></span>
</div> </div>
</div> </div>
<div class="header-right"> <div class="header-right">
@ -3592,6 +3592,48 @@ body.is-elevated::after {
a: 'edit access rules' a: 'edit access rules'
}; };
// ── "who can?" — tell a denied user who to ask, subtly ──────────────────
// The PATTERN: gate an affordance on cap.has(node, verb) / a path view's
// path_verbs; when the verb is ABSENT, don't just disable — render
// cap.denyHint(view, verb) as the disabled control's tooltip/subtitle (or
// a small inline note) so the user learns who can do it instead of acting
// and bouncing off a 403. handleForbidden() does the same on a denial that
// slips past a pre-check.
function humanizeRole(name) { return String(name || '').replace(/[_-]+/g, ' ').trim(); }
// whoCan(view, verb) → the Authority {roles, people} the server computed for
// a verb the caller lacks at view's path, or null. `view` is a /.profile/
// access?path= result (from cap.at) OR a 403 body carrying who_can.
function whoCan(view, verb) {
if (!view) return null;
var map = view.path_who_can;
if (map && map[verb]) return map[verb];
if (view.who_can && view.missing_verb === verb) return view.who_can;
return null;
}
// denyHint(view, verb) → { text, title } for a subtle "who can" line.
// Role-first: "Only the document controller can create here", with the
// specific people (admins / role members) as the tooltip detail. Falls back
// to naming people, then to "Ask an administrator". Returns null when the
// verb is actually granted (nothing to hint) and a generic hint when no
// authority is known.
function denyHint(view, verb) {
var a = whoCan(view, verb);
var doing = VERB_LABELS[verb] || verb || 'do that';
if (!a || (!(a.roles && a.roles.length) && !(a.people && a.people.length))) {
return { text: 'Ask an administrator to ' + doing + ' here.', title: '' };
}
var people = (a.people || []).slice();
var detail = people.length ? people.join(', ') : '';
if (a.roles && a.roles.length) {
return { text: 'Only the ' + humanizeRole(a.roles[0]) + ' can ' + doing + ' here.', title: detail };
}
var shown = people.slice(0, 2).join(', ') + (people.length > 2 ? ', …' : '');
return { text: 'Ask ' + shown + ' to ' + doing + ' here.', title: detail };
}
// handleForbidden(resp, opts) — render a 403 toast naming the // handleForbidden(resp, opts) — render a 403 toast naming the
// missing verb. opts.path (optional) is the URL the failed request // missing verb. opts.path (optional) is the URL the failed request
// hit; when provided, the helper consults /.profile/access?path= to // hit; when provided, the helper consults /.profile/access?path= to
@ -3605,8 +3647,9 @@ body.is-elevated::after {
async function handleForbidden(resp, opts) { async function handleForbidden(resp, opts) {
opts = opts || {}; opts = opts || {};
var missing = ''; var missing = '';
var body = null;
try { try {
var body = await resp.clone().json(); body = await resp.clone().json();
if (body && typeof body.missing_verb === 'string') { if (body && typeof body.missing_verb === 'string') {
missing = body.missing_verb; missing = body.missing_verb;
} }
@ -3621,6 +3664,16 @@ body.is-elevated::after {
msg = prefix + 'Forbidden.'; msg = prefix + 'Forbidden.';
} }
// Append the subtle "who can" hint: prefer who_can from the 403 body,
// else fall back to the path-scoped access view. So even a denial the
// UI didn't pre-check still tells the user who to ask.
if (missing) {
var src = (body && body.who_can) ? { who_can: body.who_can, missing_verb: missing } : null;
if (!src && opts.path) src = await at(opts.path);
var hint = src ? denyHint(src, missing) : null;
if (hint && hint.text) msg += ' ' + hint.text;
}
// Optional elevate offer: only when the caller supplied a // Optional elevate offer: only when the caller supplied a
// path AND the path-scoped access view reports an elevation // path AND the path-scoped access view reports an elevation
// grant covering the missing verb. Render as a clickable // grant covering the missing verb. Render as a clickable
@ -3653,7 +3706,7 @@ body.is-elevated::after {
} }
} }
window.zddc.cap = { at: at, has: has, handleForbidden: handleForbidden }; window.zddc.cap = { at: at, has: has, handleForbidden: handleForbidden, whoCan: whoCan, denyHint: denyHint };
})(); })();
// shared/context-menu.js — generic context-menu framework exposed on // shared/context-menu.js — generic context-menu framework exposed on
@ -8138,7 +8191,9 @@ body.is-elevated::after {
if (verbs.indexOf('c') === -1) { if (verbs.indexOf('c') === -1) {
addRowBtn.classList.add('is-disabled'); addRowBtn.classList.add('is-disabled');
addRowBtn.setAttribute('aria-disabled', 'true'); addRowBtn.setAttribute('aria-disabled', 'true');
addRowBtn.title = "You don't have create access in this folder."; // Tell them who can (subtly): role-first text + people in the tooltip.
var hint = window.zddc.cap.denyHint ? window.zddc.cap.denyHint(view, 'c') : null;
addRowBtn.title = hint ? (hint.text + (hint.title ? ' (' + hint.title + ')' : '')) : "You don't have create access in this folder.";
// Swallow clicks so the no-op feedback is the // Swallow clicks so the no-op feedback is the
// tooltip, not a 403 toast on submission. // tooltip, not a 403 toast on submission.
addRowBtn.addEventListener('click', function (ev) { addRowBtn.addEventListener('click', function (ev) {