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>
|
</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>
|
||||||
|
|
|
||||||
|
|
@ -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 can’t 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) + '/<party>/</code>.' +
|
'Pick the party this ' + kindWord + ' belongs to — it lands under <code>' + escapeHtml(opts.slot) + '/<party>/</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 don’t 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 {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue