feat(zddc): defaults — browse hosts the markdown editor for working/+reviewing/

Flip default_tool from `mdedit` to `browse` (which now ships a Toast UI
markdown editor plugin in its preview pane) at:

  • paths."*".paths.working
  • paths."*".paths.working.paths."*"   (per-user homes)
  • paths."*".paths.reviewing

available_tools at those levels drops `mdedit` and adds `browse` next
to `classifier`. Operator overrides per .zddc cascade still work; only
the embedded baseline changes.

Test fixtures updated:

  • lookups_test.go     — DefaultToolAt assertions for working/+reviewing/
  • availability_test.go — AppAvailableAt + DefaultAppAt for working/+
                           reviewing/+per-user home
  • main_test.go        — dispatch route asserts "ZDDC Browse" (was "ZDDC
                          Markdown"); Apps cascade fixture swaps mdedit
                          for browse so the live route fetches the right
                          embedded HTML
This commit is contained in:
ZDDC 2026-05-13 10:34:06 -05:00
parent b5aab81d31
commit 7fbe7867fd
4 changed files with 56 additions and 55 deletions

View file

@ -138,14 +138,14 @@ func TestDispatchAppsResolution(t *testing.T) {
"archive": upstream.URL + "/archive_stable.html", "archive": upstream.URL + "/archive_stable.html",
"transmittal": upstream.URL + "/transmittal_stable.html", "transmittal": upstream.URL + "/transmittal_stable.html",
"classifier": upstream.URL + "/classifier_stable.html", "classifier": upstream.URL + "/classifier_stable.html",
"mdedit": upstream.URL + "/mdedit_stable.html",
"landing": upstream.URL + "/landing_stable.html", "landing": upstream.URL + "/landing_stable.html",
"browse": upstream.URL + "/browse_stable.html",
}, },
} }
if err := zddc.WriteFile(root, zf); err != nil { if err := zddc.WriteFile(root, zf); err != nil {
t.Fatalf("WriteFile: %v", err) t.Fatalf("WriteFile: %v", err)
} }
// Create folder convention dirs so classifier/mdedit/transmittal // Create folder convention dirs so classifier/browse/transmittal
// availability rules pass for the test paths used below. // availability rules pass for the test paths used below.
mustMkdir(t, filepath.Join(root, "Project-A", "Working")) mustMkdir(t, filepath.Join(root, "Project-A", "Working"))
@ -380,10 +380,10 @@ func TestDispatchArchiveRedirect(t *testing.T) {
func TestDispatchSlashRouting(t *testing.T) { func TestDispatchSlashRouting(t *testing.T) {
// Convention: <dir>/ → browse (directory view, via DirTool which // Convention: <dir>/ → browse (directory view, via DirTool which
// defaults to browse); <dir> → the directory's default_tool ("the // defaults to browse); <dir> → the directory's default_tool ("the
// specialized app": mdedit under working/, transmittal under // specialized app": browse under working/+reviewing/, transmittal
// staging/, archive under archive/, tables under archive/<party>/mdl). // under staging/, archive under archive/, tables under
// Without a default_tool, no-slash falls through to the trailing- // archive/<party>/mdl). Without a default_tool, no-slash falls
// slash redirect (302). // through to the trailing-slash redirect (302).
// //
// The only trailing-slash redirect is for a directory that is the // The only trailing-slash redirect is for a directory that is the
// rows-dir of a table declared via a REAL on-disk parent .zddc // rows-dir of a table declared via a REAL on-disk parent .zddc
@ -429,7 +429,7 @@ func TestDispatchSlashRouting(t *testing.T) {
wantNoRedirect bool wantNoRedirect bool
wantLoc string // checked when wantStatus is a redirect wantLoc string // checked when wantStatus is a redirect
}{ }{
{"working no-slash → mdedit", "/Project/working", http.StatusOK, true, ""}, {"working no-slash → browse", "/Project/working", http.StatusOK, true, ""},
{"working slash → browse", "/Project/working/", http.StatusOK, true, ""}, {"working slash → browse", "/Project/working/", http.StatusOK, true, ""},
{"staging no-slash → transmittal", "/Project/staging", http.StatusOK, true, ""}, {"staging no-slash → transmittal", "/Project/staging", http.StatusOK, true, ""},
{"staging slash → browse", "/Project/staging/", http.StatusOK, true, ""}, {"staging slash → browse", "/Project/staging/", http.StatusOK, true, ""},
@ -525,21 +525,21 @@ func TestDispatchEmptyCanonicalProjectFolders(t *testing.T) {
} }
// No-trailing-slash form on a canonical folder → default app // No-trailing-slash form on a canonical folder → default app
// (mdedit for working/, transmittal for staging/, archive for // (browse for working/+reviewing/, transmittal for staging/,
// archive/). Mirror of the existing "no-slash → default app" // archive for archive/). Mirror of the existing "no-slash →
// behavior at the IsDir branch, extended to cover the case where // default app" behavior at the IsDir branch, extended to cover
// the folder doesn't exist on disk yet. // the case where the folder doesn't exist on disk yet.
noSlashDefaultApp := []struct { noSlashDefaultApp := []struct {
stage string stage string
expect string // substring that should appear in the response body expect string // substring that should appear in the response body
}{ }{
{"working", "ZDDC Markdown"}, {"working", "ZDDC Browse"},
{"staging", "ZDDC Transmittal"}, {"staging", "ZDDC Transmittal"},
{"archive", "ZDDC Archive"}, {"archive", "ZDDC Archive"},
// reviewing/ also routes to mdedit; the polyfill follows the // reviewing/ also routes to browse (markdown editor lives
// virtual aggregator's listing into canonical archive/+staging // inside it now); the polyfill follows the virtual aggregator's
// paths from there. // listing into canonical archive/+staging paths from there.
{"reviewing", "ZDDC Markdown"}, {"reviewing", "ZDDC Browse"},
} }
for _, tc := range noSlashDefaultApp { for _, tc := range noSlashDefaultApp {
t.Run("no-slash/"+tc.stage+" → default app", func(t *testing.T) { t.Run("no-slash/"+tc.stage+" → default app", func(t *testing.T) {

View file

@ -35,11 +35,12 @@ func TestAppAvailableAt(t *testing.T) {
{root + "/Project-A/archive/ACME/mdl", "classifier", false}, {root + "/Project-A/archive/ACME/mdl", "classifier", false},
{root + "/Project-A/some-other-folder", "classifier", false}, {root + "/Project-A/some-other-folder", "classifier", false},
// mdedit: working/ only (review responses live in working/<rs-name>/) // browse: universal — every directory has browse available
{root + "/Project-A/working", "mdedit", true}, // (it's in the embedded-defaults baseline available_tools).
{root + "/Project-A/working/sub", "mdedit", true}, {root + "/Project-A/working", "browse", true},
{root + "/Project-A/staging", "mdedit", false}, {root + "/Project-A/working/sub", "browse", true},
{root + "/Project-A/archive/ACME/incoming", "mdedit", false}, {root + "/Project-A/staging", "browse", true},
{root + "/Project-A/archive/ACME/incoming", "browse", true},
// transmittal: staging/ only // transmittal: staging/ only
{root + "/Project-A/staging", "transmittal", true}, {root + "/Project-A/staging", "transmittal", true},
@ -48,8 +49,6 @@ func TestAppAvailableAt(t *testing.T) {
{root + "/Project-A/archive/ACME/issued", "transmittal", false}, {root + "/Project-A/archive/ACME/issued", "transmittal", false},
// case-fold: any case of canonical names matches // case-fold: any case of canonical names matches
{root + "/Project-A/Working", "mdedit", true},
{root + "/Project-A/WORKING", "mdedit", true},
{root + "/Project-A/Staging", "transmittal", true}, {root + "/Project-A/Staging", "transmittal", true},
{root + "/Project-A/STAGING", "transmittal", true}, {root + "/Project-A/STAGING", "transmittal", true},
{root + "/Project-A/archive/ACME/Incoming", "classifier", true}, {root + "/Project-A/archive/ACME/Incoming", "classifier", true},
@ -81,9 +80,9 @@ func TestDefaultAppAt(t *testing.T) {
// no-slash falls through to the redirect. // no-slash falls through to the redirect.
{root + "/Project-A", ""}, {root + "/Project-A", ""},
// Canonical project-root folders. // Canonical project-root folders.
{root + "/Project-A/working", "mdedit"}, {root + "/Project-A/working", "browse"},
{root + "/Project-A/working/alice@example.com", "mdedit"}, {root + "/Project-A/working/alice@example.com", "browse"},
{root + "/Project-A/working/2026-06-15_x (DFT) - y", "mdedit"}, {root + "/Project-A/working/2026-06-15_x (DFT) - y", "browse"},
{root + "/Project-A/staging", "transmittal"}, {root + "/Project-A/staging", "transmittal"},
{root + "/Project-A/staging/2026-06-15_x (DFT) - y", "transmittal"}, {root + "/Project-A/staging/2026-06-15_x (DFT) - y", "transmittal"},
// archive: at the archive root, party folders default to archive. // archive: at the archive root, party folders default to archive.
@ -98,15 +97,16 @@ func TestDefaultAppAt(t *testing.T) {
// mdl wins over the broader archive rule. // mdl wins over the broader archive rule.
{root + "/Project-A/archive/Acme/mdl", "tables"}, {root + "/Project-A/archive/Acme/mdl", "tables"},
{root + "/Project-A/archive/Acme/mdl/anything-deeper", "tables"}, {root + "/Project-A/archive/Acme/mdl/anything-deeper", "tables"},
// reviewing/ is virtual but mdedit is wired as the default // reviewing/ is virtual; browse hosts the markdown editor that
// tool; the polyfill follows the listing's canonical URLs // renders responses (the polyfill follows the listing's
// into archive/ and staging/ for the actual files. // canonical URLs into archive/ and staging/ for the actual
{root + "/Project-A/reviewing", "mdedit"}, // files).
{root + "/Project-A/reviewing/123-EM-SUB-0001", "mdedit"}, {root + "/Project-A/reviewing", "browse"},
{root + "/Project-A/reviewing/123-EM-SUB-0001", "browse"},
// Random non-canonical folder names → no default. // Random non-canonical folder names → no default.
{root + "/Project-A/scratch", ""}, {root + "/Project-A/scratch", ""},
// Case-fold on canonical names. // Case-fold on canonical names.
{root + "/Project-A/Working", "mdedit"}, {root + "/Project-A/Working", "browse"},
{root + "/Project-A/STAGING", "transmittal"}, {root + "/Project-A/STAGING", "transmittal"},
{root + "/Project-A/Archive/Acme/MDL", "tables"}, {root + "/Project-A/Archive/Acme/MDL", "tables"},
} }

View file

@ -53,11 +53,12 @@ roles:
members: [] members: []
# Universal tool baseline. archive (record browser), browse (file # Universal tool baseline. archive (record browser), browse (file
# tree), and landing (project picker) work everywhere. Each canonical # tree, hosts the in-place markdown editor), and landing (project
# folder below adds its own context-specific tools (mdedit in # picker) work everywhere. Each canonical folder below adds its own
# working/, transmittal in staging/, etc.). The cascade unions # context-specific tools (transmittal in staging/, etc.). The cascade
# available_tools across all levels — leaf restrictions don't drop # unions available_tools across all levels — leaf restrictions don't
# ancestor entries — so this baseline propagates to every descendant. # drop ancestor entries — so this baseline propagates to every
# descendant.
available_tools: [archive, browse, landing] available_tools: [archive, browse, landing]
# ── The slash / no-slash routing convention ──────────────────────────────── # ── The slash / no-slash routing convention ────────────────────────────────
@ -71,9 +72,9 @@ available_tools: [archive, browse, landing]
# default; you rarely set it. # default; you rarely set it.
# <dir> (no slash) → `default_tool` — the "specialized # <dir> (no slash) → `default_tool` — the "specialized
# app" for this folder (e.g. archive, # app" for this folder (e.g. archive,
# transmittal, mdedit, tables). If a # transmittal, tables). If a folder
# folder declares no default_tool, the # declares no default_tool, the no-
# no-slash form just 302s to the slash # slash form just 302s to the slash
# form, so you land on `dir_tool`. # form, so you land on `dir_tool`.
# #
# JSON listing requests are unaffected by either key — they always get # JSON listing requests are unaffected by either key — they always get
@ -81,7 +82,7 @@ available_tools: [archive, browse, landing]
# can enumerate entries no matter what dir_tool/default_tool are. # can enumerate entries no matter what dir_tool/default_tool are.
# #
# Both keys cascade leaf→root: a parent's default_tool applies to # Both keys cascade leaf→root: a parent's default_tool applies to
# descendants unless a deeper level overrides it (mdedit set on # descendants unless a deeper level overrides it (browse set on
# working/ reaches working/alice/notes/ for free). The keys below set # working/ reaches working/alice/notes/ for free). The keys below set
# default_tool on the canonical folders; dir_tool is left unset # default_tool on the canonical folders; dir_tool is left unset
# everywhere, so the slash form is always `browse`. # everywhere, so the slash form is always `browse`.
@ -201,8 +202,8 @@ paths:
default_tool: archive default_tool: archive
worm: [document_controller] worm: [document_controller]
working: working:
default_tool: mdedit default_tool: browse
available_tools: [mdedit, classifier] available_tools: [browse, classifier]
# 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
@ -215,8 +216,8 @@ paths:
admins: [document_controller] admins: [document_controller]
paths: paths:
"*": # per-user home dir "*": # per-user home dir
default_tool: mdedit default_tool: browse
available_tools: [mdedit, classifier] available_tools: [browse, classifier]
auto_own: true auto_own: true
# Per-user home is private by default: the generated # Per-user home is private by default: the generated
# auto-own .zddc carries inherit:false so ancestor ACL # auto-own .zddc carries inherit:false so ancestor ACL
@ -233,8 +234,8 @@ paths:
# rationale as working/. # rationale as working/.
admins: [document_controller] admins: [document_controller]
reviewing: reviewing:
default_tool: mdedit default_tool: browse
available_tools: [mdedit] available_tools: [browse]
# reviewing/ is purely virtual — the aggregator handler # reviewing/ is purely virtual — the aggregator handler
# synthesises listings from received/ ↔ staging/ ↔ issued/. # synthesises listings from received/ ↔ staging/ ↔ issued/.
virtual: true virtual: true

View file

@ -21,10 +21,10 @@ func TestDefaultToolAt_FromEmbeddedConvention(t *testing.T) {
{filepath.Join(root, "Project-X", "archive", "Acme", "incoming"), "classifier"}, {filepath.Join(root, "Project-X", "archive", "Acme", "incoming"), "classifier"},
{filepath.Join(root, "Project-X", "archive", "Acme", "received"), "archive"}, {filepath.Join(root, "Project-X", "archive", "Acme", "received"), "archive"},
{filepath.Join(root, "Project-X", "archive", "Acme", "issued"), "archive"}, {filepath.Join(root, "Project-X", "archive", "Acme", "issued"), "archive"},
{filepath.Join(root, "Project-X", "working"), "mdedit"}, {filepath.Join(root, "Project-X", "working"), "browse"},
{filepath.Join(root, "Project-X", "working", "alice@example.com"), "mdedit"}, {filepath.Join(root, "Project-X", "working", "alice@example.com"), "browse"},
{filepath.Join(root, "Project-X", "staging"), "transmittal"}, {filepath.Join(root, "Project-X", "staging"), "transmittal"},
{filepath.Join(root, "Project-X", "reviewing"), "mdedit"}, {filepath.Join(root, "Project-X", "reviewing"), "browse"},
} }
for _, tc := range cases { for _, tc := range cases {
got := DefaultToolAt(root, tc.path) got := DefaultToolAt(root, tc.path)
@ -177,7 +177,7 @@ func TestOperatorOverride_DefaultsAreSurfaceable(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
// Operator declares that Special/working uses classifier // Operator declares that Special/working uses classifier
// instead of the embedded-default mdedit. // instead of the embedded-default browse.
writeZddc(t, filepath.Join(root, "Special", "working"), writeZddc(t, filepath.Join(root, "Special", "working"),
"default_tool: classifier\n") "default_tool: classifier\n")
@ -185,7 +185,7 @@ func TestOperatorOverride_DefaultsAreSurfaceable(t *testing.T) {
t.Errorf("operator override should set default_tool=classifier, got %q", got) t.Errorf("operator override should set default_tool=classifier, got %q", got)
} }
// Default still applies at other projects. // Default still applies at other projects.
if got := DefaultToolAt(root, filepath.Join(root, "Project-Y", "working")); got != "mdedit" { if got := DefaultToolAt(root, filepath.Join(root, "Project-Y", "working")); got != "browse" {
t.Errorf("default convention should hold at unchanged paths, got %q", got) t.Errorf("default convention should hold at unchanged paths, got %q", got)
} }
} }
@ -193,14 +193,14 @@ func TestOperatorOverride_DefaultsAreSurfaceable(t *testing.T) {
// TestDefaultToolAt_PropagatesToDescendants — once an ancestor sets // TestDefaultToolAt_PropagatesToDescendants — once an ancestor sets
// default_tool, descendants inherit it unless they override. So a // default_tool, descendants inherit it unless they override. So a
// path under working/ that isn't explicitly declared in paths: still // path under working/ that isn't explicitly declared in paths: still
// gets mdedit as its default tool. // gets browse as its default tool.
func TestDefaultToolAt_PropagatesToDescendants(t *testing.T) { func TestDefaultToolAt_PropagatesToDescendants(t *testing.T) {
resetCache() resetCache()
root := t.TempDir() root := t.TempDir()
// Deep path under working/ — not explicitly mentioned in paths:. // Deep path under working/ — not explicitly mentioned in paths:.
deep := filepath.Join(root, "Project-X", "working", "alice@example.com", "notes", "sub", "deep") deep := filepath.Join(root, "Project-X", "working", "alice@example.com", "notes", "sub", "deep")
if got := DefaultToolAt(root, deep); got != "mdedit" { if got := DefaultToolAt(root, deep); got != "browse" {
t.Errorf("DefaultToolAt(%q) = %q, want mdedit (cascade propagation)", t.Errorf("DefaultToolAt(%q) = %q, want browse (cascade propagation)",
deep[len(root):], got) deep[len(root):], got)
} }
} }