@@ -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 };
})();
diff --git a/zddc/internal/apps/embedded/browse.html b/zddc/internal/apps/embedded/browse.html
index 7c42f59..73cd8bd 100644
--- a/zddc/internal/apps/embedded/browse.html
+++ b/zddc/internal/apps/embedded/browse.html
@@ -2824,7 +2824,7 @@ li.CodeMirror-hint-active {
@@ -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 ' + escapeHtml(opts.slot) + '/<party>/.' +
'' +
'
@@ -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 " ()" 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 };
})();
diff --git a/zddc/internal/apps/embedded/index.html b/zddc/internal/apps/embedded/index.html
index 56da175..25eaffb 100644
--- a/zddc/internal/apps/embedded/index.html
+++ b/zddc/internal/apps/embedded/index.html
@@ -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 {