@bongos/core 1.19.534 → 1.19.536

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/.bongos-core.json +74 -39
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +8 -0
  4. package/clients/bongos-client/index.cjs +8 -0
  5. package/clients/bongos-client/index.d.ts +11 -0
  6. package/clients/bongos-client/index.mjs +8 -0
  7. package/docs/api/openapi.json +186 -3
  8. package/docs/api-reference.md +10 -2
  9. package/docs/copy-inventory.md +64 -43
  10. package/docs/copy-registry.json +293 -62
  11. package/docs/module-api-changelog.md +4 -0
  12. package/modules/dev-box/app/src/vendor/bongos-client.cjs +8 -0
  13. package/modules/government/catalog.js +1 -0
  14. package/modules/government/migrations/government_010_project_settings_manage.sql +58 -0
  15. package/modules/hall-ui/public/hall-kit.js +93 -7
  16. package/modules/hall-ui/public/project-settings.css +90 -0
  17. package/modules/hall-ui/public/project-settings.html +68 -0
  18. package/modules/hall-ui/public/project-settings.js +249 -0
  19. package/modules/hall-ui/public/roadmap-timeline.js +105 -0
  20. package/modules/hall-ui/public/roadmap.html +7 -1
  21. package/modules/hall-ui/public/roadmap.js +16 -0
  22. package/modules/hall-ui/public/shell.js +3 -0
  23. package/modules/hall-ui/public/style.css +4 -2
  24. package/modules/hall-ui/public/watch.html +4 -0
  25. package/modules/hall-ui/public/watch.js +116 -0
  26. package/modules/lifecycle/goal-category.js +82 -0
  27. package/modules/lifecycle/module.json +2 -1
  28. package/modules/lifecycle/routes/goals.js +16 -0
  29. package/modules/lifecycle/routes/project-settings.js +263 -0
  30. package/package-lock.json +2 -2
  31. package/package.json +1 -1
  32. package/scripts/hall-preview/refresh-fixtures.js +6 -0
  33. package/src/bongos/serve-internal.js +12 -1
  34. package/src/module-api.js +13 -1
  35. package/tests/hall_kit_charts.mjs +173 -0
  36. package/tests/hall_nav.mjs +3 -1
  37. package/tests/hall_oversight_world.mjs +159 -0
  38. package/tests/hall_page_gate_map.mjs +4 -0
  39. package/tests/hall_reading_world.mjs +153 -0
  40. package/tests/project_settings_page.mjs +337 -0
@@ -1517,5 +1517,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
1517
1517
  joinabilityMode did at 1.19.374.
1518
1518
  1.19.534 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1519
1519
  landed since 1.19.533 with no explicit bump. run 33885355024. (task 1002620)
1520
+ 1.19.535 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1521
+ landed since 1.19.534 with no explicit bump. run 33886686350. (task 1002620)
1522
+ 1.19.536 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1523
+ landed since 1.19.535 with no explicit bump. run 33888048777. (task 1002620)
1520
1524
  ---------------------------------------------------------------------------
1521
1525
  ```
@@ -633,6 +633,12 @@ function createClient(opts = {}) {
633
633
  // GET /profiles/{handle} — rank: public — GET /profiles/:handle
634
634
  getProfilesHandle: (args) => request("GET", "/profiles/{handle}", { hasBody: false }, args),
635
635
  },
636
+ "projectSettings": {
637
+ // GET /project-settings — rank: metic+archon — GET /project-settings
638
+ getProjectSettings: (args) => request("GET", "/project-settings", { hasBody: false }, args),
639
+ // PATCH /project-settings/rot-days — rank: metic+archon — PATCH /project-settings/rot-days
640
+ patchProjectSettingsRotDays: (args) => request("PATCH", "/project-settings/rot-days", { hasBody: true }, args),
641
+ },
636
642
  "projects": {
637
643
  // GET /projects — rank: metic+archon — GET /projects
638
644
  getProjects: (args) => request("GET", "/projects", { hasBody: false }, args),
@@ -894,6 +900,8 @@ function createClient(opts = {}) {
894
900
  getWorkCategories: (args) => request("GET", "/work-categories", { hasBody: false }, args),
895
901
  // POST /work-categories — rank: metic+archon — POST /work-categories
896
902
  postWorkCategories: (args) => request("POST", "/work-categories", { hasBody: true }, args),
903
+ // PATCH /work-categories/{id} — rank: metic+archon — PATCH /work-categories/:id
904
+ patchWorkCategoriesId: (args) => request("PATCH", "/work-categories/{id}", { hasBody: true }, args),
897
905
  },
898
906
  };
899
907
  }
@@ -200,6 +200,7 @@ const PERMISSIONS = [
200
200
  // personally opted in (task 1003177, ADR 0187 §5), so this atom cannot put
201
201
  // anyone in a room with a stranger on its own.
202
202
  { key: 'mingle.manage', system: false, floor: 'metic', guards: 'PUT /mingle/project (the project half of mingle consent), POST /mingle/round (run a pairing round) — task 1003177, goal 1000075' },
203
+ { key: 'project.settings.manage', system: false, floor: 'metic', guards: "PATCH /project-settings/* — the project's own live knobs (the rot timer today) and the work-category set's shape (PATCH /work-categories/:id). Metic, matching goal.create's reasoning that extending the project's vocabulary is a planning act rather than an administrative one — task 1003280, goal 1000072" },
203
204
  { key: 'project.curate', system: false, floor: 'metic', guards: 'platform-identity: GET/POST /projects, /projects/invite, PATCH /projects/:id/featured, GET /scouting (ADR 0157, was archon)' },
204
205
  { key: 'override_request.decide', system: false, floor: 'metic', guards: 'GET /override-requests, POST .../:id/decide (ADR 0157, was archon)' },
205
206
  { key: 'claim.release.any', system: false, floor: 'metic', scope: 'any', resource: 'claim', guards: 'POST /claims/:id/release-on-behalf (ADR 0157, was archon)' },
@@ -0,0 +1,58 @@
1
+ -- government_010_project_settings_manage.sql — grant the new `project.settings.manage`
2
+ -- atom to the Metic and Archon rank-roles (task 1003280, goal 1000072, criterion
3
+ -- legibility-project-settings).
4
+ --
5
+ -- The project-settings page carries the five fundamentals of a project, and TWO of
6
+ -- them are live knobs a person may turn: the rot timer (project_settings, migration
7
+ -- core_231) and the shape of the work-category set (work_categories, core_226). Both
8
+ -- writes need a gate, and BV1.R105 says a modules/*/routes gate is a permission atom
9
+ -- and never a rank — tests/government_require_permission.mjs fails the build on a new
10
+ -- `requireRank`.
11
+ --
12
+ -- FLOOR `metic`, by the `goal.create` analogue rather than archon. `goal.create`
13
+ -- already gates POST /work-categories on the reasoning that "extending the project's
14
+ -- vocabulary is a planning act", and this atom governs the same vocabulary's SHAPE
15
+ -- (label, order, whether it is still offered) plus one reporting timer. What makes
16
+ -- that safe is the ADR 0192 wall the category set already stands behind: a category
17
+ -- is ADVISORY — no gate, route guard, rank check, feed filter or dependency rule may
18
+ -- read it (scripts/gds/category-advisory-guard.js fails the build if that changes) —
19
+ -- so this atom cannot alter who may do what. The rot timer is the reporting half of
20
+ -- ADR 0226's asymmetry: it only ever ASKS a human, and the timer that ENFORCES
21
+ -- (src/stale-timer.js, which auto-releases claims) is deliberately NOT reachable
22
+ -- from here.
23
+ --
24
+ -- A BRAND-NEW key: no rank holds it yet, so BOTH rungs the floor implies are granted
25
+ -- here. RANK_SEED is a cumulative superset — floor `metic` means {metic, archon} —
26
+ -- and the drift guard (tests/government_seed.mjs) compares the UNION of the run-once
27
+ -- seed plus every later grant migration against that floor-derived seed, per rank.
28
+ -- Minting the catalog row WITHOUT this migration is exactly what that guard caught
29
+ -- on this task, as it did on task 1003177.
30
+ --
31
+ -- COPIES government_009_mingle_manage.sql, NOT a `governance_*` file. Those predate
32
+ -- government_001_rename_from_governance, which renamed `governance_role_permissions`
33
+ -- → `government_rank_permissions` and `role_key` → `rank_key` with no compatibility
34
+ -- shim, so copying one writes to a table that no longer exists and errors at apply
35
+ -- time on any fresh install.
36
+ --
37
+ -- Additive only: one new permission key granted to two ranks; nothing is revoked.
38
+ -- Idempotent (ON CONFLICT DO NOTHING); forward-safe (INSERT only).
39
+ --
40
+ -- One block PER RANK, in the government_004/009 shape: the drift guard scans for
41
+ -- `SELECT '<rank>', unnest(ARRAY[…])` literally, so a combined
42
+ -- `unnest(ARRAY['metic','archon']), 'key'` form parses as zero grants and reds it.
43
+
44
+ BEGIN;
45
+
46
+ INSERT INTO government_rank_permissions (rank_key, permission_key)
47
+ SELECT 'metic', unnest(ARRAY[
48
+ 'project.settings.manage'
49
+ ])
50
+ ON CONFLICT (rank_key, permission_key) DO NOTHING;
51
+
52
+ INSERT INTO government_rank_permissions (rank_key, permission_key)
53
+ SELECT 'archon', unnest(ARRAY[
54
+ 'project.settings.manage'
55
+ ])
56
+ ON CONFLICT (rank_key, permission_key) DO NOTHING;
57
+
58
+ COMMIT;
@@ -737,13 +737,25 @@
737
737
  + '</svg>';
738
738
  }
739
739
 
740
- // Horizontal labelled bars. rows: [{ label, value, valueLabel }].
740
+ // Horizontal labelled bars. rows: [{ label, value, start, valueLabel }].
741
741
  // opts: { max, tone: 'accent'|'neutral', labelWidth, valueWidth, barHeight,
742
742
  // pill, ariaLabel, list }
743
743
  //
744
744
  // `max` is the caller's when several series must share one scale; otherwise
745
745
  // the largest value here. It floors at 1 so an all-zero set draws empty
746
746
  // tracks instead of dividing by zero.
747
+ //
748
+ // A row's optional `start` OFFSETS its bar instead of beginning it at the
749
+ // track's left edge, which turns the same primitive into a SPAN series — a
750
+ // timeline. It arrived with the roadmap's versions (task 1003447): each one
751
+ // holds a `started_at` and a `shipped_at`, and the series those two dates
752
+ // support is "when did each version run", every bar on one shared axis. That
753
+ // is a bar with a left offset and nothing else, so it is an OPTION here
754
+ // rather than a second class family — the same seam every other difference
755
+ // between call sites rides. `start` is in the SAME units as `value` and
756
+ // measured from the axis origin, so the kit still never learns what a date
757
+ // is: a caller passes `max` as the axis span and `start` as the offset into
758
+ // it, exactly as it already passes x0/x1/yMax to the plot.
747
759
  function barSeriesHtml(rows, opts = {}) {
748
760
  const list = (rows || []).filter((r) => r && r.label !== undefined && r.label !== null);
749
761
  if (!list.length) return '';
@@ -775,14 +787,34 @@
775
787
  + (opts.ariaLabel ? ` aria-label="${esc(opts.ariaLabel)}"` : '')
776
788
  + '>'
777
789
  + list.map((r, i) => {
778
- const w = Math.round((vals[i] / max) * 100);
790
+ // A POSITIVE value always draws at least a sliver. Rounding alone sends
791
+ // anything under half a percent of the max to `width:0%`, which reads as
792
+ // "none" — the one thing it is not. Zero stays zero: an empty track is
793
+ // the honest picture of nothing, and the column series makes the
794
+ // opposite choice for the opposite reason (a zero bucket there is a week
795
+ // that happened). Load-bearing for a span: a version that started and
796
+ // shipped inside one day is a real span half a pixel wide.
797
+ const raw = Math.round((vals[i] / max) * 100);
798
+ const w = vals[i] > 0 ? Math.max(1, raw) : raw;
799
+ // The offset takes the SAME floor, and for the same reason: a span that
800
+ // begins just after the axis origin but rounds to 0% draws flush against
801
+ // the track edge, which is the picture of "started first" — and on a
802
+ // timeline that is a claim about ordering, not a rounding artefact.
803
+ const start = Number(r.start);
804
+ const rawOff = Number.isFinite(start) && start > 0 ? Math.round((start / max) * 100) : 0;
805
+ const off = Number.isFinite(start) && start > 0 ? Math.max(1, rawOff) : 0;
806
+ // Offset by MARGIN, not `left`: the fill is an ordinary block inside the
807
+ // track, so absolute positioning would need a positioned ancestor the
808
+ // track does not declare. Omitted entirely at zero, so every existing
809
+ // caller's markup is byte-identical to what it was.
810
+ const geom = off > 0 ? `margin-left:${off}%;width:${w}%` : `width:${w}%`;
779
811
  const val = r.valueLabel === undefined || r.valueLabel === null
780
812
  ? String(vals[i])
781
813
  : String(r.valueLabel);
782
814
  return `<div class="kit-bars__row"${rowRole}>`
783
815
  + `<span class="kit-bars__label">${esc(r.label)}</span>`
784
816
  + `<span class="kit-bars__track" aria-hidden="true">`
785
- + `<span class="kit-bars__fill" style="width:${w}%"></span></span>`
817
+ + `<span class="kit-bars__fill" style="${geom}"></span></span>`
786
818
  + `<span class="kit-bars__val">${esc(val)}</span>`
787
819
  + '</div>';
788
820
  }).join('')
@@ -818,10 +850,60 @@
818
850
  return { width, height, pad, base: height - pad.b };
819
851
  }
820
852
 
853
+ // The VERTICAL window a series should be drawn in — the companion to
854
+ // timeStepGeometry's yMin/yMax, and geometry rather than date semantics, which
855
+ // is why it lives here rather than at the call site (task 1003447).
856
+ //
857
+ // `zeroBased` (the default) is right whenever zero is a state the series can
858
+ // actually reach: a count of dead exports sitting at zero for a week is the
859
+ // reading, and lifting the floor off it would invent a change. Pass false for
860
+ // a LEVEL — repo-health's total_loc — where zero is unreachable and a zero
861
+ // floor squashes a month of real movement into a flat band across the top.
862
+ // A fitted floor magnifies every wiggle, so a caller that passes false owes
863
+ // the reader the range in words somewhere on the page.
864
+ //
865
+ // `air` is the fraction of the range left as breathing room (12% by default)
866
+ // so the line neither rides the top edge nor welds itself to the axis. A FLAT
867
+ // series has no range to take a fraction of, so the air falls back to the
868
+ // value's own magnitude — and, for a series flat at zero, to 1, which leaves
869
+ // it drawn honestly along the axis.
870
+ //
871
+ // A MISSING entry is dropped, not read as zero: a null from a snapshot that
872
+ // predates a column would otherwise drag `lo` to zero and draw a cliff nothing
873
+ // recorded. Note this is the one place in the kit that must NOT go through
874
+ // `chartNums` — that maps a null to 0 on purpose, because a gap-filled DAILY
875
+ // feed really does mean "nothing happened that day". Here a null means "not
876
+ // measured", and the two are opposite readings of the same byte. `Number(null)`
877
+ // and `Number('')` are both a finite 0, so the emptiness has to be tested for
878
+ // before the coercion, never after it.
879
+ function plotWindow(values, opts = {}) {
880
+ const nums = (values || [])
881
+ .filter((v) => v !== null && v !== undefined && v !== '')
882
+ .map(Number)
883
+ .filter((n) => Number.isFinite(n));
884
+ if (!nums.length) return { lo: 0, hi: 0, yMin: 0, yMax: 1, n: 0 };
885
+ const lo = Math.min(...nums);
886
+ const hi = Math.max(...nums);
887
+ const frac = Number.isFinite(Number(opts.air)) ? Number(opts.air) : 0.12;
888
+ const air = ((hi - lo) || Math.abs(hi) || 1) * frac;
889
+ const zeroBased = opts.zeroBased !== false;
890
+ return { lo, hi, yMin: zeroBased ? 0 : lo - air, yMax: hi + air, n: nums.length };
891
+ }
892
+
821
893
  // A STEP line over a time axis: the value holds until the next recorded
822
894
  // point, then rises — which is what a cumulative "how much by now" series
823
895
  // means, and why this is not a straight polyline between samples.
824
896
  // points: [{ x, value }] in data space, sorted. Returns { d, area, xs }.
897
+ //
898
+ // `yMin` (default 0) is the value the AXIS sits at, and it exists because the
899
+ // second kind of series this plot draws is not cumulative. A cumulative line
900
+ // starts at nothing and every reading is "how much by now", so zero is the
901
+ // only honest floor. A LEVEL — repo-health's total_loc, task 1003447 — is a
902
+ // large number that moves a little: on a zero floor its whole month of change
903
+ // is a flat band across the top, and the chart says nothing the headline
904
+ // figure did not. A caller that fits the floor to the data MUST say so on the
905
+ // page (watch.js puts the range in the figure's foot), because a non-zero
906
+ // baseline silently multiplies how big every wiggle looks.
825
907
  function timeStepGeometry(points, opts = {}) {
826
908
  const { width, height, pad, base } = plotBox(opts);
827
909
  const list = points || [];
@@ -829,11 +911,14 @@
829
911
  const x0 = Number(opts.x0 ?? list[0].x);
830
912
  const x1 = Number(opts.x1 ?? list[list.length - 1].x);
831
913
  const last = list[list.length - 1];
832
- // A span of 0 (one point, no window) would divide by zero; yMax 0 likewise.
914
+ // A span of 0 (one point, no window) would divide by zero; a flat series
915
+ // whose yMin and yMax are equal likewise.
833
916
  const spanX = (x1 - x0) || 1;
834
- const yMax = Number(opts.yMax ?? last.value) || 1;
917
+ const yMin = Number.isFinite(Number(opts.yMin)) ? Number(opts.yMin) : 0;
918
+ const yMax = Number(opts.yMax ?? last.value);
919
+ const spanY = ((Number.isFinite(yMax) ? yMax : 0) - yMin) || 1;
835
920
  const px = (t) => pad.l + ((t - x0) / spanX) * (width - pad.l - pad.r);
836
- const py = (v) => height - pad.b - (v / yMax) * (height - pad.t - pad.b);
921
+ const py = (v) => height - pad.b - ((v - yMin) / spanY) * (height - pad.t - pad.b);
837
922
  let d = `M${px(list[0].x).toFixed(1)},${py(list[0].value).toFixed(1)}`;
838
923
  list.forEach((p, i) => {
839
924
  if (i === 0) return;
@@ -961,7 +1046,8 @@
961
1046
  plotSvgHtml, plotValueLabelHtml, timeStepSeriesHtml, timeStepGeometry, columnSeriesHtml,
962
1047
  // plotBox is exported so a caller mapping pointer x back into viewBox
963
1048
  // space reads the plot's width instead of re-declaring the number.
964
- plotBox,
1049
+ // plotWindow fits the vertical axis to a level series (task 1003447).
1050
+ plotBox, plotWindow,
965
1051
  // states
966
1052
  emptyStateHtml, skeletonRows, collapsibleCard,
967
1053
  // ship-time task visual (task 1003109)
@@ -0,0 +1,90 @@
1
+ /* project-settings.css — the five-fundamentals page (task 1003280, goal 1000072).
2
+ The ROW is oversight.css's .ov-row and the panel is style.css's .scroll; what
3
+ lives here is only what those two do not already say: the state pill that marks a
4
+ row unwritable, the small control cluster a knob row carries, and the provenance
5
+ foot that names the file a value resolves from. */
6
+
7
+ /* The provenance line. Every row has one — it is the point of the page — so it is
8
+ quiet by default and never competes with the value above it. */
9
+ .ps-prov {
10
+ margin: 6px 0 0;
11
+ font-size: 12px;
12
+ line-height: 1.5;
13
+ color: var(--ink-soft);
14
+ }
15
+ .ps-prov code {
16
+ font-size: 11px;
17
+ overflow-wrap: anywhere; /* a table[key] path must wrap on a phone, never widen the page */
18
+ }
19
+
20
+ /* The state pill: what kind of value this is. A reader must be able to tell "you
21
+ cannot type here" from "this is empty" at a glance, which is why absent and
22
+ not-yet-real do not share a colour with live. */
23
+ .ps-pill {
24
+ display: inline-block;
25
+ padding: 1px 8px;
26
+ border-radius: 999px;
27
+ font-size: 11px;
28
+ font-weight: 600;
29
+ letter-spacing: 0.01em;
30
+ border: 1px solid currentColor;
31
+ vertical-align: middle;
32
+ }
33
+ .ps-pill--live { color: var(--ok); }
34
+ .ps-pill--readonly { color: var(--ink-soft); }
35
+ .ps-pill--absent { color: var(--warn); }
36
+
37
+ /* A knob's controls. Wraps rather than widening: the page never scrolls sideways
38
+ (hall-ui/CLAUDE.md), and this cluster is the only thing on the page wide enough
39
+ to threaten that at 390px. */
40
+ .ps-controls {
41
+ display: flex;
42
+ flex-wrap: wrap;
43
+ align-items: center;
44
+ gap: 8px;
45
+ margin-top: 10px;
46
+ }
47
+ .ps-controls input[type="number"] { width: 7em; }
48
+ .ps-controls input[type="text"] { min-width: 0; flex: 1 1 12em; }
49
+
50
+ /* The category list inside its row. A plain list, not a nested ledger: it is edited
51
+ in place and a table would put a horizontal scrollport inside a card that already
52
+ is one. */
53
+ .ps-cats { margin: 10px 0 0; padding: 0; list-style: none; }
54
+ .ps-cat {
55
+ display: flex;
56
+ flex-wrap: wrap;
57
+ align-items: center;
58
+ gap: 8px;
59
+ padding: 8px 0;
60
+ border-top: var(--line) solid var(--rule-soft);
61
+ }
62
+ .ps-cat--off { opacity: 0.6; }
63
+ .ps-cat__slug {
64
+ font-size: 11px;
65
+ color: var(--ink-soft);
66
+ flex: 0 0 auto;
67
+ }
68
+
69
+ /* The invalid-value note: a configured value the resolver could not use. It renders
70
+ on the screen of the person who can fix it rather than 500-ing the page — the
71
+ reporting half of ADR 0226's asymmetric fail posture. */
72
+ .ps-invalid {
73
+ margin: 8px 0 0;
74
+ padding: 8px 10px;
75
+ border-radius: 6px;
76
+ font-size: 12px;
77
+ color: var(--warn);
78
+ border: 1px solid currentColor;
79
+ }
80
+
81
+ /* The sibling note on the stale-timer row — the OTHER timer, the one that enforces.
82
+ Indented and quiet: it is context that stops a misreading, not a second setting. */
83
+ .ps-sibling {
84
+ margin: 10px 0 0;
85
+ padding-left: 10px;
86
+ border-left: var(--line) solid var(--rule);
87
+ font-size: 12px;
88
+ color: var(--ink-soft);
89
+ }
90
+ .ps-sibling strong { color: inherit; font-weight: 600; }
@@ -0,0 +1,68 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Project settings — {{worldName}}</title>
7
+ <meta name="description" content="The fundamentals of this project in one place — the stale timer, the category set, how credits are calculated, whether equity is real, and what share of upmarket revenue is paid on credits.">
8
+ <link rel="icon" href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><text x='16' y='24' font-size='24' text-anchor='middle' font-family='sans-serif'>⌂</text></svg>">
9
+ <link rel="preconnect" href="https://fonts.googleapis.com">
10
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
11
+ <link rel="stylesheet" href="/builders/style.css?v=2026-07-02-shell1">
12
+ <link rel="stylesheet" href="/builders/oversight.css">
13
+ <link rel="stylesheet" href="/builders/project-settings.css">
14
+ </head>
15
+ <body data-page="project-settings" data-page-title="Project settings">
16
+
17
+ <div class="app">
18
+ <aside class="app-sidebar" id="app-sidebar" aria-label="Primary"></aside>
19
+ <div class="app-scrim" id="app-scrim" hidden></div>
20
+ <div class="app-body">
21
+ <header class="app-topbar" id="app-topbar"></header>
22
+ <div class="app-content">
23
+ <!-- The LABEL voice (the page-head--board idiom, task 1003307): on a working
24
+ surface the page's own name is the least interesting thing on it. -->
25
+ <div class="page-head page-head--board">
26
+ <h1 class="page-head__h">Project settings</h1>
27
+ <p class="page-head__sub" id="ps-sub">Loading…</p>
28
+ </div>
29
+
30
+ <main id="hall-main">
31
+ <!-- The sealed state is a panel with a heading, not a bare line on the ground
32
+ (task 1003469). The page gate is the real wall; this is what a reader who
33
+ followed a link sees instead of an empty page. -->
34
+ <section class="scroll" id="ps-sealed" aria-labelledby="ps-sealed-h" hidden>
35
+ <h2 class="scroll__h" id="ps-sealed-h">Restricted</h2>
36
+ <p class="scroll__lede" id="ps-sealed-body">This page opens at a higher rank.</p>
37
+ </section>
38
+
39
+ <section class="scroll" id="ps-body" aria-labelledby="ps-body-h" hidden>
40
+ <h2 class="scroll__h" id="ps-body-h">The fundamentals</h2>
41
+ <p class="scroll__lede">
42
+ Five things every project settles. Two of them are knobs you can turn here;
43
+ the other three are decided in committed configuration, and each says which
44
+ file decides it.
45
+ </p>
46
+ <!-- One mount, five rows, written by project-settings.js. The rows are NOT
47
+ authored in this file: each carries a value plus the provenance the
48
+ server resolved it through, and markup that could drift from that is the
49
+ thing this page exists to end. -->
50
+ <div id="ps-rows"><span class="kit-skel"></span></div>
51
+ </section>
52
+ </main>
53
+
54
+ <footer class="app-foot" id="app-foot"></footer>
55
+ </div>
56
+ </div>
57
+ </div>
58
+
59
+ <div id="toast" class="toast" role="status" aria-live="polite" data-visible="0"></div>
60
+
61
+ <script src="/builders/shell.js?v=2026-07-02-shell1"></script>
62
+ <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
63
+ <script src="/builders/palette.js" defer></script>
64
+ <script src="/builders/dom-utils.js?v=2026-06-16-gate"></script>
65
+ <script src="/builders/hall-kit.js"></script>
66
+ <script src="/builders/project-settings.js?v=2026-09-04-ps1"></script>
67
+ </body>
68
+ </html>