Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 93 additions & 11 deletions src/opendox/web/views/lens.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,60 @@
// write-nothing, TEXT the human places.
export const STAGING_SEED_ROUTE = "/actions/staging-seed";

// WHETHER A BINDING ANSWERS A ROUTE (R1Q19 (a), plan 034 T088). The two seed
// routes above are answered by nothing openDox ships: they exist where a host
// contributes them, and a standalone install has no host. So a control that
// calls one is OFFERED ONLY WHERE A BINDING ANSWERS IT, and the census's `?` row
// for this file stands until R1Q19 (b) moves the controls into a view extension.
//
// `/capabilities` already says which routes a host contributes
// (`views.contributed_routes`). `serve.build_server()` builds that list from the
// very table its POST dispatch consults, so the lens reads its answer off the
// payload the shell has already fetched: no second fetch and no new field.
//
// This mirrors `RouteBinding.matches` (`src/route_extension.py`): the method,
// then the path, exact or under a prefix, and a GET binding answers HEAD too. A
// test holds it to `route_extension.match()` itself.
//
// IT FAILS CLOSED, and on the WHOLE manifest, as `manifestRoutes()` does: a
// payload that is absent, that carries no manifest, or in which ANY route is not
// the shape the server would have accepted answers no for every route. A list
// with one malformed entry beside a good one is a list this cannot vouch for, so
// the good one is not taken on trust (Copilot, #65). That shape is
// `RouteBinding.__post_init__`'s, entry by entry; a test holds the two to each
// other. Unlike `manifestRoutes()` it never throws, because it gates a control,
// and a control that cannot be justified is left out rather than made a reason
// to lose the whole lens (`probeCapabilityPath`'s own posture: a missing payload
// is an unmet requirement, not an error).

// `route_extension.METHODS`.
const ROUTE_METHODS = ["GET", "HEAD", "POST"];

Check warning on line 90 in src/opendox/web/views/lens.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

`ROUTE_METHODS` should be a `Set`, and use `ROUTE_METHODS.has()` to check existence or non-existence.

See more on https://sonarcloud.io/project/issues?id=opensoft_openDox-code&issues=AaDvoaB0kK9GZAB4ram0&open=AaDvoaB0kK9GZAB4ram0&pullRequest=65

// One manifest entry, held to what `RouteBinding.__post_init__` accepts: a known
// method, a pattern rooted at a slash with no query or fragment, a boolean
// `is_prefix`, and a prefix that ends in a slash.
function wellFormedRoute(route) {
return route !== null && typeof route === "object"
&& ROUTE_METHODS.includes(route.method)
&& typeof route.pattern === "string" && route.pattern.startsWith("/")
&& !/[?#]/.test(route.pattern)
&& typeof route.is_prefix === "boolean"
&& (!route.is_prefix || route.pattern.endsWith("/"));
}

function routeMatches(route, method, path) {
const sameMethod = route.method === method
|| (route.method === "GET" && method === "HEAD");
if (!sameMethod) return false;
return route.is_prefix ? path.startsWith(route.pattern) : path === route.pattern;
}

export function bindingAnswers(capabilities, method, path) {
const routes = capabilities?.views?.contributed_routes;
if (!Array.isArray(routes) || !routes.every(wellFormedRoute)) return false;
return routes.some((route) => routeMatches(route, method, path));
}

//: How many of the ranked relationships the rail offers. Enough to choose
//: from, short enough to read; the whole list is the model's `pairs`.
const RELATIONSHIPS_SHOWN = 12;
Expand Down Expand Up @@ -499,8 +553,11 @@
const table = el("table", "lensmatrix");
table.setAttribute("aria-label", "Keyword membership matrix (flat view of the bullseye)");
const head = el("tr");
// THE SELECTION COLUMN IS OFFERED WITH THE SELECTION (R1Q19 (a), T088): where no
// binding answers the staging seed there is nothing to select for, so the
// column is not drawn at all rather than left as an empty gutter.
const pickHead = el("th", "pickcol");
head.appendChild(pickHead);
if (ctx.pickDoc) head.appendChild(pickHead);
head.appendChild(el("th", null, "#"));
// THE MATRIX'S OWN COLUMN HEADING (Copilot round 2). It was the literal
// `doc` — openxFactory's short spelling of the source station, rendered as a
Expand Down Expand Up @@ -535,17 +592,17 @@
// SELECTION (Brett, 2026-08-08: "I should have a checkbox on each one to
// generate the seed from checked"). The row's own box, so the set is
// built where the evidence is read rather than retyped somewhere else.
const pick = el("td", "pickcol");
if (ctx.pickDoc) {
const pick = el("td", "pickcol");
const box = el("input");
box.type = "checkbox";
box.checked = ctx.isPicked(r.document);
box.title = "include " + r.document + " in the drafted seed";
box.setAttribute("aria-label", "select " + r.document);
box.addEventListener("change", () => ctx.pickDoc(r.document, box.checked));
pick.appendChild(box);
tr.appendChild(pick);
}
tr.appendChild(pick);
// the matrix IS the radar's legend: #N here is the number on that dot
tr.appendChild(el("td", "docnum", String(r.number)));
tr.appendChild(el("td", null, String(r.document).split("/").pop() || r.document));
Expand All @@ -562,7 +619,8 @@
const td = el("td", "empty", model.checked.length
? "no " + display.many(SOURCE) + " match the checked keywords"
: "this view carries no " + display.many(SOURCE));
td.setAttribute("colspan", String(model.checked.length + 4));
// the selection column, when it is drawn, is one of the four fixed columns
td.setAttribute("colspan", String(model.checked.length + (ctx.pickDoc ? 4 : 3)));
tr.appendChild(td);
table.appendChild(tr);
}
Expand Down Expand Up @@ -812,8 +870,12 @@
pane.appendChild(el("div", "drill-note",
"Each row is the " + ctx.display.many(SOURCE) + " carried by one "
+ "combination of repositories. "
+ "Drill in scopes the whole dashboard to that set; the seed drafts a "
+ "candidate-register entry for a set two or more repositories share."));
+ "Drill in scopes the whole dashboard to that set"
// the sentence about the seed is there only where the seed is (R1Q19 (a))
+ (ctx.onSeed
? "; the seed drafts a candidate-register entry for a set two or more "
+ "repositories share."
: ".")));

// Group the dots by their repository combination; the centre is the
// combination that IS the whole checked set.
Expand Down Expand Up @@ -872,6 +934,8 @@
+ " repositories share (text you merge; nothing is written)";
seed.addEventListener("click", () => ctx.onSeed(row.keywords, seed));
seed.dataset.carriers = String(row.matchCount);
// the hook the browser half reads the control by (T096, AT-R1 step 6)
seed.dataset.seedAction = "dtn-seed";
acts.appendChild(seed);
}
line.appendChild(acts);
Expand Down Expand Up @@ -982,6 +1046,8 @@
// rather than implying a second, separate seed.
ctx.hasDraft() ? "re-draft" : "draft staging seed");
draft.type = "button";
// the hook the browser half reads the control by (T096, AT-R1 step 6)
draft.dataset.seedAction = "staging-seed";
draft.disabled = !n;
draft.title = "Draft a staging-queue fragment covering the selected "
+ ctx.display.many(SOURCE) + " and the terms they share. Nothing is "
Expand Down Expand Up @@ -1042,7 +1108,7 @@

// ---- view assembly ----

export function renderLens(root, snapshot, opts) {

Check failure on line 1111 in src/opendox/web/views/lens.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 19 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=opensoft_openDox-code&issues=AaDvmv4lcHQtsgtTmJeS&open=AaDvmv4lcHQtsgtTmJeS&pullRequest=65
const options = opts || {};
// The gate capability verdict (from app.js's single /capabilities probe) and
// an injectable fetcher (test seam). Gate-off => the plan panel stays
Expand All @@ -1058,6 +1124,14 @@
// S4). Null where no gate column is registered, which is exactly what a
// student install is — the plan panel then renders plan-only.
const mountLensGate = options.mountLensGate || null;
// WHICH OF THE TWO SEED ACTIONS THIS PLANE OFFERS (R1Q19 (a), T088). Each is
// offered only where a binding answers its route, and read off the SAME
// `caps` the gate verdict above is: the D10 read-only projection keeps the
// `views` block, so a composed view asks the same question a single one does.
// Standalone, no host has contributed either route, so neither is offered, and
// nothing whose only use is to feed one is offered either.
const registerSeedOffered = bindingAnswers(caps, "POST", DTN_SEED_ROUTE);
const stagingSeedOffered = bindingAnswers(caps, "POST", STAGING_SEED_ROUTE);
// THE STATION VOCABULARY, from the shell's one read of `/capabilities`
// (§ 4.3 step 3). `app.js` has passed `display: ctx.display` on this mount
// since slice S7; this view is the last one in the bundle to read it.
Expand Down Expand Up @@ -1252,7 +1326,7 @@
// affordance is legitimately available on the read-only composed view
// (nothing is written, exactly as the neutrality lane drafts seeds a
// human merges).
onSeed: vocab.id === "repositories" && composed
onSeed: registerSeedOffered && vocab.id === "repositories" && composed
? async (repositories, button) => {
const label = button.textContent;
button.disabled = true;
Expand Down Expand Up @@ -1295,10 +1369,18 @@
// ---- the matrix selection (Brett, 2026-08-08) ----
isPicked(doc) { return state.picked.has(doc); },
pickedCount() { return state.picked.size; },
pickDoc(doc, on) {
if (on) state.picked.add(doc); else state.picked.delete(doc);
draw();
},
// NULL WHERE NO BINDING ANSWERS THE STAGING SEED (R1Q19 (a), T088). The
// selection exists to feed that one action, and every site that offers it is
// guarded by this member: the matrix's checkbox column and its select-all,
// the bullseye's clickable dots, the `picked` mark and the pick bar. So
// nulling it withdraws the whole selection along with the button, and a
// standalone lens never says "tick documents to draft from them".
pickDoc: stagingSeedOffered
? (doc, on) => {
if (on) state.picked.add(doc); else state.picked.delete(doc);
draw();
}
: null,
pickDocs(docs, on) {
for (const doc of docs || []) {
if (on) state.picked.add(doc); else state.picked.delete(doc);
Expand Down
10 changes: 5 additions & 5 deletions tests/fixtures/web_boundary_census.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ totals:
A: {files: 26, loc: 18073}
B: {files: 1, loc: 73}
C: {files: 14, loc: 12587}
"?": {files: 1, loc: 1495}
"?": {files: 1, loc: 1577}
files:
- path: app.js
class: A
Expand Down Expand Up @@ -335,8 +335,8 @@ files:

- path: views/lens.js
class: "?"
loc: 1495
note: "VOCABULARIES is a class-C vocabulary table; DTN_SEED_ROUTE and STAGING_SEED_ROUTE are answered ONLY by openxFactory's adapter (a THIRD column, note \u00a7 3.3). OUT OF SCOPE FOR S4 and still \"?\": none of Q1-Q5 rules on it (\u00a7 5's S4 row: 'lens.js is OUT OF SCOPE ... it stays \"?\" as assertion 1's one declared, ruled-later exception'). The S1 note read 'splits at S4', which contradicted that row; corrected by S4, which is the slice that would have had to do the splitting. Its two lane sites are assertion 2's own declared exception. loc 1524 -> 1435: the execute affordance, the organizer-evidence form and the outcome panel left for views/gate-lens.js with the two gate routes they called. loc 1435 -> 1495 when this file was WIRED ONTO THE DISPLAY FACET: app.js had passed it `display: ctx.display` since slice S7 and it was the last view in the bundle that never read it, so twenty-six rendered sites naming the SOURCE, GROUPING and SUBMISSION stations now resolve BY ROLE (sixteen in the first pass; the solo-term toggle, the matrix select-all, the drill rows, the staging-seed panel's four refusal captions and the two add-as-<grouping> captions on Copilot's round-1 findings, and the matrix's own `doc` column heading on its round-2 finding — a word no sweep in this tree could have found, since only `docs` is watched) and three seam keys (`tab`, `scopeKind`) come off `views/display.js`'s own TAB_IDS / SCOPE_KINDS rather than being spelled a second time. THE CLASS DOES NOT MOVE and this act does not rule on it: reading the facet decided nothing about app.js's class either, and the two lane sites, the two routes and the VOCABULARIES table are untouched, so every sentence above still holds. Its module-level VOCABULARIES notes are the one place a station noun survives, and only two of them: they are evaluated at import and are now functions of the facet, called at the render that reads them."
loc: 1577
note: "VOCABULARIES is a class-C vocabulary table; DTN_SEED_ROUTE and STAGING_SEED_ROUTE are answered ONLY by openxFactory's adapter (a THIRD column, note \u00a7 3.3). OUT OF SCOPE FOR S4 and still \"?\": none of Q1-Q5 rules on it (\u00a7 5's S4 row: 'lens.js is OUT OF SCOPE ... it stays \"?\" as assertion 1's one declared, ruled-later exception'). The S1 note read 'splits at S4', which contradicted that row; corrected by S4, which is the slice that would have had to do the splitting. Its two lane sites are assertion 2's own declared exception. loc 1524 -> 1435: the execute affordance, the organizer-evidence form and the outcome panel left for views/gate-lens.js with the two gate routes they called. loc 1435 -> 1495 when this file was WIRED ONTO THE DISPLAY FACET: app.js had passed it `display: ctx.display` since slice S7 and it was the last view in the bundle that never read it, so twenty-six rendered sites naming the SOURCE, GROUPING and SUBMISSION stations now resolve BY ROLE (sixteen in the first pass; the solo-term toggle, the matrix select-all, the drill rows, the staging-seed panel's four refusal captions and the two add-as-<grouping> captions on Copilot's round-1 findings, and the matrix's own `doc` column heading on its round-2 finding — a word no sweep in this tree could have found, since only `docs` is watched) and three seam keys (`tab`, `scopeKind`) come off `views/display.js`'s own TAB_IDS / SCOPE_KINDS rather than being spelled a second time. THE CLASS DOES NOT MOVE and this act does not rule on it: reading the facet decided nothing about app.js's class either, and the two lane sites, the two routes and the VOCABULARIES table are untouched, so every sentence above still holds. Its module-level VOCABULARIES notes are the one place a station noun survives, and only two of them: they are evaluated at import and are now functions of the facet, called at the render that reads them. loc 1495 -> 1577 on plan 034 T088 (R1Q19 (a), openxFactory#656 comment 5850003126): the two seed actions are offered only where a binding answers their routes, read off the `/capabilities` `views.contributed_routes` the shell already fetched, so a standalone install offers neither, and the selection that exists only to feed the staging seed goes with it. THE CLASS DOES NOT MOVE: the two route literals, `DTN_SEED_ROUTE` and `STAGING_SEED_ROUTE`, and their `route_ownership_exceptions` entry are untouched, and the row stays \"?\" until R1Q19 (b), which moves the two controls into a view extension openxFactory contributes."

- path: views/lineage.js
class: C
Expand Down Expand Up @@ -426,7 +426,7 @@ files:
declared_transitional_rows:
- path: views/lens.js
class: "?"
until: "a future ruling on views/lens.js's two openxFactory-lane routes"
until: "R1Q19 (b): the two controls move into a view extension that openxFactory contributes, after release 1 (openxFactory#656 comment 5850003126 rules (a) now and (b) later; plan 034 T088 realizes (a), and the row stands until (b))"
reason: >-
none of Q1-Q5 rules on this file (openxFactory#656 comment 5642758731):
DTN_SEED_ROUTE and STAGING_SEED_ROUTE are answered ONLY by openxFactory's
Expand Down Expand Up @@ -456,7 +456,7 @@ route_ownership_exceptions:
routes:
- "/actions/dtn-seed"
- "/actions/staging-seed"
until: "a future ruling on views/lens.js's two openxFactory-lane routes"
until: "R1Q19 (b): the two controls move into a view extension that openxFactory contributes, after release 1 (openxFactory#656 comment 5850003126 rules (a) now and (b) later; plan 034 T088 realizes (a), and the row stands until (b))"
reason: >-
the same ruling gap `declared_transitional_rows` names: none of Q1-Q5
rules on views/lens.js, so its two openxFactory-lane sites do not clear on
Expand Down
11 changes: 10 additions & 1 deletion tests/test_display_facet.py
Original file line number Diff line number Diff line change
Expand Up @@ -1388,9 +1388,18 @@ def test_the_lens_renders_the_declared_words_and_none_of_openxfactorys(tmp_path)
submission: {{ one: "filing", many: "filings", short: "filings",
label: "filings" }} }},
}} }});
// A HOST THAT ANSWERS BOTH SEED ROUTES. Since plan 034 T088 (R1Q19 (a)) the
// lens offers the two seed actions, and the selection that feeds them, only where
// a binding answers them, so this drives the lens as it renders under such a
// host: the pick bar is a pane whose words this reads. The standalone lens,
// which offers neither, is `tests/test_lens_seed_actions.py`'s.
const caps = {{ views: {{ contributed_routes: [
{{ method: "POST", pattern: "/actions/dtn-seed", is_prefix: false }},
{{ method: "POST", pattern: "/actions/staging-seed", is_prefix: false }},
] }} }};
function page(display) {{
const root = new Node("div");
L.renderLens(root, snap, {{ display }});
L.renderLens(root, snap, {{ display, caps }});
// every rendered string on the page: text nodes AND the title/aria-label
// attributes, because half this view's station words are on a tooltip.
const nodes = flatten(root);
Expand Down
Loading
Loading