feat(zddc): Phase 4a — drop_target cascade key, browse upload zone migrated

The last hardcoded client-side knowledge of the canonical convention
was the upload-zone regex in browse:

    var UPLOAD_SCOPES = /\/(working|staging|incoming)(\/|$)/i;

Now declared in the cascade:

  Schema:
    drop_target: true|false   leaf-only; describes THIS dir
                              (not propagated to descendants)

  Lookup:
    zddc.DropTargetAt(root, dir) bool

  Surfaced to clients:
    Directory listings carry an X-ZDDC-Drop-Target: true response
    header when the cascade declares this leaf as an upload zone.
    No header = no drop target.

  Defaults populated:
    working / working/* / staging / archive/<party>/incoming
    all carry drop_target: true. Operators can extend (e.g. drop
    files on archive/<party>/received via override) or disable
    (e.g. drop_target: false at a specific staging subtree) without
    touching code.

  Browse migration:
    loader.fetchServerChildren reads the response header and stamps
    state.scopeDropTarget on every listing fetch. upload.js's
    currentScopeAllows now reads that flag instead of regex-
    matching the URL. Initial value is false in init.js so a
    listing failure (offline / server doesn't emit the header)
    safely defaults to "no drop zone".

Phase 4a closes the most visible asymmetry between server-side and
client-side cascade knowledge. The remaining client hardcodes
(browse grid-mode regex, archive source heuristics, shared/nav
stage strip) follow the same pattern when needed — Phase 4b/c/d.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-05-11 16:12:41 -05:00
parent 6310afa922
commit 4b04f61e4b
9 changed files with 77 additions and 15 deletions

View file

@ -50,6 +50,11 @@
// Single shared popup window for file preview (across // Single shared popup window for file preview (across
// multiple file clicks). Same pattern as archive's preview. // multiple file clicks). Same pattern as archive's preview.
previewWindow: null previewWindow: null,
// Cascade-resolved scope flags, refreshed on each listing
// fetch from response headers.
// scopeDropTarget: cascade's drop_target at currentPath
scopeDropTarget: false
}; };
})(); })();

View file

@ -85,6 +85,14 @@
headers: { 'Accept': 'application/json' }, headers: { 'Accept': 'application/json' },
credentials: 'same-origin' credentials: 'same-origin'
}); });
// Capture cascade-resolved scope flags from response headers
// before bailing on 404. zddc-server emits X-ZDDC-Drop-Target
// for directories the cascade marks as upload destinations
// (see zddc/internal/zddc/lookups.go DropTargetAt). The flag
// is leaf-only — it describes THIS path, not its descendants
// — so a rescope or popstate re-reads it from the new listing.
var dropTargetHdr = (resp.headers.get('X-ZDDC-Drop-Target') || '').toLowerCase();
window.app.state.scopeDropTarget = dropTargetHdr === 'true';
if (resp.status === 404) { if (resp.status === 404) {
return []; return [];
} }

View file

@ -1,10 +1,16 @@
// upload.js — drag-drop file upload into the current scope. // upload.js — drag-drop file upload into the current scope.
// //
// Active only in server mode and only at paths where uploads make // Active only in server mode and only at paths where the cascade
// sense (any segment named working / staging / incoming, case- // declares drop_target: true (see zddc/internal/zddc/lookups.go
// insensitive). At other scopes the handlers stay armed but ignore // DropTargetAt + defaults.zddc.yaml). The loader captures the
// drops silently — there is no visible drop-zone overlay outside an // X-ZDDC-Drop-Target response header on every directory listing
// upload-eligible context. // fetch and stamps state.scopeDropTarget; this module just reads it.
//
// At scopes where drop_target is false (or unset), the handlers
// stay armed but ignore drops silently — no visible drop-zone
// overlay. An operator can flip working/staging/incoming on or
// extend the cascade to mark additional dirs as drop targets via
// .zddc; the client follows automatically without code change.
// //
// Wire model: // Wire model:
// - dragenter on the document raises a counter; first-enter shows // - dragenter on the document raises a counter; first-enter shows
@ -30,11 +36,6 @@
if (!window.app || !window.app.modules) return; if (!window.app || !window.app.modules) return;
var UPLOAD_MAX_BYTES = 256 * 1024 * 1024; // 256 MiB per file var UPLOAD_MAX_BYTES = 256 * 1024 * 1024; // 256 MiB per file
// Path segments where uploads are allowed. Matches the current
// hardcoded surface (working / staging / incoming). Will become
// configurable when the folders: schema lands.
var UPLOAD_SCOPES = /\/(working|staging|incoming)(\/|$)/i;
var state = window.app.state; var state = window.app.state;
var enterCount = 0; var enterCount = 0;
var overlayEl = null; var overlayEl = null;
@ -56,8 +57,12 @@
function currentScopeAllows() { function currentScopeAllows() {
if (!state || state.source !== 'server') return false; if (!state || state.source !== 'server') return false;
var p = state.currentPath || ''; // state.scopeDropTarget is set by the loader on every listing
return UPLOAD_SCOPES.test(p); // fetch from the X-ZDDC-Drop-Target response header; it's a
// boolean read of the cascade's effective drop_target flag at
// the current path. Defaults to false when the header is
// absent (older server or non-server response).
return !!state.scopeDropTarget;
} }
function showOverlay() { function showOverlay() {

View file

@ -128,6 +128,15 @@ func ServeDirectory(cfg config.Config, appsSrv *apps.Server, w http.ResponseWrit
// auto-detect (which fetches the same URL with Accept: JSON). // auto-detect (which fetches the same URL with Accept: JSON).
w.Header().Set("Vary", "Accept") w.Header().Set("Vary", "Accept")
// Surface cascade-resolved scope flags via response headers so
// the browse SPA can render scope-aware UI (drop-zone overlay,
// future affordances) without re-implementing the cascade
// client-side. Keep the header surface tight — only routing-
// shape booleans go here; ACL details stay server-side.
if zddc.DropTargetAt(cfg.Root, absDir) {
w.Header().Set("X-ZDDC-Drop-Target", "true")
}
if strings.Contains(accept, "application/json") { if strings.Contains(accept, "application/json") {
// Content-hash ETag on the listing payload. Re-fetched on every // Content-hash ETag on the listing payload. Re-fetched on every
// request (the cascade is walked, ACL filter applied, JSON // request (the cascade is walked, ACL filter applied, JSON

View file

@ -1300,7 +1300,7 @@ body.help-open .app-header {
</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.17-alpha · 2026-05-11 21:01:13 · 5e393cb-dirty</span></span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-alpha · 2026-05-11 21:11:16 · 6310afa-dirty</span></span>
</div> </div>
</div> </div>
<div class="header-right"> <div class="header-right">

View file

@ -68,6 +68,8 @@ paths:
# First write into incoming/ auto-creates an owner # First write into incoming/ auto-creates an owner
# grant so the creator can manage their own drops. # grant so the creator can manage their own drops.
auto_own: true auto_own: true
# Browse shows a drag-drop overlay here.
drop_target: true
received: received:
default_tool: archive default_tool: archive
# received/ is WORM — express as ACL elsewhere; the # received/ is WORM — express as ACL elsewhere; the
@ -80,6 +82,7 @@ paths:
# working/ auto-owns the first creator + the per-user homes # working/ auto-owns the first creator + the per-user homes
# below. # below.
auto_own: true auto_own: true
drop_target: true
paths: paths:
"*": # per-user home dir "*": # per-user home dir
default_tool: mdedit default_tool: mdedit
@ -90,10 +93,12 @@ paths:
# grants don't reach inside. The user can edit the file # grants don't reach inside. The user can edit the file
# to grant collaborators access. # to grant collaborators access.
auto_own_fenced: true auto_own_fenced: true
drop_target: true
staging: staging:
default_tool: transmittal default_tool: transmittal
available_tools: [transmittal, classifier] available_tools: [transmittal, classifier]
auto_own: true auto_own: true
drop_target: true
reviewing: reviewing:
default_tool: mdedit default_tool: mdedit
available_tools: [mdedit] available_tools: [mdedit]

View file

@ -201,6 +201,14 @@ type ZddcFile struct {
// cascade. // cascade.
Virtual *bool `yaml:"virtual,omitempty" json:"virtual,omitempty"` Virtual *bool `yaml:"virtual,omitempty" json:"virtual,omitempty"`
// DropTarget marks this directory as a destination for drag-drop
// uploads in the browse client. The directory listing's response
// header (X-ZDDC-Drop-Target) surfaces this to the SPA, which
// shows the drop-zone overlay only at scopes where the cascade
// permits uploads. Leaf-only — the property describes THIS dir,
// not its descendants. Defaults (nil): no drop zone.
DropTarget *bool `yaml:"drop_target,omitempty" json:"drop_target,omitempty"`
// AvailableTools restricts which tools the server will auto-serve // AvailableTools restricts which tools the server will auto-serve
// at this directory and its descendants. The effective list is the // at this directory and its descendants. The effective list is the
// concat-dedupe union of all AvailableTools across the cascade // concat-dedupe union of all AvailableTools across the cascade

View file

@ -68,6 +68,24 @@ func AutoOwnFencedAt(fsRoot, dirPath string) bool {
return false return false
} }
// DropTargetAt reports whether THIS specific directory accepts
// drag-drop uploads from a client (e.g. browse's drop-zone overlay).
// Leaf-only — the property describes a specific path, not a subtree.
func DropTargetAt(fsRoot, dirPath string) bool {
chain, err := EffectivePolicy(fsRoot, dirPath)
if err != nil {
return false
}
leaf := leafLevel(chain)
if leaf.DropTarget != nil {
return *leaf.DropTarget
}
if v := chain.Embedded.DropTarget; v != nil {
return *v
}
return false
}
// VirtualAt reports whether THIS specific directory is declared as // VirtualAt reports whether THIS specific directory is declared as
// purely virtual (never materialise on disk). Leaf-only: the virtual // purely virtual (never materialise on disk). Leaf-only: the virtual
// property describes a particular path, not a subtree. A child of a // property describes a particular path, not a subtree. A child of a
@ -201,7 +219,8 @@ func isZeroZddcFile(zf ZddcFile) bool {
if zf.DefaultTool != "" { if zf.DefaultTool != "" {
return false return false
} }
if zf.AutoOwn != nil || zf.AutoOwnFenced != nil || zf.Virtual != nil || zf.Inherit != nil { if zf.AutoOwn != nil || zf.AutoOwnFenced != nil || zf.Virtual != nil ||
zf.DropTarget != nil || zf.Inherit != nil {
return false return false
} }
if len(zf.AvailableTools) > 0 { if len(zf.AvailableTools) > 0 {

View file

@ -75,6 +75,9 @@ func mergeOverlay(base, top ZddcFile) ZddcFile {
if top.AutoOwnFenced != nil { if top.AutoOwnFenced != nil {
out.AutoOwnFenced = top.AutoOwnFenced out.AutoOwnFenced = top.AutoOwnFenced
} }
if top.DropTarget != nil {
out.DropTarget = top.DropTarget
}
if top.Virtual != nil { if top.Virtual != nil {
out.Virtual = top.Virtual out.Virtual = top.Virtual
} }