@bongos/core 1.19.584 → 1.19.586
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.
- package/.bongos-core.json +31 -16
- package/docs/adr/0261-a-preselect-always-carries-a-reason-the-bundle-summary-is-the-floor.md +78 -0
- package/docs/adr/README.md +1 -0
- package/docs/module-api-changelog.md +4 -0
- package/modules/public-landing/public/assets/cosmos.css +64 -2
- package/modules/public-landing/public/projects.html +108 -8
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/hub_map_orb_states.mjs +255 -0
- package/tests/projects_hub_module_picker.mjs +29 -11
- package/tests/wizard_preselect_why.mjs +137 -0
package/.bongos-core.json
CHANGED
|
@@ -2,22 +2,22 @@
|
|
|
2
2
|
"artifact": "bongos-core",
|
|
3
3
|
"manifest_schema": 1,
|
|
4
4
|
"generator": "scripts/gds/package-core.js",
|
|
5
|
-
"core_version": "1.19.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.19.586",
|
|
6
|
+
"core_contract": "1.19.586",
|
|
7
|
+
"source_commit": "ac6f287b895b1259c7b9fcc6b3a13e4fe178fbc0",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-
|
|
9
|
+
"built_at": "2026-09-07T22:34:04.491Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
|
-
"docs_redacted":
|
|
12
|
+
"docs_redacted": 454,
|
|
13
13
|
"agent_docs_stubbed": 24,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 2058,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 2536,
|
|
20
|
+
"tree_sha256": "7986b516ffada3b257352c5ecedda7fbc33f7e216f58f11fd2cd8edf23920cae",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/blocker-review/SKILL.md",
|
|
@@ -1819,10 +1819,15 @@
|
|
|
1819
1819
|
"mode": "0000644",
|
|
1820
1820
|
"sha256": "fa04f8d71fff8186ae975a949c045ca7481bd0f22f395cb8c7397bbe03ddd449"
|
|
1821
1821
|
},
|
|
1822
|
+
{
|
|
1823
|
+
"path": "docs/adr/0261-a-preselect-always-carries-a-reason-the-bundle-summary-is-the-floor.md",
|
|
1824
|
+
"mode": "0000644",
|
|
1825
|
+
"sha256": "04382d76f04c9317b565fb72951b36503e67acc95981a388d5c45efc80faaa26"
|
|
1826
|
+
},
|
|
1822
1827
|
{
|
|
1823
1828
|
"path": "docs/adr/README.md",
|
|
1824
1829
|
"mode": "0000644",
|
|
1825
|
-
"sha256": "
|
|
1830
|
+
"sha256": "6d2df0c7c0e6cfd3381935d14a703f8cfa7854d107a6add03bc743e631823ada"
|
|
1826
1831
|
},
|
|
1827
1832
|
{
|
|
1828
1833
|
"path": "docs/api-reference.md",
|
|
@@ -2707,7 +2712,7 @@
|
|
|
2707
2712
|
{
|
|
2708
2713
|
"path": "docs/module-api-changelog.md",
|
|
2709
2714
|
"mode": "0000644",
|
|
2710
|
-
"sha256": "
|
|
2715
|
+
"sha256": "3474ae19a143b381a4da0091daa41c301338dac6ffd97e7ef396079803589160"
|
|
2711
2716
|
},
|
|
2712
2717
|
{
|
|
2713
2718
|
"path": "docs/modules-contract.md",
|
|
@@ -6572,7 +6577,7 @@
|
|
|
6572
6577
|
{
|
|
6573
6578
|
"path": "modules/public-landing/public/assets/cosmos.css",
|
|
6574
6579
|
"mode": "0000644",
|
|
6575
|
-
"sha256": "
|
|
6580
|
+
"sha256": "17637a0fd4eafddcd3235dec51c0ff196a8b39360918ab7a4c0370fc21d3a20f"
|
|
6576
6581
|
},
|
|
6577
6582
|
{
|
|
6578
6583
|
"path": "modules/public-landing/public/assets/verro-medallion.png",
|
|
@@ -6832,7 +6837,7 @@
|
|
|
6832
6837
|
{
|
|
6833
6838
|
"path": "modules/public-landing/public/projects.html",
|
|
6834
6839
|
"mode": "0000644",
|
|
6835
|
-
"sha256": "
|
|
6840
|
+
"sha256": "eca7a7b580db8a794f5e0e17c8bfbb106529f14bf43c6e627d8f32e1dc9d6a53"
|
|
6836
6841
|
},
|
|
6837
6842
|
{
|
|
6838
6843
|
"path": "modules/public-landing/public/projects.probes.json",
|
|
@@ -7577,12 +7582,12 @@
|
|
|
7577
7582
|
{
|
|
7578
7583
|
"path": "package-lock.json",
|
|
7579
7584
|
"mode": "0000644",
|
|
7580
|
-
"sha256": "
|
|
7585
|
+
"sha256": "d60a7d4e330cfd6154a5dea888aa9062d7571dee2d62aafc73cadb19b4afa604"
|
|
7581
7586
|
},
|
|
7582
7587
|
{
|
|
7583
7588
|
"path": "package.json",
|
|
7584
7589
|
"mode": "0000644",
|
|
7585
|
-
"sha256": "
|
|
7590
|
+
"sha256": "14e7286fe47165dabdc74cb24b6bb5afdc1e7b949ae612b2f72c7a56107a246b"
|
|
7586
7591
|
},
|
|
7587
7592
|
{
|
|
7588
7593
|
"path": "public-docs/index.html",
|
|
@@ -9292,7 +9297,7 @@
|
|
|
9292
9297
|
{
|
|
9293
9298
|
"path": "src/module-api.js",
|
|
9294
9299
|
"mode": "0000644",
|
|
9295
|
-
"sha256": "
|
|
9300
|
+
"sha256": "b3b115c1a4652247daebb85a6c393400361c2269cb047e691121193c45a63648"
|
|
9296
9301
|
},
|
|
9297
9302
|
{
|
|
9298
9303
|
"path": "src/module-loader/catalog.js",
|
|
@@ -10914,6 +10919,11 @@
|
|
|
10914
10919
|
"mode": "0000644",
|
|
10915
10920
|
"sha256": "7d384cd84cdc93b8e7b57df291a4600a40f43346fd2a52168324ecde219f1388"
|
|
10916
10921
|
},
|
|
10922
|
+
{
|
|
10923
|
+
"path": "tests/hub_map_orb_states.mjs",
|
|
10924
|
+
"mode": "0000644",
|
|
10925
|
+
"sha256": "cdbee4e7f252f01e4a72feb1b63a5e340d1110c3f7a88aa6db77da95bc1268cb"
|
|
10926
|
+
},
|
|
10917
10927
|
{
|
|
10918
10928
|
"path": "tests/hub_profile_leak.mjs",
|
|
10919
10929
|
"mode": "0000644",
|
|
@@ -11697,7 +11707,7 @@
|
|
|
11697
11707
|
{
|
|
11698
11708
|
"path": "tests/projects_hub_module_picker.mjs",
|
|
11699
11709
|
"mode": "0000644",
|
|
11700
|
-
"sha256": "
|
|
11710
|
+
"sha256": "bae4d97b96d9aa747e70ee3a62f40bb7ef16c3702f4c2be362b12fe814a60aa6"
|
|
11701
11711
|
},
|
|
11702
11712
|
{
|
|
11703
11713
|
"path": "tests/projects_hub_pre_uat.mjs",
|
|
@@ -12649,6 +12659,11 @@
|
|
|
12649
12659
|
"mode": "0000644",
|
|
12650
12660
|
"sha256": "74ffbc7e4585e651d00ab879ece6f3d23089d7c9ae7acb15483d3752b47bbdc0"
|
|
12651
12661
|
},
|
|
12662
|
+
{
|
|
12663
|
+
"path": "tests/wizard_preselect_why.mjs",
|
|
12664
|
+
"mode": "0000644",
|
|
12665
|
+
"sha256": "2dc90311d8a4f31b543ed7ef711b4d3fc8b387a241bf0bbe797ab360107eac38"
|
|
12666
|
+
},
|
|
12652
12667
|
{
|
|
12653
12668
|
"path": "tests/workflow_exec_paths.mjs",
|
|
12654
12669
|
"mode": "0000644",
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# ADR 0261 — A preselect always carries a reason; the bundle's summary is the floor
|
|
2
|
+
|
|
3
|
+
**Status:** Accepted · 2026-09-07 · task 1003684
|
|
4
|
+
**Supersedes nothing. Resolves a tension between [ADR 0237](<redacted>.md) and [ADR 0243](<redacted>.md).**
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
Two rules were each right on their own and wrong together.
|
|
9
|
+
|
|
10
|
+
**ADR 0243** made `bundle.adjustments` a **delta**: a team-shape rule that fires without moving
|
|
11
|
+
anything claims nothing. That is correct — a sentence explaining a change that did not happen is
|
|
12
|
+
a claim the owner cannot check, and the module contract states it plainly ("a rule that changed
|
|
13
|
+
nothing claims nothing").
|
|
14
|
+
|
|
15
|
+
**ADR 0237** says a preselect the owner cannot see a reason for is one they have to audit, which
|
|
16
|
+
is worse than no preselect at all. The wizard's own paint site quotes this.
|
|
17
|
+
|
|
18
|
+
The two collide whenever a type's bundle **already contains** what a rule would add. The live
|
|
19
|
+
case: `game` is `['dev-box','discord']` and the `small-team` rule adds `dev-box`. The rule fires,
|
|
20
|
+
moves nothing, and correctly reports `adjustments: []`. Confirmed against live core 1.19.580:
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
GET /provisioning/starter-bundles → bundles[type=game].byTeamShape['small-team']
|
|
24
|
+
{"bundle":["dev-box","discord"],"adjustments":[]}
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
The owner then reached step 4 of the create wizard and saw **two extras switched on and no reason
|
|
28
|
+
beside them** — `#modWhy` rendered as an empty, hidden div. Exactly the state ADR 0237 exists to
|
|
29
|
+
prevent, produced by ADR 0243 behaving correctly.
|
|
30
|
+
|
|
31
|
+
This was originally filed as a bug against the recommendation engine (a "dropped adjustment").
|
|
32
|
+
It was not: the engine is right. The gap is that the panel had only one voice — the rule's — and
|
|
33
|
+
fell silent when the rule had nothing to say.
|
|
34
|
+
|
|
35
|
+
## Decision
|
|
36
|
+
|
|
37
|
+
**The why-line has two voices, and the type's is the floor.**
|
|
38
|
+
|
|
39
|
+
1. When one or more rules **moved** something, their sentences are shown, unchanged. A real
|
|
40
|
+
adjustment still speaks for itself and is never displaced.
|
|
41
|
+
2. When no rule moved anything, the **type's own curated `summary`** explains the preselect.
|
|
42
|
+
3. When the owner has answered the picker for themselves (`state.modules !== null`), the panel
|
|
43
|
+
says nothing at all — unchanged. Handing back a reason for a toggle they just flipped reads
|
|
44
|
+
as the panel arguing with them.
|
|
45
|
+
4. A bundle carrying no summary still says nothing. The floor is a floor, not an invention.
|
|
46
|
+
|
|
47
|
+
The summary was already authored for this job (`STARTER_BUNDLES[type].summary` in
|
|
48
|
+
`modules/provisioning/starter-bundles.js`), already served on every row of
|
|
49
|
+
`GET /provisioning/starter-bundles`, and — until this change — **rendered nowhere in the
|
|
50
|
+
product**.
|
|
51
|
+
|
|
52
|
+
## Consequences
|
|
53
|
+
|
|
54
|
+
- No new copy, no new endpoint, no new field. The change is which existing sentence is rendered
|
|
55
|
+
when the delta is empty.
|
|
56
|
+
- `adjustments` keeps its meaning exactly. Nothing about ADR 0243 is weakened: the delta is still
|
|
57
|
+
a delta, and no rule is credited with a change it did not make. What changed is that the
|
|
58
|
+
panel's silence is no longer the *only* alternative to a rule sentence.
|
|
59
|
+
- The pin `tests/projects_hub_module_picker.mjs` had a fixture with **no `summary` field**, which
|
|
60
|
+
made four "no adjustment, no sentence" cases pass for the wrong reason. The fixture now carries
|
|
61
|
+
the real summaries and those cases assert the new contract — including, explicitly, that no
|
|
62
|
+
*rule* sentence is invented for a change that did not happen.
|
|
63
|
+
- `tests/wizard_preselect_why.mjs` pins the resolver directly across every combination.
|
|
64
|
+
|
|
65
|
+
## Alternatives rejected
|
|
66
|
+
|
|
67
|
+
**Make the rule claim a no-op change.** Report `a-team-shares-one-environment` as an adjustment
|
|
68
|
+
even when `dev-box` was already in the bundle. Rejected: it re-introduces exactly the unverifiable
|
|
69
|
+
claim ADR 0243 removed, and the owner would read "a small team gets one shared environment" beside
|
|
70
|
+
a module that was on before they answered.
|
|
71
|
+
|
|
72
|
+
**Widen the bundles so no rule is ever redundant.** Rejected: it contorts curated per-type presets
|
|
73
|
+
to serve an unrelated rendering concern, and the redundancy would return the moment a rule or a
|
|
74
|
+
bundle changed.
|
|
75
|
+
|
|
76
|
+
**Leave it silent.** Rejected by the owner on 2026-09-07 after the trade-off was put to them
|
|
77
|
+
directly. The preselect is the wizard's most consequential default, and shipping it unexplained
|
|
78
|
+
for the most common answer combination is the failure ADR 0237 names.
|
package/docs/adr/README.md
CHANGED
|
@@ -352,3 +352,4 @@ This keeps the decision history honest and traceable.
|
|
|
352
352
|
| 0258 | [**The public CLI is a generated client-only package, and its file list is proven by running it** ([task 1003679](https://cloudbongos.com/builders#/task/1003679) · goal 1000054 — *A newcomer can build without the UI*). The core ships private as `@bongos/core` ([ADR 0108](<redacted>.md)), so a newcomer with no credential can install NOTHING and the web hall is the only way in — the owner's words: "how am I supposed to easily take on tasks as a new builder? I should be able to do everything without the UI." Open task 1002025 proposed publishing the core, which ships the SERVER and bypasses [ADR 0099](<redacted>.md)'s redaction pipeline (dormant until ~2026-11). **Decision: generate a separate public `@cloudbongos/cli` from the core and do not publish the core.** Four load-bearing parts. (1) SCOPED NAME, because npm shares one namespace between org names and unscoped packages — the bare `cloudbongos` is unpublishable *precisely because the owner owns that org*, which npm reports as "invalid" and reads like the name is taken; and a package name cannot be created on the npm website at all (it exists on first publish, which is why "Add Existing Package" answered `Forbidden`). (2) THE FILE LIST IS DECLARED AND PROVEN BY BEHAVIOUR, never computed — a static require-closure CANNOT answer this, because `src/module-api.js` is the doorway a module may only import ([ADR 0083](<redacted>.md)) and it *names* every kernel capability, so a static walk sees 33 `src/` files from `start.js` alone where runtime resolves **one** (`api-prefix.js`, which imports nothing) — the same trap as task 1003677's unsatisfiable done-when. So the test packs the tarball, installs it into an empty dir with NO repo, and runs every verb: it may fail for want of a session, never for a missing file. That caught two real holes on its first strengthened run — `clients/bongos-client/index.mjs`, reached by a dynamic `import()` no `require()` walk can see, and a hand-listed `files` array that dropped `clients/` and produced a tarball which installed cleanly then died on first use; both are now build-time refusals, and the `files` array is derived from the manifest. (3) `claim`/`ship`/`dev`/`serve`/`module`/`upgrade`/`onboard`/`doctor`/`exec`/`package-core` are ABSENT and the CLI says WHY plus the next step (`bongos shell` → a cloud box with the full CLI) — a bare `unknown command` teaches nothing, which is the exact failure being fixed. The supported journey is `login` → `start` → `shell`. (4) A REDACTION GATE fails the build closed on non-loopback IPv4 (RFC 5737 doc ranges exempt), token/key shapes, and every domain + owner login readable from the instance's own `config/branding.json` — needles come from host config so the core carries no instance identity ([ADR 0062 §7](<redacted>.md)); `cloudbongos.com` is allowlisted as public by design. No `repository` field while the core repo is private (it would 404 for every user and publish the owner's login for nothing). 31 files, one dependency (`undici`), version independent of the core's ([ADR 0161](<redacted>.md)). `@bongos/client` is VENDORED, not depended on — a public package depending on a private one is uninstallable. **The owner runs `npm publish`; a builder must not.** Task 1002025 is superseded. Rejected: publishing the core; deriving from a static closure (structurally impossible past the doorway); waiting for the mirror (dormant, and this is a build product not a source release); an unscoped name; depending on `@bongos/client`; a degraded `claim`; shipping all of `src/` to make lazy getters safe.](<redacted>.md) | cli / distribution / public surface |
|
|
353
353
|
| 0259 | [**A project’s departure from the public list is public, and the copy says so** ([task 1003672](https://cloudbongos.com/builders#/task/1003672) · goal 1000046 — *Project creation*). Decides the leak [ADR 0255](<redacted>.md) §6.2 deliberately filed rather than implied fixed. The public projects feed publishes a named list plus a count of anonymous rows, so when a project flips to stealth its named row LEAVES the identified block while the count RISES BY ONE — *“Mercury is gone, and there is one more black hole”* names it, and the moment, from a single diff of two snapshots. **Everything [ADR 0182](<redacted>.md) D2a does is defeated by it**, because the identity was never carried ON the anonymous row (`STEALTH_ANONYMOUS_KEEPS` is an allow-list precisely so a column added next year is anonymous by default) — it is carried by the ABSENCE of the named row that used to sit beside it. **Larger than the channel 0255 closed**, which needed repeated sampling and correlation; this one is exact and needs two reads. No `ORDER BY` change touches it: it is inherent in publishing a named list at all, so every fix is a product decision with a visible cost and the owner’s call, not a builder’s. **Decision: accept it, and make the copy say so.** Two reasons it is the right end of the trade and not merely the cheapest — (1) it is ALREADY the honest reading of the existing promise, since D2a made existence and count public *by design*, and a count that is public and truthful is a count whose CHANGES are public; (2) both alternatives damage the control they defend. A **delay window** means the owner clicks the privacy setting and is not private yet *without knowing it*, converting an information leak into a false belief — strictly worse, and the wrong direction for a privacy control. **Decoys** spend the count’s truthfulness (it is truthful today) on a guarantee that is still only probabilistic, and a decoy scheme needs some way to tell padding from real rows — the exact stable per-project key D2a forbids. The promise stealth actually keeps is about the project’s CONTENTS, not the TRANSITION: an anonymous row stays anonymous (no name, tagline, art, origin, or key surviving to the next snapshot); what is not private is stepping off a published list readers may keep copies of. So the copy — which read *“its name, and everything else about it, stay yours”*, every clause true and the paragraph as a whole overselling, because a reader takes it to cover the switch — now names the boundary in BOTH places an owner meets the setting (the choice’s blurb and the what-changes fold), in the plain register 0182’s interview fixed for this card. **A privacy control that oversells is worse than one that admits a limit**, the same standard 0255 §6 set for itself. **No behaviour changes** in `projects-feed.js` — the feed, the redaction allow-list and the keyed rotating permutation are decided FINISHED, not insufficient. `tests/projects_hub.mjs` pins the sentence in both surfaces (the copy IS the fix, so a future edit tightening the blurb must not quietly drop it) and re-applies the no-space-metaphor ban to it. Carries 0255 §6.3’s smaller residual so both live in one place: within one 24h epoch the permutation is stable, so same-day snapshots tell black holes apart (not name them) — accepted on the same reasoning, since the fix is a per-request permutation, which breaks pagination exactly as `ORDER BY random()` was rejected for. Guards recorded for any future attempt: no stable per-project key on an anonymous row, and no anonymous ordering an observer can predict or correlate. Rejected: the delay; decoys; dropping the named list (protects a privacy nobody asked for at the cost of the feature); and saying nothing, which is the failure mode this closes.](<redacted>.md) | platform-identity / privacy boundary / copy honesty |
|
|
354
354
|
| 0260 | [**An application IS the consent, and the hub’s own echo is the gate** ([task 1002972](https://cloudbongos.com/builders#/task/1002972) · goal 1000045, criterion C4). Privacy spec **D7** asks that a reviewer see what the applicant’s OWN profile rules would already show, evaluated LIVE at review time. The rule is ONE port composing the two EXISTING views — `getPublicProfileExtras` for a public account, `recruiter-sliver`’s own `sliverShapeFor` for a private one — because a third five-key lookalike **is** the new disclosure class D7 forbids. **The one deliberate difference from the D3 sliver:** `getRecruiterSliver` floors on recruiting REACH, and reusing it verbatim would be wrong in the direction that looks safe — `recruiter_discoverable` defaults to FALSE for a private account, so a private builder who deliberately applied would show their reviewer NOTHING and C4 would be satisfied by an empty box. D7 settles it: *“applying is an explicit act”*. The swap relaxes REACH only; `accountActiveSql` (active **and** terms accepted) and `hide_stats` still floor it, and the proof asserts both directions on the same account so the tempting refactor cannot pass quietly. **The federated route’s gate is the whole security story** ([ADR 0205](<redacted>.md) / security report 1000027): client credentials name a PROJECT and never a person, so `POST /sso/applicant-profile` authenticated alone is a bulk disclosure oracle over every account on the platform, private ones included. `applicationBacksProfileRead` demands a HUB-WRITTEN echo for exactly (this account, this client) inside the same 30-day window — a row only the hub’s own join relay creates — refusing **404, never 403**, and BEFORE any account is read (asserted on the statement log, because a gate that refuses after reading has already done the disclosure work). LIVE means nothing is stored at either end; the queue keys on the VOUCH and never on `github_login` (the one PUBLIC unauthenticated write makes an unvouched login an impersonation surface); and the hub client secret stays in core behind a narrow doorway port. **The path is complete but DORMANT until R09 ([task 1002285](https://cloudbongos.com/builders#/task/1002285)) ships the vouch writer** — nothing writes `applicant_github_id` today, and that dependency was missing from 1002972’s graph. Rejected: routing the port through `getRecruiterSliver`, a snapshot at apply time, resolving by `github_login`, and putting `loadIdpConfig` on the doorway.](<redacted>.md) | platform identity / privacy |
|
|
355
|
+
| 0261 | [**A preselect always carries a reason; the bundle’s summary is the floor** ([task 1003684](https://cloudbongos.com/builders#/task/1003684) · goal 1000046 — *Project creation*). Resolves a collision between two rules that were each right alone. [ADR 0243](<redacted>.md) made `adjustments` a **delta** — a team-shape rule that fires without moving anything claims nothing, because a sentence explaining a change that did not happen is a claim the owner cannot check. [ADR 0237](<redacted>.md) says a preselect the owner cannot see a reason for is one they must audit, which is worse than no preselect at all. The two collide whenever a type’s bundle **already contains** what a rule would add: `game` is `[dev-box, discord]` and the `small-team` rule adds `dev-box`, so the rule fires, moves nothing, and correctly reports `adjustments: []`. Confirmed on live core 1.19.580 — `byTeamShape[small-team]` = `{"bundle":["dev-box","discord"],"adjustments":[]}`. The owner then reached step 4 and saw **two extras switched on with no reason beside them**, `#modWhy` an empty hidden div: exactly what 0237 exists to prevent, produced by 0243 behaving correctly. Filed as a dropped adjustment; it was not — the engine is right, and the panel simply had ONE voice and fell silent when the rule had nothing to say. **Decision: the why-line has two voices and the type’s is the floor.** A real adjustment still speaks for itself and is never displaced; when none moved, the type’s own curated `summary` explains the preselect; when the owner has answered the picker themselves the panel stays silent (unchanged — a reason handed back for a toggle they just flipped reads as the panel arguing with them); a bundle with no summary still says nothing, because a floor is not an invention. **No new copy, endpoint or field** — the summary was authored for this job in `starter-bundles.js`, served on every row of `GET /provisioning/starter-bundles`, and rendered NOWHERE in the product until now. 0243 is not weakened: the delta stays a delta and no rule is credited with a change it did not make. The pin `tests/projects_hub_module_picker.mjs` carried a fixture with **no `summary` field**, which made four “no adjustment, no sentence” cases pass for the wrong reason; the fixture now carries the real summaries and those cases assert the new contract, including explicitly that no RULE sentence is invented for a change that did not happen. Rejected: making the rule claim a no-op change (re-introduces the unverifiable claim 0243 removed); widening the bundles so no rule is ever redundant (contorts curated presets for a rendering concern, and the redundancy returns on the next edit); and leaving it silent, declined by the owner once the trade-off was put to them directly.](<redacted>.md) | provisioning / starter bundles / owner-facing copy |
|
|
@@ -1617,5 +1617,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
1617
1617
|
landed since 1.19.582 with no explicit bump. run 34162998083. (task 1002620)
|
|
1618
1618
|
1.19.584 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1619
1619
|
landed since 1.19.583 with no explicit bump. run 34164936809. (task 1002620)
|
|
1620
|
+
1.19.585 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1621
|
+
landed since 1.19.584 with no explicit bump. run 34165392436. (task 1002620)
|
|
1622
|
+
1.19.586 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1623
|
+
landed since 1.19.585 with no explicit bump. run 34167072966. (task 1002620)
|
|
1620
1624
|
---------------------------------------------------------------------------
|
|
1621
1625
|
```
|
|
@@ -50,6 +50,10 @@
|
|
|
50
50
|
|
|
51
51
|
/* the gate's locked space palette (dark mode's sky) */
|
|
52
52
|
--space-deep:#05060f; --space-mid:#0a0c20; --space-high:#0d0f28; --nebula:#2b2456;
|
|
53
|
+
/* the black hole's interior (task 1002328) — the one colour that is NOT
|
|
54
|
+
theme-relative: a black hole is the absence of a surface in either sky, so it
|
|
55
|
+
is declared once here and never restated dark. Explore's own --orb-void. */
|
|
56
|
+
--orb-void:#0b0b10;
|
|
53
57
|
--sun-lit:#fb923c; --gold:#ffd98a; --gold-soft:#f6c976;
|
|
54
58
|
|
|
55
59
|
/* semantic state tier — each is painted as 12px/700 chip TEXT on its own
|
|
@@ -822,6 +826,60 @@ p.sec-p{font-size:var(--fs-lede);font-weight:500;color:var(--ink-soft);line-heig
|
|
|
822
826
|
.worldOrb[data-material="obsidian"][data-shape="blob"]{--tpl-src:var(--plate-obsidian-blob-cut);--tpl-ar:.794;--tpl-zoom:1.55448;--tpl-px:-.2697;--tpl-py:-.15965;}
|
|
823
827
|
.worldOrb[data-material="obsidian"][data-shape="pair"]{--tpl-src:var(--plate-obsidian-pair-cut);--tpl-ar:2.23705;--tpl-zoom:1.0248;--tpl-px:-.01015;--tpl-py:-.30191;}
|
|
824
828
|
.worldOrb[data-material="obsidian"][data-shape="bead"]{--tpl-src:var(--plate-obsidian-bead-cut);--tpl-ar:1.0027;--tpl-zoom:1.34698;--tpl-px:-.17053;--tpl-py:-.17255;}
|
|
829
|
+
/* ── THE VISIBILITY RENDER-TYPE (data-orb), a SECOND axis over the planet above
|
|
830
|
+
(BV1.R09, task 1002328; ADR 0182 D1, criterion proj-visibility-states). The
|
|
831
|
+
planet is what a project LOOKS like — the owner's chosen shape and material.
|
|
832
|
+
This is what its door SAYS, derived server-side and read off the feed. The
|
|
833
|
+
three readings are the Explore page's (task 1002326, modules/status-ui/public/
|
|
834
|
+
explore.html), so the two in-repo surfaces that draw this feed agree.
|
|
835
|
+
|
|
836
|
+
PUBLIC is the baseline and takes no rule: the planet as drawn is the public
|
|
837
|
+
planet. Only the two states that differ from it declare anything here. ── */
|
|
838
|
+
|
|
839
|
+
/* PRIVATE — a star: visible from anywhere, with no surface to land on. The object
|
|
840
|
+
keeps its own artwork and its own name (D1: private is on the map UNDER its
|
|
841
|
+
name; what it withholds is the door, not the identity) and gains a star's light.
|
|
842
|
+
A drop-shadow on the disc's own filter, the .worldOrb.match idiom below: the
|
|
843
|
+
object is a MASK, so an outline or a box-shadow would be clipped away. Composed
|
|
844
|
+
after the contact shadow, which dark states as a no-op filter for this reason.
|
|
845
|
+
|
|
846
|
+
Carried as a VARIABLE rather than written into the filter directly, because the
|
|
847
|
+
search spotlight below sets `filter` on the same element and is later in the
|
|
848
|
+
cascade: written inline it would be clobbered, and a private project would
|
|
849
|
+
quietly draw as a planet for as long as a query matched it. */
|
|
850
|
+
.worldOrb[data-orb="star"]{
|
|
851
|
+
--star-light:drop-shadow(0 0 7px color-mix(in srgb,var(--acc,var(--accent)) 75%,transparent))
|
|
852
|
+
drop-shadow(0 0 20px color-mix(in srgb,var(--acc,var(--accent)) 40%,transparent));}
|
|
853
|
+
.worldOrb[data-orb="star"] .disc{filter:var(--orb-shadow) var(--star-light);}
|
|
854
|
+
/* the corona sits BEHIND the object (the relationship ring's grammar, z-index:-1),
|
|
855
|
+
so the star reads as a point of light the object is standing in */
|
|
856
|
+
.worldOrb[data-orb="star"] .disc::after{content:"";position:absolute;
|
|
857
|
+
left:50%;top:50%;width:calc(100% + 34px);aspect-ratio:1;translate:-50% -50%;border-radius:50%;z-index:-1;
|
|
858
|
+
background:radial-gradient(circle,color-mix(in srgb,var(--acc,var(--accent)) 26%,transparent) 0 34%,transparent 70%);}
|
|
859
|
+
/* the ring wins the ::after when the viewer owns or builds on the project — the
|
|
860
|
+
relationship is the more useful thing to say, and the glow above still reads */
|
|
861
|
+
.worldOrb[data-orb="star"][data-rel="owner"] .disc::after,
|
|
862
|
+
.worldOrb[data-orb="star"][data-rel="builder"] .disc::after{background:none;}
|
|
863
|
+
|
|
864
|
+
/* STEALTH — a black hole: existence without identity. The object is REPLACED, not
|
|
865
|
+
decorated: ::before is where the planet plate is painted, so switching it off is
|
|
866
|
+
what makes this the one orb on the map carrying no artwork. Nothing here reads a
|
|
867
|
+
per-project value — every black hole is drawn identically, on the page's own
|
|
868
|
+
accent, because interchangeable is the whole point of the metaphor (D2a). With
|
|
869
|
+
the mask gone a box-shadow is no longer clipped, so the ring is a real ring. */
|
|
870
|
+
.worldOrb[data-orb="black-hole"]{cursor:default;}
|
|
871
|
+
.worldOrb[data-orb="black-hole"] .disc{border-radius:50%;background:var(--orb-void);
|
|
872
|
+
box-shadow:0 0 0 1.5px var(--accent),0 0 14px 2px color-mix(in srgb,var(--accent) 55%,transparent);}
|
|
873
|
+
.worldOrb[data-orb="black-hole"] .disc::before{display:none;}
|
|
874
|
+
/* it does not rise to meet the pointer: there is nothing behind it to open */
|
|
875
|
+
.worldOrb[data-orb="black-hole"]:hover .disc{translate:none;}
|
|
876
|
+
/* the placeholder name is not a project's name, and does not dress as one */
|
|
877
|
+
.worldOrb[data-orb="black-hole"] .nm{color:var(--ink-soft);font-style:italic;}
|
|
878
|
+
/* the table's anonymous row: same rule, and no pointer, since the row's click
|
|
879
|
+
target (its address) is exactly what the row does not have */
|
|
880
|
+
.tbl .tblRow.anon{cursor:default;}
|
|
881
|
+
.tbl .tblRow.anon .ptitle{color:var(--ink-soft);font-style:italic;}
|
|
882
|
+
|
|
825
883
|
/* the relationship ring: yours takes the accent (the one live thing about the
|
|
826
884
|
orb); building there is a hairline of it */
|
|
827
885
|
.worldOrb[data-rel="owner"] .disc::after,.worldOrb[data-rel="builder"] .disc::after{content:"";position:absolute;
|
|
@@ -986,8 +1044,12 @@ p.sec-p{font-size:var(--fs-lede);font-weight:500;color:var(--ink-soft);line-heig
|
|
|
986
1044
|
/* the spotlight is a drop-shadow in the accent on the disc's own filter — a
|
|
987
1045
|
glow that follows the silhouette (the object is a mask, so an outline or a
|
|
988
1046
|
box-shadow would be clipped away). Composed after the contact shadow, which
|
|
989
|
-
dark states as a no-op filter for exactly this.
|
|
990
|
-
|
|
1047
|
+
dark states as a no-op filter for exactly this.
|
|
1048
|
+
|
|
1049
|
+
A star composes its own light in after the spotlight (--star-light, set above;
|
|
1050
|
+
the empty fallback makes this a no-op for every other orb), so a private project
|
|
1051
|
+
the query matched does not stop looking private for as long as it matches. */
|
|
1052
|
+
.worldOrb.match .disc{filter:var(--orb-shadow) drop-shadow(0 0 16px color-mix(in srgb,var(--acc,var(--accent)) 60%,transparent)) var(--star-light,);}
|
|
991
1053
|
.field .hoverCard{position:absolute;z-index:6;pointer-events:none;display:flex;flex-direction:column;gap:2px;
|
|
992
1054
|
background:var(--bg-card);border:1px solid var(--rule);border-radius:var(--radius-sm);
|
|
993
1055
|
padding:9px 13px;max-width:240px;}
|
|
@@ -1553,6 +1553,39 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
1553
1553
|
ORB_SHAPES.forEach(function (s) { ['tint', 'wash', 'chrome'].forEach(function (y) { ORB_TEMPLATES.push(s + '-' + y); }); });
|
|
1554
1554
|
ORB_SHAPES.forEach(function (s) { ORB_MATERIALS.forEach(function (m) { ['tint', 'wash', 'plain'].forEach(function (y) { ORB_TEMPLATES.push(s + '-' + m + '-' + y); }); }); });
|
|
1555
1555
|
var ORB_WALK = ['blob-tint', 'pair-gold-wash', 'bead-obsidian-plain', 'blob-frost-wash', 'pair-chrome', 'bead-gold-tint', 'blob-obsidian-wash', 'pair-frost-tint', 'bead-wash', 'blob-gold-plain', 'pair-obsidian-tint', 'bead-frost-wash'];
|
|
1556
|
+
/* THE VISIBILITY RENDER-TYPE — a SECOND axis, and not the planet above (BV1.R09,
|
|
1557
|
+
task 1002328; ADR 0182 D1, criterion proj-visibility-states). The planet is
|
|
1558
|
+
what a project LOOKS like — the owner's chosen shape and material. This is what
|
|
1559
|
+
its door SAYS: public draws a planet, private a star, stealth a black hole.
|
|
1560
|
+
Dark matter never arrives; the feed excludes it in SQL, because a client-side
|
|
1561
|
+
filter ships the husk to anyone who opens a network tab.
|
|
1562
|
+
|
|
1563
|
+
THE VALUE IS READ, NEVER DERIVED. `orb` is computed server-side in
|
|
1564
|
+
modules/platform-identity/projects-feed.js, which holds the only copy of the
|
|
1565
|
+
state→orb mapping in the repo; this page reads that field and does not own a
|
|
1566
|
+
`state` table of its own. The three words below are LABELS for the states, not
|
|
1567
|
+
a second mapping — and they are explore.js's words (R07, task 1002326), so the
|
|
1568
|
+
two in-repo surfaces say the same thing. */
|
|
1569
|
+
var ORB_STATE = { planet: 'Public', star: 'Private', 'black-hole': 'Stealth' };
|
|
1570
|
+
/* FAIL-CLOSED, the choice projects-feed.js makes for the same question: a row
|
|
1571
|
+
whose render-type this page does not recognise is NOT DRAWN. The risk is a
|
|
1572
|
+
fourth state shipped by a newer server and read by an older page — drawing it
|
|
1573
|
+
as the default would publish, as a public planet, a project that chose not to
|
|
1574
|
+
be one. Applied to CATALOG rows only: the viewer's own provisioning rows never
|
|
1575
|
+
came from the feed and carry no `orb`. */
|
|
1576
|
+
function drawableCatalogRow(p) {
|
|
1577
|
+
return Object.prototype.hasOwnProperty.call(ORB_STATE, String((p && p.orb) || ''));
|
|
1578
|
+
}
|
|
1579
|
+
/* Is this row anonymous on the map? The one predicate the sky and the table both
|
|
1580
|
+
ask, so neither can answer it differently (ADR 0182 D2a). */
|
|
1581
|
+
function isBlackHole(p) { return String((p && p.orb) || '') === 'black-hole'; }
|
|
1582
|
+
/* What a black hole is allowed to say. Deliberately takes no project argument:
|
|
1583
|
+
there is no identifying value in scope to leak by accident, now or when the
|
|
1584
|
+
feed grows a column. explore.js's anonymousCard() makes the same choice. */
|
|
1585
|
+
function anonymousName() { return 'A project'; }
|
|
1586
|
+
function anonymousBlurb() {
|
|
1587
|
+
return 'Something is here. Its name, and everything else about it, stay with its owner.';
|
|
1588
|
+
}
|
|
1556
1589
|
var catalog = [];
|
|
1557
1590
|
var mineOrigins = {}; /* normalized host → 'owner' | 'builder' */
|
|
1558
1591
|
var ownedInstances = []; /* the viewer's OWN provisioning rows (never torn_down) */
|
|
@@ -1696,6 +1729,27 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
1696
1729
|
(spot ? list.concat(dimmed) : list).forEach(function (p, i) {
|
|
1697
1730
|
var slot = ORB_SLOTS[i % ORB_SLOTS.length];
|
|
1698
1731
|
var isDim = spot && i >= list.length;
|
|
1732
|
+
/* A BLACK HOLE IS DRAWN FROM NOTHING BUT ITS EXISTENCE (ADR 0182 D2a).
|
|
1733
|
+
A <span>, not an <a>: there is nowhere to go. No name, no address, no
|
|
1734
|
+
badge, no liveness chip — and no planet artwork, because the shape /
|
|
1735
|
+
material walk is an identity THIS PAGE assigns, and an identity assigned
|
|
1736
|
+
to rows that are supposed to be interchangeable is exactly what lets an
|
|
1737
|
+
observer follow one of them from yesterday's feed into today's. The
|
|
1738
|
+
server redacted the row; this is the page declining to re-identify it. */
|
|
1739
|
+
if (isBlackHole(p)) {
|
|
1740
|
+
var bh = document.createElement('span');
|
|
1741
|
+
bh.className = 'worldOrb blackHole' + (spot ? (isDim ? ' dim' : ' match') : '');
|
|
1742
|
+
bh.setAttribute('data-generated', '');
|
|
1743
|
+
bh.setAttribute('data-orb', 'black-hole');
|
|
1744
|
+
if (isDim) bh.setAttribute('aria-hidden', 'true');
|
|
1745
|
+
/* no --acc: the accent is per-project identity too, so a black hole takes
|
|
1746
|
+
the page's own and is the same colour as every other black hole */
|
|
1747
|
+
bh.style.cssText = '--d:' + slot[2] + 'px;left:' + slot[0] + '%;top:' + slot[1] + '%';
|
|
1748
|
+
bh.title = anonymousBlurb();
|
|
1749
|
+
bh.innerHTML = '<span class="disc"></span><span class="nm">' + esc(anonymousName()) + '</span>';
|
|
1750
|
+
layer.appendChild(bh);
|
|
1751
|
+
return;
|
|
1752
|
+
}
|
|
1699
1753
|
var a = document.createElement('a');
|
|
1700
1754
|
a.className = 'worldOrb' + (spot ? (isDim ? ' dim' : ' match') : '');
|
|
1701
1755
|
/* a dimmed orb is BACKDROP: at .25 opacity its label cannot carry
|
|
@@ -1705,6 +1759,12 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
1705
1759
|
a.setAttribute('data-generated', '');
|
|
1706
1760
|
var rel = relOf(p);
|
|
1707
1761
|
a.setAttribute('data-rel', rel);
|
|
1762
|
+
/* the door the project chose, as the feed derived it. An IDENTIFIED state —
|
|
1763
|
+
public or private — is on the map under its own name; what the state
|
|
1764
|
+
changes is how it is drawn and who may walk in, never whether it is named.
|
|
1765
|
+
An owned-only entry never came from the feed, so it claims no state. */
|
|
1766
|
+
var orbState = drawableCatalogRow(p) ? String(p.orb) : '';
|
|
1767
|
+
if (orbState) a.setAttribute('data-orb', orbState);
|
|
1708
1768
|
/* the planet: shape, material and style as data attributes (shape[-material]-style;
|
|
1709
1769
|
no material = chrome), cosmos.css does the rest */
|
|
1710
1770
|
var tpl = templateOf(p), tplParts = tpl.split('-');
|
|
@@ -1731,7 +1791,10 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
1731
1791
|
if (href) { a.target = '_blank'; a.rel = 'noopener'; }
|
|
1732
1792
|
}
|
|
1733
1793
|
a.setAttribute('data-origin', String(p.origin || ''));
|
|
1734
|
-
|
|
1794
|
+
/* the state in words as well as in the drawing — a visitor should not have
|
|
1795
|
+
to have learned the shapes to tell a planet from a star (the explore
|
|
1796
|
+
page's rule: the orb is decorative, the plain word carries the meaning) */
|
|
1797
|
+
a.title = [p.tagline || p.name || '', ORB_STATE[orbState] || ''].filter(Boolean).join(' · ');
|
|
1735
1798
|
a.style.cssText = '--acc:' + accentOf(p) +
|
|
1736
1799
|
';--d:' + slot[2] + 'px;left:' + slot[0] + '%;top:' + slot[1] + '%';
|
|
1737
1800
|
a.innerHTML =
|
|
@@ -1772,7 +1835,21 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
1772
1835
|
not a peer in the catalog's sort order. */
|
|
1773
1836
|
body.querySelectorAll('li[data-generated]').forEach(function (n) { n.remove(); });
|
|
1774
1837
|
body.insertAdjacentHTML('beforeend', list.map(function (p) {
|
|
1838
|
+
/* The table is a PEER VIEW of the map, not a back door around D2a: a black
|
|
1839
|
+
hole says exactly as little here as it does in the sky. No name, no
|
|
1840
|
+
address, no facts, no doors — and no liveness chip, because a chip is a
|
|
1841
|
+
claim about a specific project. The row exists so the count is honest;
|
|
1842
|
+
that is the whole of what it publishes. */
|
|
1843
|
+
if (isBlackHole(p)) {
|
|
1844
|
+
return '<li class="tblRow anon" data-generated data-orb="black-hole">' +
|
|
1845
|
+
'<span class="worldOrb blackHole" data-orb="black-hole" style="--d:64px" aria-hidden="true">' +
|
|
1846
|
+
'<span class="disc"></span></span>' +
|
|
1847
|
+
'<div class="tWho"><span class="ptitle">' + esc(anonymousName()) + '</span></div>' +
|
|
1848
|
+
'<p class="ptag">' + esc(anonymousBlurb()) + '</p>' +
|
|
1849
|
+
'</li>';
|
|
1850
|
+
}
|
|
1775
1851
|
var rel = relOf(p);
|
|
1852
|
+
var orbState = drawableCatalogRow(p) ? String(p.orb) : '';
|
|
1776
1853
|
var pill = rel === 'owner' ? '<span class="pill">yours</span>'
|
|
1777
1854
|
: rel === 'builder' ? '<span class="pill">you build here</span>' : '';
|
|
1778
1855
|
/* the project's own planet, the object the sky and the Your-projects row
|
|
@@ -1782,6 +1859,7 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
1782
1859
|
var planet = '<span class="worldOrb"' + planetAttrs({ tpl: templateOf(p) }) +
|
|
1783
1860
|
' style="--acc:' + accentOf(p) + ';--d:64px" aria-hidden="true"><span class="disc"></span></span>';
|
|
1784
1861
|
return '<li class="tblRow" data-generated ' + (p._inst ? 'data-manage="' + esc(String(p._inst.id)) + '" ' : '') +
|
|
1862
|
+
(orbState ? 'data-orb="' + esc(orbState) + '" ' : '') +
|
|
1785
1863
|
'data-origin="' + esc(String(p.origin || '')) + '">' + planet +
|
|
1786
1864
|
'<div class="tWho">' + (p._inst
|
|
1787
1865
|
? '<a class="ptitle" data-manage="' + esc(String(p._inst.id)) + '" href="?view=manage&id=' + esc(String(p._inst.id)) + '">' + esc(p.name) + '</a>'
|
|
@@ -1817,7 +1895,13 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
1817
1895
|
entries. The sky, the table, the search box and the count read the same
|
|
1818
1896
|
list and can never disagree about what exists. */
|
|
1819
1897
|
var catalogHosts = {};
|
|
1820
|
-
|
|
1898
|
+
/* FAIL-CLOSED FIRST, before anything else reads these rows (task 1002328): a
|
|
1899
|
+
render-type this page does not recognise is not drawn, not counted, and —
|
|
1900
|
+
the reason it is filtered HERE rather than inside the renderers — not allowed
|
|
1901
|
+
to claim a host either, or it would suppress the viewer's own entry for that
|
|
1902
|
+
same project in the loop below. */
|
|
1903
|
+
var drawnCatalog = catalog.filter(drawableCatalogRow);
|
|
1904
|
+
drawnCatalog.forEach(function (p) {
|
|
1821
1905
|
var h = hostOf(p.origin);
|
|
1822
1906
|
catalogHosts[h] = 1;
|
|
1823
1907
|
p._inst = ownedByHost[h] || null;
|
|
@@ -1825,7 +1909,7 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
1825
1909
|
create — their own instance row is a truthful source, never a guess */
|
|
1826
1910
|
if (p.type == null && p._inst && p._inst.type) p.type = p._inst.type;
|
|
1827
1911
|
});
|
|
1828
|
-
var browse =
|
|
1912
|
+
var browse = drawnCatalog.slice();
|
|
1829
1913
|
ownedInstances.forEach(function (inst) {
|
|
1830
1914
|
var h = inst.domain ? hostOf(inst.domain) : '';
|
|
1831
1915
|
if (h && catalogHosts[h]) return; /* annotated onto its catalog row above */
|
|
@@ -3324,13 +3408,29 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
3324
3408
|
return row ? row.bundle.slice() : [];
|
|
3325
3409
|
}
|
|
3326
3410
|
/* Why the preselect is what it is, in the owner's own words — one sentence per
|
|
3327
|
-
rule that ACTUALLY moved something
|
|
3328
|
-
|
|
3329
|
-
|
|
3411
|
+
rule that ACTUALLY moved something, and failing that the type's own summary.
|
|
3412
|
+
Returns SENTENCES, not rule rows.
|
|
3413
|
+
|
|
3414
|
+
The fallback is the point (task 1003684). `adjustments` is a DELTA: a rule that
|
|
3415
|
+
fires without changing anything claims nothing (ADR 0243). So for a type whose
|
|
3416
|
+
bundle ALREADY contains what the rule would add — game is [dev-box, discord] and
|
|
3417
|
+
the small-team rule adds dev-box — the delta is empty and the owner was shown a
|
|
3418
|
+
tailored preselect with no reason beside it at all. That is the state ADR 0237
|
|
3419
|
+
calls worse than no preselect: one the owner has to audit. Each bundle carries a
|
|
3420
|
+
curated summary authored for exactly this, served on every row, and until now
|
|
3421
|
+
never rendered anywhere (ADR 0261). It is the floor, not a replacement — an adjustment
|
|
3422
|
+
still speaks for itself and wins.
|
|
3423
|
+
|
|
3424
|
+
Still empty once the owner has answered for themselves: a reason given back for
|
|
3425
|
+
a toggle they flipped reads as the panel arguing with them. */
|
|
3330
3426
|
function preselectReasons() {
|
|
3331
3427
|
if (state.modules !== null) return [];
|
|
3332
3428
|
var row = tailoredRow();
|
|
3333
|
-
|
|
3429
|
+
if (!row) return [];
|
|
3430
|
+
var moved = (row.adjustments || []).map(function (a) { return a.reason; });
|
|
3431
|
+
if (moved.length) return moved;
|
|
3432
|
+
var b = bundleRow();
|
|
3433
|
+
return b && b.summary ? [b.summary] : [];
|
|
3334
3434
|
}
|
|
3335
3435
|
/* What the panel paints and the review names: the owner's answer if they
|
|
3336
3436
|
gave one, otherwise the type's preselect. */
|
|
@@ -3423,7 +3523,7 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
3423
3523
|
var why = $('modWhy');
|
|
3424
3524
|
var reasons = preselectReasons();
|
|
3425
3525
|
why.innerHTML = reasons.length
|
|
3426
|
-
? reasons.map(function (
|
|
3526
|
+
? reasons.map(function (r) { return '<span>' + esc(r) + '</span>'; }).join('')
|
|
3427
3527
|
: '';
|
|
3428
3528
|
why.classList.toggle('on', reasons.length > 0);
|
|
3429
3529
|
var core = (starterBundles.core || []).length;
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.586",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.586",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.586",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/src/module-api.js
CHANGED
|
@@ -55,7 +55,7 @@ const { buildInfo } = require('./build-info');
|
|
|
55
55
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
56
56
|
// the entry to that file. Look for a version's history there, not here.
|
|
57
57
|
// ---------------------------------------------------------------------------
|
|
58
|
-
const CORE_VERSION = '1.19.
|
|
58
|
+
const CORE_VERSION = '1.19.586'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
59
59
|
|
|
60
60
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
61
61
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
// tests/hub_map_orb_states.mjs — the hub map draws the VISIBILITY render-type,
|
|
2
|
+
// EXECUTED (BV1.R09, task 1002328; ADR 0182 D1/D2a, criterion proj-visibility-states).
|
|
3
|
+
//
|
|
4
|
+
// R06 (task 1002325) made the feed answer what each project draws as — `orb` is
|
|
5
|
+
// planet / star / black-hole, derived server-side from the join door, with stealth
|
|
6
|
+
// rows redacted before they leave the server and dark matter excluded in SQL. R07
|
|
7
|
+
// (task 1002326) dogfooded it on the Explore page. This file pins the surface a
|
|
8
|
+
// visitor actually lands on: the hub map at modules/public-landing/public/projects.html,
|
|
9
|
+
// in both its views — the sky and the table.
|
|
10
|
+
//
|
|
11
|
+
// The original R09 scoped this to a separate-repo static gate site; that gate was
|
|
12
|
+
// retired (src/platform-server.js: the apex 302 into /gate/ "is gone with the gate
|
|
13
|
+
// itself"), and the orb sky became a view inside the hub. So the criterion's last
|
|
14
|
+
// clause lands here.
|
|
15
|
+
//
|
|
16
|
+
// Harness: the tests/project_door_ui.mjs pattern — there is no jsdom in this repo,
|
|
17
|
+
// so the real region is sliced out of the page verbatim, run in a vm under a narrow
|
|
18
|
+
// DOM stub, and the markup it writes is parsed back. Both slice boundaries are
|
|
19
|
+
// asserted so a rename can never leave this file executing an empty string.
|
|
20
|
+
//
|
|
21
|
+
// What it proves, and why each matters:
|
|
22
|
+
// • the three states draw as three different things — a private project stopped
|
|
23
|
+
// being indistinguishable from a public one
|
|
24
|
+
// • a black hole is drawn from NOTHING but its existence: no name, no address,
|
|
25
|
+
// no per-project artwork, and nothing to click. The server already redacted the
|
|
26
|
+
// row; this asserts the page cannot re-identify it from what it kept
|
|
27
|
+
// • an unrecognised render-type is DROPPED, not drawn — the fail-closed choice
|
|
28
|
+
// projects-feed.js makes for the same question, so a fourth state added later
|
|
29
|
+
// can never inherit the public planet
|
|
30
|
+
// • the mapping is never re-derived client-side from `state` — one copy, server-side
|
|
31
|
+
// • the search box cannot surface a black hole
|
|
32
|
+
// • the viewer's OWN provisioning rows, which carry no feed `orb`, still draw —
|
|
33
|
+
// fail-closed must not delete the owner's own sky
|
|
34
|
+
//
|
|
35
|
+
// Run: node --test --test-reporter=tap tests/hub_map_orb_states.mjs
|
|
36
|
+
|
|
37
|
+
import assert from 'node:assert/strict';
|
|
38
|
+
import { test } from 'node:test';
|
|
39
|
+
import fs from 'node:fs';
|
|
40
|
+
import path from 'node:path';
|
|
41
|
+
import vm from 'node:vm';
|
|
42
|
+
import { fileURLToPath } from 'node:url';
|
|
43
|
+
|
|
44
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
45
|
+
const LANDING = path.join(ROOT, 'modules', 'public-landing', 'public');
|
|
46
|
+
const HUB = fs.readFileSync(path.join(LANDING, 'projects.html'), 'utf8');
|
|
47
|
+
const SCRIPT = (HUB.match(/<script>([\s\S]*?)<\/script>\s*<\/body>/) || [, ''])[1];
|
|
48
|
+
|
|
49
|
+
// ── the region under test, sliced verbatim ───────────────────────────────────
|
|
50
|
+
const START = SCRIPT.indexOf('var ORB_SLOTS = [');
|
|
51
|
+
const END = SCRIPT.indexOf('function applyMapFilter(');
|
|
52
|
+
assert.ok(START > 0, 'the map-render region must start at ORB_SLOTS');
|
|
53
|
+
assert.ok(END > START, 'the map-render region must end before applyMapFilter');
|
|
54
|
+
const REGION = SCRIPT.slice(START, END);
|
|
55
|
+
for (const fn of ['var ORB_STATE = {', 'function drawableCatalogRow(', 'function renderOrbs(', 'function renderRows(']) {
|
|
56
|
+
assert.ok(REGION.includes(fn), `the region must carry ${fn}`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// planetAttrs() lives further down the page (it is shared with the manage cards),
|
|
60
|
+
// so it is sliced in rather than re-typed — a hand-copied stub would drift from the
|
|
61
|
+
// thing whose output this file asserts about.
|
|
62
|
+
const PA_START = SCRIPT.indexOf('function planetAttrs(');
|
|
63
|
+
const PA_END = SCRIPT.indexOf('var PLANET_SHAPE_WORDS');
|
|
64
|
+
assert.ok(PA_START > 0 && PA_END > PA_START, 'planetAttrs must still be sliceable');
|
|
65
|
+
const PLANET_ATTRS = SCRIPT.slice(PA_START, PA_END);
|
|
66
|
+
|
|
67
|
+
// ── the DOM stub ─────────────────────────────────────────────────────────────
|
|
68
|
+
// Narrow on purpose: it supports exactly what the region touches, so it stays
|
|
69
|
+
// honest about what it can prove.
|
|
70
|
+
|
|
71
|
+
function makeNode(tag) {
|
|
72
|
+
return {
|
|
73
|
+
tagName: String(tag).toUpperCase(),
|
|
74
|
+
_attrs: {},
|
|
75
|
+
_children: [],
|
|
76
|
+
className: '',
|
|
77
|
+
tabIndex: 0,
|
|
78
|
+
style: { cssText: '' },
|
|
79
|
+
innerHTML: '',
|
|
80
|
+
setAttribute(k, v) { this._attrs[k] = String(v); },
|
|
81
|
+
getAttribute(k) { return Object.prototype.hasOwnProperty.call(this._attrs, k) ? this._attrs[k] : null; },
|
|
82
|
+
hasAttribute(k) { return Object.prototype.hasOwnProperty.call(this._attrs, k); },
|
|
83
|
+
appendChild(n) { this._children.push(n); return n; },
|
|
84
|
+
querySelectorAll() { return []; },
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function boot() {
|
|
89
|
+
const sky = makeNode('div');
|
|
90
|
+
let rowsHtml = '';
|
|
91
|
+
const rows = makeNode('ul');
|
|
92
|
+
rows.insertAdjacentHTML = (_where, html) => { rowsHtml += html; };
|
|
93
|
+
|
|
94
|
+
const els = { skyCam: sky, mapRows: rows };
|
|
95
|
+
const sandbox = {
|
|
96
|
+
// safeHttpUrl() (inside the region) scheme-checks through the URL parser — a vm
|
|
97
|
+
// context has no globals of its own, so it is handed the real one
|
|
98
|
+
URL,
|
|
99
|
+
document: { createElement: makeNode, getElementById: (id) => els[id] || null },
|
|
100
|
+
$: (id) => els[id] || null,
|
|
101
|
+
esc: (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => (
|
|
102
|
+
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])),
|
|
103
|
+
productName: 'Cloud Bongos',
|
|
104
|
+
// the surrounding page's own helpers, narrowed to what the region reads back
|
|
105
|
+
chipFor: () => '<span class="chip"></span>',
|
|
106
|
+
hallGlyphHtml: () => '',
|
|
107
|
+
shippedFigure: () => '',
|
|
108
|
+
listedDate: (iso) => String(iso),
|
|
109
|
+
};
|
|
110
|
+
vm.createContext(sandbox);
|
|
111
|
+
vm.runInContext(PLANET_ATTRS + '\n' + REGION, sandbox);
|
|
112
|
+
return {
|
|
113
|
+
sandbox,
|
|
114
|
+
sky,
|
|
115
|
+
rowsHtml: () => rowsHtml,
|
|
116
|
+
orbs: () => sky._children,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// A feed row as the server actually shapes it (projects-feed.js): `state` and `orb`
|
|
121
|
+
// on every row, and a stealth row nulled down to those two keys.
|
|
122
|
+
const PUBLIC_ROW = {
|
|
123
|
+
origin: 'https://demo.example.com', name: 'Demo', tagline: 'a demo project',
|
|
124
|
+
art_url: null, created_at: '2026-01-01T00:00:00.000Z', builders: 3, tags: [],
|
|
125
|
+
type: null, state: 'public', orb: 'planet',
|
|
126
|
+
};
|
|
127
|
+
const PRIVATE_ROW = {
|
|
128
|
+
origin: 'https://circles.example.com', name: 'Circles', tagline: 'invite only',
|
|
129
|
+
art_url: null, created_at: '2026-01-02T00:00:00.000Z', builders: 2, tags: [],
|
|
130
|
+
type: null, state: 'private', orb: 'star',
|
|
131
|
+
};
|
|
132
|
+
const STEALTH_ROW = {
|
|
133
|
+
origin: null, name: null, tagline: null, art_url: null, created_at: null,
|
|
134
|
+
builders: null, tags: null, type: null, state: 'stealth', orb: 'black-hole',
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
test('the three states draw as three different orbs', () => {
|
|
138
|
+
const h = boot();
|
|
139
|
+
h.sandbox.renderOrbs([PUBLIC_ROW, PRIVATE_ROW, STEALTH_ROW], []);
|
|
140
|
+
const drawn = h.orbs();
|
|
141
|
+
assert.equal(drawn.length, 3, 'every drawable row reaches the sky');
|
|
142
|
+
assert.deepEqual(drawn.map((n) => n.getAttribute('data-orb')),
|
|
143
|
+
['planet', 'star', 'black-hole'],
|
|
144
|
+
'each orb carries the render-type the feed derived');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('a public project keeps its planet: name, address and artwork', () => {
|
|
148
|
+
const h = boot();
|
|
149
|
+
h.sandbox.renderOrbs([PUBLIC_ROW], []);
|
|
150
|
+
const [orb] = h.orbs();
|
|
151
|
+
assert.equal(orb.tagName, 'A', 'a public project is a real link');
|
|
152
|
+
assert.equal(orb.href, 'https://demo.example.com/');
|
|
153
|
+
assert.match(orb.innerHTML, /Demo/, 'it is named');
|
|
154
|
+
assert.ok(orb.getAttribute('data-template'), 'it carries its planet artwork');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('a private project is a star, and is still itself', () => {
|
|
158
|
+
const h = boot();
|
|
159
|
+
h.sandbox.renderOrbs([PRIVATE_ROW], []);
|
|
160
|
+
const [orb] = h.orbs();
|
|
161
|
+
assert.equal(orb.getAttribute('data-orb'), 'star');
|
|
162
|
+
// identified, per ADR 0182 D1: private is on the map UNDER ITS NAME — what
|
|
163
|
+
// changes is the door, not the anonymity
|
|
164
|
+
assert.match(orb.innerHTML, /Circles/);
|
|
165
|
+
assert.equal(orb.href, 'https://circles.example.com/');
|
|
166
|
+
assert.ok(orb.getAttribute('data-template'), 'it keeps its planet artwork');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('a black hole is existence without identity — nothing to read, nothing to click', () => {
|
|
170
|
+
const h = boot();
|
|
171
|
+
h.sandbox.renderOrbs([STEALTH_ROW], []);
|
|
172
|
+
const [orb] = h.orbs();
|
|
173
|
+
assert.equal(orb.getAttribute('data-orb'), 'black-hole');
|
|
174
|
+
assert.notEqual(orb.tagName, 'A', 'a black hole is not a link — there is nowhere to go');
|
|
175
|
+
assert.ok(!orb.href, 'and it carries no address');
|
|
176
|
+
assert.equal(orb.getAttribute('data-origin'), null, 'not even as a data attribute');
|
|
177
|
+
// no per-project artwork: the shape/material walk is an IDENTITY the page
|
|
178
|
+
// assigns, and assigning one to a black hole would make interchangeable rows
|
|
179
|
+
// individually recognisable across snapshots
|
|
180
|
+
for (const a of ['data-template', 'data-shape', 'data-material', 'data-style']) {
|
|
181
|
+
assert.equal(orb.getAttribute(a), null, `a black hole must not carry ${a}`);
|
|
182
|
+
}
|
|
183
|
+
assert.doesNotMatch(orb.innerHTML, /class="badge"|class="chip"/, 'no badge, no liveness chip');
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test('an unrecognised render-type is dropped, not drawn (fail-closed)', () => {
|
|
187
|
+
const { sandbox } = boot();
|
|
188
|
+
for (const orb of ['planet', 'star', 'black-hole']) {
|
|
189
|
+
assert.equal(sandbox.drawableCatalogRow({ orb }), true, `${orb} is drawable`);
|
|
190
|
+
}
|
|
191
|
+
// the shape of the risk: a fourth state shipped by a newer server, read by an
|
|
192
|
+
// older page. It must not inherit the public planet.
|
|
193
|
+
for (const orb of ['wormhole', 'nebula', '', null, undefined]) {
|
|
194
|
+
assert.equal(sandbox.drawableCatalogRow({ orb }), false,
|
|
195
|
+
`an unknown render-type (${String(orb)}) is not drawn`);
|
|
196
|
+
}
|
|
197
|
+
assert.equal(sandbox.drawableCatalogRow({}), false, 'a row with no render-type at all is not drawn');
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test('the drop is wired into the browse list, not left as an orphan predicate', () => {
|
|
201
|
+
// behaviour above proves the predicate; this proves the page actually asks it,
|
|
202
|
+
// and asks it of the CATALOG (the viewer's own provisioning rows carry no feed
|
|
203
|
+
// `orb` and must survive)
|
|
204
|
+
assert.match(SCRIPT, /catalog\.filter\(drawableCatalogRow\)/,
|
|
205
|
+
'applyMapFilter must filter the catalog through drawableCatalogRow');
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("the viewer's own provisioning rows still draw — fail-closed must not empty the owner's sky", () => {
|
|
209
|
+
const h = boot();
|
|
210
|
+
// the synthetic row applyMapFilter pushes for an owned instance the catalog
|
|
211
|
+
// does not carry: no feed `orb`, because it never came from the feed
|
|
212
|
+
const owned = {
|
|
213
|
+
name: 'my-project', tagline: 'my-project.example.com',
|
|
214
|
+
origin: 'https://my-project.example.com', builders: null, tags: null, type: null,
|
|
215
|
+
_inst: { id: '42', slug: 'my-project', domain: 'my-project.example.com' },
|
|
216
|
+
};
|
|
217
|
+
h.sandbox.renderOrbs([owned], []);
|
|
218
|
+
const [orb] = h.orbs();
|
|
219
|
+
assert.equal(h.orbs().length, 1, 'an owned-only entry is drawn');
|
|
220
|
+
assert.equal(orb.getAttribute('data-orb'), null, 'and is not labelled with a state it never claimed');
|
|
221
|
+
assert.ok(orb.getAttribute('data-template'), 'it keeps its slug-walked planet');
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test('the state→orb mapping is read from the feed, never re-derived here', () => {
|
|
225
|
+
// projects-feed.js owns ORB_BY_STATE. A second copy on the client is a copy
|
|
226
|
+
// that drifts — the explicit rule R07 already follows in explore.js.
|
|
227
|
+
assert.doesNotMatch(REGION, /['"]public['"]\s*:\s*['"]planet['"]/,
|
|
228
|
+
'the page must not carry its own state→orb table');
|
|
229
|
+
assert.match(REGION, /p\.orb|\bORB_STATE\b/, 'it reads the feed’s own orb field');
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test('a black hole cannot be surfaced by the search box', () => {
|
|
233
|
+
const { sandbox } = boot();
|
|
234
|
+
// it publishes no text, so there is nothing for a query to match — asserted
|
|
235
|
+
// rather than assumed, because a future row that gained a searchable field
|
|
236
|
+
// would silently make stealth projects probeable by guessing names
|
|
237
|
+
for (const q of ['a', 'project', 'demo', 'circles']) {
|
|
238
|
+
assert.equal(sandbox.matchesMapQuery(STEALTH_ROW, q), false,
|
|
239
|
+
`a query (${q}) must never match a black hole`);
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test('the table view says the same three things as the sky', () => {
|
|
244
|
+
const h = boot();
|
|
245
|
+
h.sandbox.renderRows([PUBLIC_ROW, PRIVATE_ROW, STEALTH_ROW]);
|
|
246
|
+
const html = h.rowsHtml();
|
|
247
|
+
for (const orb of ['planet', 'star', 'black-hole']) {
|
|
248
|
+
assert.ok(html.includes('data-orb="' + orb + '"'), `the table marks its ${orb} row`);
|
|
249
|
+
}
|
|
250
|
+
// the anonymous row is anonymous HERE too: the table is a peer view of the map,
|
|
251
|
+
// not a back door around D2a
|
|
252
|
+
const anon = html.slice(html.indexOf('data-orb="black-hole"'));
|
|
253
|
+
assert.doesNotMatch(anon, /class="rowGo"/, 'a black hole has no "view" door');
|
|
254
|
+
assert.doesNotMatch(anon, /data-template=/, 'and no per-project artwork');
|
|
255
|
+
});
|
|
@@ -56,12 +56,16 @@ const PAYLOAD = {
|
|
|
56
56
|
core: ['autonomy', 'builder-settings', 'copy-desk', 'economy', 'government', 'grading',
|
|
57
57
|
'hall-ui', 'ideas', 'lifecycle', 'memory', 'onboarding', 'public-landing',
|
|
58
58
|
'security', 'sessions', 'status-ui', 'ui-design'],
|
|
59
|
+
// `summary` is part of every real bundle row and is the why-line's FLOOR since task
|
|
60
|
+
// 1003684 — the sentence shown when no rule moved anything. The fixture carried none
|
|
61
|
+
// before that, which quietly made every "no adjustment, no sentence" case pass for the
|
|
62
|
+
// wrong reason. These are the real strings from starter-bundles.js.
|
|
59
63
|
bundles: [
|
|
60
|
-
{ type: 'game', bundle: ['dev-box', 'discord'] },
|
|
61
|
-
{ type: 'research', bundle: ['agents'] },
|
|
62
|
-
{ type: 'business', bundle: ['agents', 'dev-box'] },
|
|
63
|
-
{ type: 'non-profit', bundle: ['discord'] },
|
|
64
|
-
{ type: 'not-sure', bundle: [] },
|
|
64
|
+
{ type: 'game', bundle: ['dev-box', 'discord'], summary: 'Somewhere for players to gather, and a place to walk a change through before anyone plays it.' },
|
|
65
|
+
{ type: 'research', bundle: ['agents'], summary: 'Extra agents to run the reading and the experiments the question needs.' },
|
|
66
|
+
{ type: 'business', bundle: ['agents', 'dev-box'], summary: 'A place to try a change before customers see it, and agents to look it over first.' },
|
|
67
|
+
{ type: 'non-profit', bundle: ['discord'], summary: 'Somewhere the community around the cause can gather and be heard.' },
|
|
68
|
+
{ type: 'not-sure', bundle: [], summary: 'Just the core, and nothing else to decide today. Add modules whenever you know what you need.' },
|
|
65
69
|
],
|
|
66
70
|
optional: [
|
|
67
71
|
{ key: 'agents', label: 'Extra agents', summary: 'More AI agents alongside the ones every project gets.' },
|
|
@@ -460,8 +464,14 @@ test('a rule that changed nothing claims nothing', async () => {
|
|
|
460
464
|
await h.open();
|
|
461
465
|
|
|
462
466
|
assert.deepEqual(h.rows().filter((r) => r.pressed).map((r) => r.key), ['dev-box', 'discord']);
|
|
463
|
-
|
|
464
|
-
|
|
467
|
+
// No adjustment, so no adjustment-sentence — the rule still claims nothing. But the
|
|
468
|
+
// panel is not silent: the TYPE explains its own preselect (task 1003684), because two
|
|
469
|
+
// extras switched on with no reason beside them is the state ADR 0237 calls worse than
|
|
470
|
+
// no preselect at all. The rule's voice and the type's voice are different things.
|
|
471
|
+
assert.deepEqual(h.why(), ['Somewhere for players to gather, and a place to walk a change through before anyone plays it.']);
|
|
472
|
+
assert.ok(!h.why().some((s) => /shared cloud environment|somewhere for it to gather is switched on/.test(s)),
|
|
473
|
+
'and no RULE sentence is invented for a change that did not happen');
|
|
474
|
+
assert.equal(h.whyShown(), true, 'the block carries the type\'s reason');
|
|
465
475
|
});
|
|
466
476
|
|
|
467
477
|
test('an added module is preselected and explained', async () => {
|
|
@@ -520,7 +530,9 @@ test('a payload with no byTeamShape degrades to the untailored bundle, never to
|
|
|
520
530
|
await h.open();
|
|
521
531
|
|
|
522
532
|
assert.deepEqual(h.rows().filter((r) => r.pressed).map((r) => r.key), ['dev-box', 'discord']);
|
|
523
|
-
|
|
533
|
+
// Degraded, but still explained: with no byTeamShape there is no adjustment to report,
|
|
534
|
+
// and the type's own summary is exactly the untailored reason (task 1003684).
|
|
535
|
+
assert.deepEqual(h.why(), ['Somewhere for players to gather, and a place to walk a change through before anyone plays it.']);
|
|
524
536
|
});
|
|
525
537
|
|
|
526
538
|
test('an off-vocabulary team shape adjusts nothing', async () => {
|
|
@@ -530,7 +542,9 @@ test('an off-vocabulary team shape adjusts nothing', async () => {
|
|
|
530
542
|
await h.open();
|
|
531
543
|
|
|
532
544
|
assert.deepEqual(h.rows().filter((r) => r.pressed).map((r) => r.key), ['dev-box', 'discord']);
|
|
533
|
-
|
|
545
|
+
// No rule fires, so nothing is CLAIMED about the unknown shape — the sentence shown is
|
|
546
|
+
// the type's, which is true regardless of who is building it (task 1003684).
|
|
547
|
+
assert.deepEqual(h.why(), ['Somewhere for players to gather, and a place to walk a change through before anyone plays it.']);
|
|
534
548
|
});
|
|
535
549
|
|
|
536
550
|
test('a couldn\'t-load read clears any reasons it had already painted', async () => {
|
|
@@ -582,8 +596,12 @@ test('the picker and the server resolve the same set for every answer an owner c
|
|
|
582
596
|
`type=${preset.type} team_shape=${teamShape}: the panel preselects what the server resolves`);
|
|
583
597
|
assert.deepEqual(h.rows().filter((r) => r.pressed).map((r) => r.key), server.selected,
|
|
584
598
|
`type=${preset.type} team_shape=${teamShape}: and the toggles show it`);
|
|
585
|
-
|
|
586
|
-
|
|
599
|
+
// One sentence per adjustment the server made — and when it made none, the single
|
|
600
|
+
// floor sentence the type carries, so no combination leaves a preselect unexplained
|
|
601
|
+
// (task 1003684). A preset with no summary of its own still says nothing.
|
|
602
|
+
const expectedWhy = server.adjustments.length || (preset.summary ? 1 : 0);
|
|
603
|
+
assert.equal(h.why().length, expectedWhy,
|
|
604
|
+
`type=${preset.type} team_shape=${teamShape}: a sentence per adjustment, or the type's own when none moved`);
|
|
587
605
|
}
|
|
588
606
|
}
|
|
589
607
|
});
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// tests/wizard_preselect_why.mjs — the module picker always says WHY the preselect is on
|
|
2
|
+
// (task 1003684).
|
|
3
|
+
//
|
|
4
|
+
// The bug as filed said the starter-bundle "why" sentence was being dropped for
|
|
5
|
+
// Game + small-team. It was not: `adjustments` is a DELTA, and a rule that fires without
|
|
6
|
+
// changing anything claims nothing (ADR 0243). The game bundle is already
|
|
7
|
+
// ['dev-box','discord'] and the small-team rule ADDS dev-box, so the rule fires, moves
|
|
8
|
+
// nothing, and correctly reports []. Verified against live core 1.19.580:
|
|
9
|
+
// byTeamShape['small-team'] => {"bundle":["dev-box","discord"],"adjustments":[]}
|
|
10
|
+
//
|
|
11
|
+
// The real defect is what the owner then saw: two extras pre-switched-on with no reason
|
|
12
|
+
// beside them — precisely the state the paint site's own comment calls out, "a preselect an
|
|
13
|
+
// owner cannot see the reason for is one they have to audit, which is the thing ADR 0237
|
|
14
|
+
// says is worse than no preselect at all". Each bundle carries a curated `summary` authored
|
|
15
|
+
// for this, served on every row of GET /provisioning/starter-bundles, and never rendered
|
|
16
|
+
// anywhere in the wizard. It is now the floor beneath the adjustment sentences.
|
|
17
|
+
//
|
|
18
|
+
// Harness: the vm-slice idiom (tests/project_door_ui.mjs), since the wizard's script is
|
|
19
|
+
// inline in the page and there is no jsdom here.
|
|
20
|
+
//
|
|
21
|
+
// Run: node --test --test-reporter=tap tests/wizard_preselect_why.mjs
|
|
22
|
+
|
|
23
|
+
import assert from 'node:assert/strict';
|
|
24
|
+
import { test } from 'node:test';
|
|
25
|
+
import fs from 'node:fs';
|
|
26
|
+
import path from 'node:path';
|
|
27
|
+
import vm from 'node:vm';
|
|
28
|
+
import { fileURLToPath } from 'node:url';
|
|
29
|
+
|
|
30
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
31
|
+
const HUB = fs.readFileSync(path.join(ROOT, 'modules', 'public-landing', 'public', 'projects.html'), 'utf8');
|
|
32
|
+
|
|
33
|
+
const SLICE_RE = /function bundleRow\(\)[\s\S]*?function preselectReasons\(\)[\s\S]*?\n {4}\}/;
|
|
34
|
+
const slice = (HUB.match(SLICE_RE) || [null])[0];
|
|
35
|
+
|
|
36
|
+
test('the preselect-reason slice is still findable', () => {
|
|
37
|
+
assert.ok(slice, 'bundleRow/tailoredRow/preselectReasons were not found in projects.html');
|
|
38
|
+
assert.match(slice, /function tailoredRow\(\)/, 'the slice spans the tailoring lookup too');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// Faithful to the real payload shape (confirmed against live): every bundle row carries
|
|
42
|
+
// label/summary/bundle, and byTeamShape holds the resolved answer per team shape.
|
|
43
|
+
const BUNDLES = {
|
|
44
|
+
bundles: [
|
|
45
|
+
{
|
|
46
|
+
type: 'game',
|
|
47
|
+
label: 'Game starter',
|
|
48
|
+
summary: 'Somewhere for players to gather, and a place to walk a change through before anyone plays it.',
|
|
49
|
+
bundle: ['dev-box', 'discord'],
|
|
50
|
+
byTeamShape: {
|
|
51
|
+
// the live case: the rule fires, moves nothing, reports nothing
|
|
52
|
+
'small-team': { bundle: ['dev-box', 'discord'], adjustments: [] },
|
|
53
|
+
solo: {
|
|
54
|
+
bundle: ['discord'],
|
|
55
|
+
adjustments: [{ reason: 'You are building this alone, so the shared cloud environment is off — your own machine is the setup.' }],
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
type: 'research',
|
|
61
|
+
label: 'Research starter',
|
|
62
|
+
summary: 'Extra agents to run the reading and the experiments the question needs.',
|
|
63
|
+
bundle: ['agents'],
|
|
64
|
+
byTeamShape: {
|
|
65
|
+
'small-team': {
|
|
66
|
+
bundle: ['agents', 'dev-box'],
|
|
67
|
+
adjustments: [{ reason: 'A small team gets one shared environment to build in, so nobody is debugging their own laptop.' }],
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
{ type: 'not-sure', label: 'Not sure', summary: '', bundle: [], byTeamShape: {} },
|
|
72
|
+
],
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
function why({ projType, teamShape, modules = null, bundles = BUNDLES }) {
|
|
76
|
+
const sandbox = { starterBundles: bundles, state: { projType, teamShape, modules } };
|
|
77
|
+
sandbox.globalThis = sandbox;
|
|
78
|
+
vm.createContext(sandbox);
|
|
79
|
+
vm.runInContext(slice + '\nvar __out = preselectReasons();', sandbox);
|
|
80
|
+
// Copy out of the vm realm: its Array has a different prototype, and assert/strict's
|
|
81
|
+
// deepEqual compares prototypes — so a correct result would otherwise fail as
|
|
82
|
+
// "same structure but not reference-equal".
|
|
83
|
+
return Array.from(sandbox.__out);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
test('game + small-team: no delta, so the type\'s own summary is the reason', () => {
|
|
87
|
+
// This is the exact combination from the live walk that surfaced the bug.
|
|
88
|
+
const out = why({ projType: 'game', teamShape: 'small-team' });
|
|
89
|
+
assert.deepEqual(out, ['Somewhere for players to gather, and a place to walk a change through before anyone plays it.']);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('a REAL adjustment still speaks for itself — the summary does not displace it', () => {
|
|
93
|
+
const out = why({ projType: 'research', teamShape: 'small-team' });
|
|
94
|
+
assert.equal(out.length, 1);
|
|
95
|
+
assert.match(out[0], /shared environment/);
|
|
96
|
+
assert.doesNotMatch(out[0], /reading and the experiments/, 'the rule\'s sentence wins, not the summary');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('a removal adjustment is reported the same way', () => {
|
|
100
|
+
const out = why({ projType: 'game', teamShape: 'solo' });
|
|
101
|
+
assert.equal(out.length, 1);
|
|
102
|
+
assert.match(out[0], /building this alone/);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('no team shape answered: falls through to the bundle, still explained', () => {
|
|
106
|
+
// tailoredRow() returns the unadjusted bundle with adjustments: [] when no shape is set,
|
|
107
|
+
// which before this change also produced an unexplained preselect.
|
|
108
|
+
const out = why({ projType: 'game', teamShape: null });
|
|
109
|
+
assert.deepEqual(out, ['Somewhere for players to gather, and a place to walk a change through before anyone plays it.']);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('once the owner has answered for themselves, the panel says nothing', () => {
|
|
113
|
+
// The pre-existing rule, and the one a careless fallback would have broken: handing back
|
|
114
|
+
// a reason for a toggle they just flipped reads as the panel arguing with them.
|
|
115
|
+
assert.deepEqual(why({ projType: 'game', teamShape: 'small-team', modules: [] }), []);
|
|
116
|
+
assert.deepEqual(why({ projType: 'game', teamShape: 'small-team', modules: ['agents'] }), []);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('an unknown type, or a bundle with no summary, says nothing rather than crashing', () => {
|
|
120
|
+
assert.deepEqual(why({ projType: 'no-such-type', teamShape: 'small-team' }), []);
|
|
121
|
+
assert.deepEqual(why({ projType: null, teamShape: 'small-team' }), []);
|
|
122
|
+
assert.deepEqual(why({ projType: 'not-sure', teamShape: 'small-team' }), [], 'empty summary is not a sentence');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('before the bundles read lands, nothing is claimed', () => {
|
|
126
|
+
assert.deepEqual(why({ projType: 'game', teamShape: 'small-team', bundles: null }), []);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('every sentence returned is a plain string, so the paint site can escape it directly', () => {
|
|
130
|
+
for (const t of ['game', 'research']) {
|
|
131
|
+
for (const s of ['small-team', 'solo', null]) {
|
|
132
|
+
for (const line of why({ projType: t, teamShape: s })) {
|
|
133
|
+
assert.equal(typeof line, 'string', `${t}/${s} yielded a non-string`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
});
|