chore(embedded): cut v0.0.27-beta
Some checks failed
Notify chart dev on beta cut / notify-chart-dev (push) Failing after 4s
Some checks failed
Notify chart dev on beta cut / notify-chart-dev (push) Failing after 4s
This commit is contained in:
parent
51f5947716
commit
605f4ab3e0
7 changed files with 477 additions and 90 deletions
|
|
@ -2717,7 +2717,7 @@ td[data-field="trackingNumber"] {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<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>
|
||||
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</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'
|
||||
};
|
||||
|
||||
// ── "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
|
||||
// missing verb. opts.path (optional) is the URL the failed request
|
||||
// hit; when provided, the helper consults /.profile/access?path= to
|
||||
|
|
@ -11536,8 +11578,9 @@ window.app.modules.filtering = {
|
|||
async function handleForbidden(resp, opts) {
|
||||
opts = opts || {};
|
||||
var missing = '';
|
||||
var body = null;
|
||||
try {
|
||||
var body = await resp.clone().json();
|
||||
body = await resp.clone().json();
|
||||
if (body && typeof body.missing_verb === 'string') {
|
||||
missing = body.missing_verb;
|
||||
}
|
||||
|
|
@ -11552,6 +11595,16 @@ window.app.modules.filtering = {
|
|||
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
|
||||
// path AND the path-scoped access view reports an elevation
|
||||
// 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>
|
||||
|
|
|
|||
|
|
@ -2824,7 +2824,7 @@ li.CodeMirror-hint-active {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<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>
|
||||
<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>
|
||||
|
|
@ -7022,6 +7022,48 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
|
|||
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
|
||||
// missing verb. opts.path (optional) is the URL the failed request
|
||||
// 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) {
|
||||
opts = opts || {};
|
||||
var missing = '';
|
||||
var body = null;
|
||||
try {
|
||||
var body = await resp.clone().json();
|
||||
body = await resp.clone().json();
|
||||
if (body && typeof body.missing_verb === 'string') {
|
||||
missing = body.missing_verb;
|
||||
}
|
||||
|
|
@ -7051,6 +7094,16 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
|
|||
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
|
||||
// path AND the path-scoped access view reports an elevation
|
||||
// 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.
|
||||
|
|
@ -16209,6 +16262,13 @@ window.__ZDDC_SCHEMA__ = {
|
|||
function openPartyPicker(opts) {
|
||||
return new Promise(function (resolve) {
|
||||
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 can’t register a new party here.';
|
||||
var newPartyTitle = (!newPartyAllowed && opts.newPartyHint && opts.newPartyHint.title) || '';
|
||||
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;';
|
||||
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) + '/<party>/</code>.' +
|
||||
'</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;">' +
|
||||
(partyList || '<em style="color:#888;">No parties yet — create one below.</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;">' +
|
||||
'<input type="radio" name="pp-party" value="__new__">' +
|
||||
'<span><strong>+ New party…</strong> <span style="color:#888;font-size:0.8rem;">(document controller only)</span></span></label>' +
|
||||
(partyList || '<em style="color:#888;">No parties yet.</em>') +
|
||||
'<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__"' + (newPartyAllowed ? '' : ' disabled') + '>' +
|
||||
'<span><strong>+ New party…</strong> <span style="color:#888;font-size:0.8rem;">' + escapeHtml(newPartyNote) + '</span></span></label>' +
|
||||
'</div>' +
|
||||
'<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>' +
|
||||
|
|
@ -16287,8 +16347,28 @@ window.__ZDDC_SCHEMA__ = {
|
|||
async function createInAggregator(agg, kind) {
|
||||
var up = window.app.modules.upload;
|
||||
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 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;
|
||||
// Party names are validated to a URL-safe charset, so no encoding
|
||||
// needed for the party segment; makeDir/makeFile encode the leaf.
|
||||
|
|
@ -16311,7 +16391,10 @@ window.__ZDDC_SCHEMA__ = {
|
|||
} catch (e) {
|
||||
var msg = (e && e.message) || String(e);
|
||||
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 don’t have create access here.');
|
||||
} else if (/\b409\b/.test(msg)) {
|
||||
statusError('Unknown party — register it first (document controller).');
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -1168,6 +1168,54 @@ body.is-elevated::after {
|
|||
/* Toast notifications come from shared/toast.css (.zddc-toast); the
|
||||
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 */
|
||||
|
||||
/* .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__conf { color: var(--text-muted); font-size: 0.72rem; width: 3rem; text-align: right; }
|
||||
|
||||
/* ── MDL-from-archive overlay ───────────────────────────────────────────── */
|
||||
.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; }
|
||||
.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; }
|
||||
.mdl-overlay__head { display: flex; align-items: center; justify-content: space-between; padding: 0.75rem 1rem; border-bottom: 1px solid var(--border); }
|
||||
.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; }
|
||||
/* The base seltable rules live in shared/seltable.css (bundled by build.sh and
|
||||
shared with the tables tool); only the classifier-specific catalog bits
|
||||
(.seltable__extra, .mdl-rev__input, .fromlist-*, .src-badge, #mdlTree) are
|
||||
here. */
|
||||
|
||||
/* ── Copy destination dialog ────────────────────────────────────────────── */
|
||||
.copy-choice__backdrop {
|
||||
|
|
@ -2407,7 +2418,7 @@ input.tfile__name:focus { border-color: var(--primary); background: var(--bg); o
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<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>
|
||||
<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>
|
||||
|
|
@ -7778,15 +7789,6 @@ X.B(E,Y);return E}return J}())
|
|||
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.
|
||||
// 'excluded' | 'done' | 'tracking' | 'transmittal' | 'partial' | 'none'
|
||||
function fileState(file) {
|
||||
|
|
@ -7824,7 +7826,11 @@ X.B(E,Y);return E}return J}())
|
|||
transmittalTree: state.transmittalTree,
|
||||
outputName: state.outputName,
|
||||
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) {
|
||||
|
|
@ -8258,7 +8264,7 @@ X.B(E,Y);return E}return J}())
|
|||
getNode: getNode, getTrackingTree: function () { return state.trackingTree; },
|
||||
getTransmittalTree: function () { return state.transmittalTree; },
|
||||
// derive + reverse
|
||||
deriveTarget: deriveTarget, filesInNode: filesInNode,
|
||||
deriveTarget: deriveTarget,
|
||||
fileState: fileState, stats: stats,
|
||||
// persistence
|
||||
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.matchNamesBtn) els.matchNamesBtn.addEventListener('click', openMatchDialog);
|
||||
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();
|
||||
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
|
||||
// the served context); every ticked directory is walked recursively into the
|
||||
// union of existing files + MDL deliverables, deduped by tracking number to
|
||||
// one row at the latest revision. Writes/alters nothing — the revision cell
|
||||
// is classifier-local and starts blank.
|
||||
// "From a list" loader: "Load…" opens a multi-select directory tree (scoped
|
||||
// to the served context); every ticked directory is walked recursively into
|
||||
// the union of existing files + MDL deliverables, deduped by tracking number
|
||||
// to one row at the latest revision. Writes/alters nothing — the revision
|
||||
// cell is classifier-local and starts blank.
|
||||
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.
|
||||
|
|
@ -15724,6 +15737,48 @@ X.B(E,Y);return E}return J}())
|
|||
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
|
||||
// missing verb. opts.path (optional) is the URL the failed request
|
||||
// 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) {
|
||||
opts = opts || {};
|
||||
var missing = '';
|
||||
var body = null;
|
||||
try {
|
||||
var body = await resp.clone().json();
|
||||
body = await resp.clone().json();
|
||||
if (body && typeof body.missing_verb === 'string') {
|
||||
missing = body.missing_verb;
|
||||
}
|
||||
|
|
@ -15753,6 +15809,16 @@ X.B(E,Y);return E}return J}())
|
|||
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
|
||||
// path AND the path-scoped access view reports an elevation
|
||||
// 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>
|
||||
|
|
|
|||
|
|
@ -1609,6 +1609,21 @@ body {
|
|||
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 {
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
|
@ -1778,7 +1793,7 @@ body {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<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 class="header-right">
|
||||
|
|
@ -1951,6 +1966,15 @@ body {
|
|||
</div><!-- /projectView -->
|
||||
</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 -->
|
||||
<aside id="help-panel" class="help-panel" hidden aria-labelledby="help-panel-title">
|
||||
<div class="help-panel__header">
|
||||
|
|
@ -3372,6 +3396,48 @@ body {
|
|||
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
|
||||
// missing verb. opts.path (optional) is the URL the failed request
|
||||
// hit; when provided, the helper consults /.profile/access?path= to
|
||||
|
|
@ -3385,8 +3451,9 @@ body {
|
|||
async function handleForbidden(resp, opts) {
|
||||
opts = opts || {};
|
||||
var missing = '';
|
||||
var body = null;
|
||||
try {
|
||||
var body = await resp.clone().json();
|
||||
body = await resp.clone().json();
|
||||
if (body && typeof body.missing_verb === 'string') {
|
||||
missing = body.missing_verb;
|
||||
}
|
||||
|
|
@ -3401,6 +3468,16 @@ body {
|
|||
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
|
||||
// path AND the path-scoped access view reports an elevation
|
||||
// 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() {
|
||||
|
|
|
|||
|
|
@ -2770,7 +2770,7 @@ dialog.modal--narrow {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<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>
|
||||
<span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span>
|
||||
<!-- Publish split-button (Transmittal-specific primary action;
|
||||
|
|
@ -14071,6 +14071,48 @@ X.B(E,Y);return E}return J}())
|
|||
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
|
||||
// missing verb. opts.path (optional) is the URL the failed request
|
||||
// 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) {
|
||||
opts = opts || {};
|
||||
var missing = '';
|
||||
var body = null;
|
||||
try {
|
||||
var body = await resp.clone().json();
|
||||
body = await resp.clone().json();
|
||||
if (body && typeof body.missing_verb === 'string') {
|
||||
missing = body.missing_verb;
|
||||
}
|
||||
|
|
@ -14100,6 +14143,16 @@ X.B(E,Y);return E}return J}())
|
|||
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
|
||||
// path AND the path-scoped access view reports an elevation
|
||||
// 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) {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
# 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
|
||||
transmittal=v0.0.27-beta · 2026-06-12 16:07:31 · f66b9c5
|
||||
classifier=v0.0.27-beta · 2026-06-12 16:07:31 · f66b9c5
|
||||
landing=v0.0.27-beta · 2026-06-12 16:07:31 · f66b9c5
|
||||
form=v0.0.27-beta · 2026-06-12 16:07:31 · f66b9c5
|
||||
tables=v0.0.27-beta · 2026-06-12 16:07:31 · f66b9c5
|
||||
browse=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-13 17:17:09 · 51f5947
|
||||
classifier=v0.0.27-beta · 2026-06-13 17:17:09 · 51f5947
|
||||
landing=v0.0.27-beta · 2026-06-13 17:17:09 · 51f5947
|
||||
form=v0.0.27-beta · 2026-06-13 17:17:09 · 51f5947
|
||||
tables=v0.0.27-beta · 2026-06-13 17:17:09 · 51f5947
|
||||
browse=v0.0.27-beta · 2026-06-13 17:17:09 · 51f5947
|
||||
|
|
|
|||
|
|
@ -1770,7 +1770,7 @@ body.is-elevated::after {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<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 class="header-right">
|
||||
|
|
@ -3592,6 +3592,48 @@ body.is-elevated::after {
|
|||
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
|
||||
// missing verb. opts.path (optional) is the URL the failed request
|
||||
// hit; when provided, the helper consults /.profile/access?path= to
|
||||
|
|
@ -3605,8 +3647,9 @@ body.is-elevated::after {
|
|||
async function handleForbidden(resp, opts) {
|
||||
opts = opts || {};
|
||||
var missing = '';
|
||||
var body = null;
|
||||
try {
|
||||
var body = await resp.clone().json();
|
||||
body = await resp.clone().json();
|
||||
if (body && typeof body.missing_verb === 'string') {
|
||||
missing = body.missing_verb;
|
||||
}
|
||||
|
|
@ -3621,6 +3664,16 @@ body.is-elevated::after {
|
|||
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
|
||||
// path AND the path-scoped access view reports an elevation
|
||||
// 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
|
||||
|
|
@ -8138,7 +8191,9 @@ body.is-elevated::after {
|
|||
if (verbs.indexOf('c') === -1) {
|
||||
addRowBtn.classList.add('is-disabled');
|
||||
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
|
||||
// tooltip, not a 403 toast on submission.
|
||||
addRowBtn.addEventListener('click', function (ev) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue