From 6dc28a26b1320f0401b01fe9068a0a1eecf8e8bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kasper=20H=C3=A4gele?= Date: Thu, 10 Sep 2026 11:11:27 +0200 Subject: [PATCH 1/4] feat(web-standalone): give Step 1 a readable hierarchy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four text roles in the panel shared one voice: field label, the explanation under it, the incident toggle and the km unit labels were all rgb(158,182,192) within a pixel of each other. The hierarchy was inverted too — p.hint had no colour of its own, so it inherited --text at .85 opacity and rendered brighter than the --muted label above it. The explanation outshouted the field it explained. Three roles, three treatments. The label leads (--text, semibold). A new .field-help carries the static explanation in --faint, a step below --muted, and is never overwritten, so it survives typing. p.hint becomes the app's reply channel: monospaced with an accent rule down the left, so "Parsed observers: ..." can no longer be read as the opening words of the next field's label, which is what happened when it replaced the help text mid-panel. Also: panels had no rule for `a`, so the browser default #0000EE landed on a dark teal card; links take the accent now. The literal backticks in "`PREFIX` or `PREFIX:count`" shipped as UI text and become real inline samples. And three status branches that told you to click the button you were looking at are gone — that is instruction, it lives in .field-help now, and the reply channel is for what the app did. Closes #132 Co-Authored-By: Claude Opus 5 --- web-standalone/changelog.json | 6 ++ web-standalone/index.html | 111 ++++++++++++++++++++++++++++------ 2 files changed, 98 insertions(+), 19 deletions(-) diff --git a/web-standalone/changelog.json b/web-standalone/changelog.json index 2762e96..c60d29e 100644 --- a/web-standalone/changelog.json +++ b/web-standalone/changelog.json @@ -1,4 +1,10 @@ [ + { + "id": "2026-09-10-step-1-reads-properly", + "date": "2026-09-10", + "title": "Step 1 stops reading as one grey block", + "body": "Every line in Step 1 was the same colour and within a pixel of the same size, and the explanation under a field was actually brighter than the label above it. Field names now lead, their explanation sits quietly underneath and stays there after you type, and what the tool says back gets its own monospaced line with a green rule beside it. The link to the spam detector was rendering in the browser's default blue on a dark panel; it is readable now. The prompt under the search button no longer repeats the sentence directly above it." + }, { "id": "2026-09-10-add-prefix-adds-one-observer", "date": "2026-09-10", diff --git a/web-standalone/index.html b/web-standalone/index.html index 47676a6..26010a1 100644 --- a/web-standalone/index.html +++ b/web-standalone/index.html @@ -22,6 +22,9 @@ --panel-border: rgba(143, 187, 201, 0.18); --text: #f3fbff; --muted: #9eb6c0; + /* Help text sits a step below --muted so a field's label always reads + louder than the sentence explaining it (#132). */ + --faint: #6b8794; --accent: #00b894; --accent-2: #ff9f43; --shadow: 0 18px 50px rgba(0, 0, 0, 0.28); @@ -415,11 +418,72 @@ } .step-panel .import-row button { margin-top: 0; } /* Tighter help text to keep the step panels compact. */ + /* Three roles under a field, and they have to look like three things + (#132). Before this they were all one colour within a pixel of each + other, and p.hint inherited --text at .85 opacity, which rendered + BRIGHTER than the --muted label above it: the explanation outshouted + the field it explained. + + .field-help static explanation. Never overwritten, so it is still + there after you type. Quietest of the three. + p.hint what the app says back. Monospaced with an accent rule so + it cannot be mistaken for the next field's label, which is + exactly what happened when "Parsed observers: ..." replaced + the help text and sat flush against the label below. */ + .step-panel p.field-help { + margin: 5px 0 0; + font-size: 0.75rem; + line-height: 1.45; + color: var(--faint); + } + .step-panel p.hint:empty { + display: none; + } .step-panel p.hint { margin: 6px 0 0; - font-size: 0.78rem; - line-height: 1.4; - opacity: 0.85; + font-size: 0.72rem; + line-height: 1.45; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + color: var(--muted); + padding-left: 9px; + border-left: 2px solid var(--accent); + } + /* A caveat is not the same as a result (#54, #85). */ + .step-panel p.hint.is-caveat { + border-left-color: var(--accent-2); + } + /* A field group needs air before the next one starts, or the reply line + still reads as the next label's opening words (#132). */ + .step-panel p + label, + .step-panel p + .radius-row { + margin-top: 14px; + } + .step-panel .label-optional { + color: var(--faint); + font-weight: 400; + font-size: 0.94em; + } + /* An inline example of what to type, instead of the literal backticks + that used to ship as UI text. */ + .step-panel .sample { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.92em; + background: rgba(255, 255, 255, 0.06); + border-radius: 4px; + padding: 1px 5px; + color: var(--muted); + } + /* Nothing styled links inside the panels, so the browser default #0000EE + landed on a dark teal card (#132). */ + .step-panel a, + .utility-panel a { + color: var(--accent); + text-underline-offset: 2px; + text-decoration-color: rgba(0, 184, 148, 0.45); + } + .step-panel a:hover, + .utility-panel a:hover { + text-decoration-color: var(--accent); } .dot { @@ -574,9 +638,10 @@ .step-panel label, .utility-panel label { display: block; - color: var(--muted); - font-size: 0.84rem; - margin-bottom: 6px; + color: var(--text); + font-weight: 600; + font-size: 0.845rem; + margin-bottom: 5px; } .source-panel input, @@ -1456,20 +1521,30 @@

Clues + Regions

-

Entry hops from mc-spamdetector.nl become the first-hop prefixes, weighted by packets; then it finds the region.

+

+ Entry hops from mc-spamdetector.nl + become the first-hop prefixes, weighted by packets; then it finds the region. +

+

- + -

`PREFIX` or `PREFIX:count` — 2/4/6 hex, comma-separated. Count = times heard (weight).

- +

+ Repeaters that heard the source directly. 2C or + 2C:4, comma separated — 2, 4 or 6 hex, and the number is + how often it was heard. +

+

+ -

Weaker, wider clue. Down-weighted automatically.

+

Heard a first-hop relay instead. Weaker and wider, so it counts for less.

-

Resolves the prefixes globally and picks the strongest geographic cluster.

+

Resolves the prefixes globally and picks the strongest geographic cluster.

+

@@ -3977,7 +4052,7 @@

Unknown Node Estimate

function renderParsedClues(weightedPrefixes) { if (!weightedPrefixes.length) { - prefixStatus.textContent = "Step 1: enter observer prefixes. Use `PREFIX` or `PREFIX:VALUE`."; + prefixStatus.textContent = ""; } else { prefixStatus.textContent = `Parsed observers: ${weightedPrefixes.map((item) => `${item.prefix}:${item.weight.toFixed(2).replace(/\.00$/, "")}`).join(", ")}`; } @@ -4609,7 +4684,7 @@

Unknown Node Estimate

function clearMapState() { resetResolvedState(true); - discoverStatus.textContent = "Map cleared. Step 1: enter clues, then find candidate clusters."; + discoverStatus.textContent = "Map cleared."; renderParsedClues(parseWeightedPrefixes(prefixInput.value)); updateWorkflowSteps("clues"); syncControlStates(); @@ -4626,9 +4701,7 @@

Unknown Node Estimate

return; } resetResolvedState(true); - discoverStatus.textContent = weightedPrefixes.length - ? "Step 1: click “Find Best Matching Region” to resolve these clues." - : "Step 1: enter observer prefixes, then find candidate clusters."; + discoverStatus.textContent = ""; updateWorkflowSteps("clues"); syncControlStates(); } @@ -4740,8 +4813,8 @@

Unknown Node Estimate

const weightedPrefixes = parseWeightedPrefixes(prefixInput.value); renderParsedClues(weightedPrefixes); discoverStatus.textContent = weightedPrefixes.length - ? `Step 1: click “Find Best Matching Region” to resolve these clues with a ${getRegionRadiusKm().toFixed(0)} km cluster radius.` - : "Step 1: enter observer prefixes, then find candidate clusters."; + ? `Cluster radius set to ${getRegionRadiusKm().toFixed(0)} km for the next run.` + : ""; updateWorkflowSteps("clues"); syncControlStates(); }); From 423cf8be3cd041b60615b576650010f913003453 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kasper=20H=C3=A4gele?= Date: Thu, 10 Sep 2026 11:15:16 +0200 Subject: [PATCH 2/4] feat(web-standalone): two ways into Step 1, second-hop folded away MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spam-detector importer is the other way to start a run, not a footnote to the first one, but it shipped as a
above the prefix field: collapsed it read as a stray grey line, expanded it pushed the primary field down ~120px, and either way the exception route opened the panel. It becomes a segmented control — Prefixes | Spam incident — with the same shape in both panes: label, field, help, one primary button. Loading an incident fills the prefix field and switches back to Prefixes, and says where the values came from in the reply channel next to them ("From incident #2148: 8 entry hops, weighted by packets"), because the pane that used to carry that message is hidden by then. Nothing arrives invisibly and it is still editable before the search runs. Second-hop prefixes fold into a disclosure, closed by default: a run is first-hop only most of the time. 2nd-hop km travels with them, since it governs nothing else; Cluster km stays out, since it governs every run. A deep link carrying second-hop clues opens the fold, so a shared scene never hides part of itself. Closes #133 Co-Authored-By: Claude Opus 5 --- web-standalone/changelog.json | 6 ++ web-standalone/index.html | 183 +++++++++++++++++++++++++--------- 2 files changed, 141 insertions(+), 48 deletions(-) diff --git a/web-standalone/changelog.json b/web-standalone/changelog.json index c60d29e..74a37ae 100644 --- a/web-standalone/changelog.json +++ b/web-standalone/changelog.json @@ -1,4 +1,10 @@ [ + { + "id": "2026-09-10-two-ways-into-step-1", + "date": "2026-09-10", + "title": "Two ways to start, and second-hop out of the way", + "body": "Loading a spam-detector incident was tucked into a fold above the prefix field, so the exception route opened the panel. It is now one of two buttons at the top of Step 1: type prefixes, or pick an incident. Loading one fills the prefix field and switches you back to it, with a line saying which incident the values came from, so you can still change them before searching. The second-hop field folds away until you ask for it, together with its own km setting, because most searches never use it. A shared link that carries second-hop prefixes opens that fold for you." + }, { "id": "2026-09-10-step-1-reads-properly", "date": "2026-09-10", diff --git a/web-standalone/index.html b/web-standalone/index.html index 26010a1..47a8d2d 100644 --- a/web-standalone/index.html +++ b/web-standalone/index.html @@ -374,31 +374,64 @@ three controls on one row left the list unreadable. */ /* Spam-detector import folded under one line (#117): the first-hop field is what a first visit needs, the import is the shortcut. */ - .import-details { - margin-bottom: 12px; + /* Two ways into Step 1, as a segmented control (#133). */ + .entry-tabs { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 3px; + padding: 3px; + border-radius: 12px; + background: rgba(255, 255, 255, 0.045); + border: 1px solid rgba(255, 255, 255, 0.08); + margin-bottom: 14px; } - .import-details summary { - cursor: pointer; + /* Beats `.step-panel button` (0,1,1) on the accent fill and the margin. */ + .step-panel .entry-tabs .entry-tab { + margin: 0; + border: 0; + border-radius: 9px; + padding: 7px 6px; + font-size: 0.78rem; + font-weight: 500; + background: transparent; + /* --faint is for text that recedes under a label; a tab is a control you + have to be able to read and click. */ color: var(--muted); - font-size: 0.84rem; - padding: 8px 0; - list-style: none; + box-shadow: none; } - .import-details summary::-webkit-details-marker { - display: none; + .step-panel .entry-tabs .entry-tab[aria-pressed="true"] { + background: rgba(0, 184, 148, 0.18); + color: var(--text); + font-weight: 600; + box-shadow: inset 0 0 0 1px rgba(0, 184, 148, 0.35); } - .import-details summary::before { - content: "▸"; - display: inline-block; - width: 1em; - transition: transform 0.15s ease; + .step-panel .entry-tabs .entry-tab:hover { + color: var(--text); + filter: none; } - .import-details[open] summary::before { - transform: rotate(90deg); + + /* The uncommon half of Step 1, closed until asked for (#133). */ + .hop2-details { + margin-top: 14px; + border-top: 1px solid rgba(255, 255, 255, 0.08); + padding-top: 12px; } - .import-details[open] summary { - margin-bottom: 8px; + .hop2-details summary { + cursor: pointer; + list-style: none; + color: var(--muted); + font-size: 0.8rem; + } + .hop2-details summary::-webkit-details-marker { display: none; } + .hop2-details summary::before { + content: "+ "; + color: var(--accent); + font-weight: 600; } + .hop2-details[open] summary::before { content: "– "; } + .hop2-details[open] summary { margin-bottom: 12px; } + .hop2-details summary:hover { color: var(--text); } + .step-panel select.import-select { width: 100%; border-radius: 12px; @@ -1512,39 +1545,62 @@ Step 1

Clues + Regions

-
- Load a spam-detector incident instead + +
+ + +
+ + + +
+ +

- Entry hops from mc-spamdetector.nl - become the first-hop prefixes, weighted by packets; then it finds the region. + Repeaters that heard the source directly. 2C or + 2C:4, comma separated — 2, 4 or 6 hex, and the number is + how often it was heard.

-

-
- - -

- Repeaters that heard the source directly. 2C or - 2C:4, comma separated — 2, 4 or 6 hex, and the number is - how often it was heard. -

-

- - -

Heard a first-hop relay instead. Weaker and wider, so it counts for less.

-
- - +

+ + +
+ Add second-hop prefixes + + +

Heard a first-hop relay instead. Weaker and wider, so it counts for less.

+
+ +
+
+ +
+ +
+ +

Resolves the prefixes globally and picks the strongest geographic cluster.

+

- -

Resolves the prefixes globally and picks the strongest geographic cluster.

-

@@ -5725,6 +5781,32 @@

Unknown Node Estimate

// side fills the field and runs discovery, as the `prefixes` deep link // does. Second-hop stays empty on purpose: it is a weaker clue the // operator rarely enters, and the estimate does not score it (#46). + // Which way into Step 1 is showing (#133). Loading an incident switches + // back to Prefixes with the values filled in, so nothing arrives invisibly. + const entryTabPrefixes = document.getElementById("entry-tab-prefixes"); + const entryTabIncident = document.getElementById("entry-tab-incident"); + const entryPanePrefixes = document.getElementById("entry-pane-prefixes"); + const entryPaneIncident = document.getElementById("entry-pane-incident"); + const hop2Details = document.getElementById("hop2-details"); + + function showEntry(which) { + const incident = which === "incident"; + if (entryTabPrefixes) entryTabPrefixes.setAttribute("aria-pressed", incident ? "false" : "true"); + if (entryTabIncident) entryTabIncident.setAttribute("aria-pressed", incident ? "true" : "false"); + if (entryPanePrefixes) entryPanePrefixes.hidden = incident; + if (entryPaneIncident) entryPaneIncident.hidden = !incident; + } + + if (entryTabPrefixes) entryTabPrefixes.addEventListener("click", () => showEntry("prefixes")); + if (entryTabIncident) entryTabIncident.addEventListener("click", () => showEntry("incident")); + + // A shared scene must never hide part of itself, so second-hop clues open + // the disclosure that holds them. + function revealHop2IfUsed() { + if (!hop2Details || !prefix2Input) return; + if (prefix2Input.value.trim()) hop2Details.open = true; + } + const incidentSelect = document.getElementById("incident-select"); const incidentInput = document.getElementById("incident-input"); const incidentLoadButton = document.getElementById("incident-load-button"); @@ -5772,8 +5854,7 @@

Unknown Node Estimate

return; } if (incidentStatus) incidentStatus.textContent = `Loading incident #${id} from the spam detector...`; - const importDetails = document.getElementById("import-details"); - if (importDetails) importDetails.open = true; + showEntry("incident"); if (incidentLoadButton) incidentLoadButton.disabled = true; try { const response = await fetch(`/proxy/spamdetector/incident/${id}`); @@ -5785,9 +5866,12 @@

Unknown Node Estimate

if (incidentSelect) incidentSelect.value = [...incidentSelect.options].some((o) => o.value === id) ? id : ""; applyPrefixInput(); const count = data.hops.length; - if (incidentStatus) { - incidentStatus.textContent = `Incident #${id}: ${count} entry hop${count === 1 ? "" : "s"} loaded as first-hop prefixes, weighted by packets. Second-hop left empty.`; - } + showEntry("prefixes"); + if (incidentStatus) incidentStatus.textContent = ""; + // applyPrefixInput() has just written "Parsed observers: ..." here; + // where they came from is the more useful thing to say until the + // operator edits them. + prefixStatus.textContent = `From incident #${id}: ${count} entry hop${count === 1 ? "" : "s"}, weighted by packets. Second-hop left empty.`; await runCaseDiscovery(); } catch (error) { if (incidentStatus) incidentStatus.textContent = `Incident #${id} did not load: ${error.message}`; @@ -5852,7 +5936,10 @@

Unknown Node Estimate

} prefixInput.value = prefixes; const prefixes2 = params.get("prefixes2"); - if (prefixes2 && prefix2Input) prefix2Input.value = prefixes2; + if (prefixes2 && prefix2Input) { + prefix2Input.value = prefixes2; + revealHop2IfUsed(); + } const hop2 = Number.parseFloat(params.get("hop2")); if (Number.isFinite(hop2) && hop2 > 0 && hop2RadiusInput) hop2RadiusInput.value = String(hop2); const cluster = Number.parseFloat(params.get("cluster")); From 3b4e6ca722cb44082564591a5a3879ff53e67801 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kasper=20H=C3=A4gele?= Date: Thu, 10 Sep 2026 11:20:54 +0200 Subject: [PATCH 3/4] feat(web-standalone): shorter observer rows, scannable cluster status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rows spent three lines on `51.99951, 4.39375` — one-metre precision in a list where you choose between candidates kilometres apart — and never showed the number the automatic pick is actually made on. dedupeByPrefix() falls back to "nearest the cluster centre" when proven links do not separate a prefix (#54), the status says so, and the list gave you no way to see it. Rows now carry `centre 0.1 km · reach 4.3 km`, with the exclusion reason as a short tag beside the name (full wording in its title) instead of a third line. Distance is computed at render, so dragging an observer (#33b) moves it too. Coordinates stay in the node's map popup, where a position belongs. Chasing the row height turned up the real cause: `.step-panel input` (0,1,1) outspecified `.observer-weight` (0,1,0), so the weight field kept the panel's 12px padding and stood 48px tall while its text was 30px. The field, not the content, set the 60px row. Measured on the same 8-prefix run near Delft: row 60px → 42px, editor 949px → 711px, left column 2145px → 1797px. The cluster status concatenated six caveats into one paragraph — eight sentences of prose in a 300px column. Same six facts, one per line, count first; nothing dropped, per AGENTS.md. The two that are inference rather than measurement take the second accent (#54, #85). Closes #134 Co-Authored-By: Claude Opus 5 --- web-standalone/changelog.json | 6 ++ web-standalone/index.html | 173 +++++++++++++++++++++++++--------- 2 files changed, 136 insertions(+), 43 deletions(-) diff --git a/web-standalone/changelog.json b/web-standalone/changelog.json index 74a37ae..a3527fd 100644 --- a/web-standalone/changelog.json +++ b/web-standalone/changelog.json @@ -1,4 +1,10 @@ [ + { + "id": "2026-09-10-shorter-observer-list", + "date": "2026-09-10", + "title": "The observer list says what you are choosing on", + "body": "Every row spent three lines on coordinates to five decimals, which is one-metre precision in a list where you pick between repeaters kilometres apart. Rows now show how far each one is from the centre of the cluster, which is the number the automatic pick is actually made on, next to its proven reach. The reason a row is unticked moved up beside the name as a short tag. Coordinates are still in the node's popup on the map. A row went from 60 to 42 pixels, so the whole column is about 350 pixels shorter. The cluster summary was one paragraph of six caveats; it is now six lines with the count in front, and the two that are a guess rather than a measurement are marked." + }, { "id": "2026-09-10-two-ways-into-step-1", "date": "2026-09-10", diff --git a/web-standalone/index.html b/web-standalone/index.html index 47a8d2d..5e8d4dc 100644 --- a/web-standalone/index.html +++ b/web-standalone/index.html @@ -924,7 +924,7 @@ .observer-row { display: grid; - grid-template-columns: auto minmax(0, 1fr) 64px; + grid-template-columns: auto minmax(0, 1fr) 58px; gap: 8px; align-items: center; padding: 5px 8px; @@ -933,6 +933,26 @@ border: 1px solid rgba(255, 255, 255, 0.07); } + .cluster-status .cluster-headline { + margin: 0 0 10px; + font-size: 0.845rem; + line-height: 1.45; + color: var(--text); + } + /* The count leads each line so the list can be scanned down its left edge + instead of read as prose (#134). */ + .cluster-status p.hint { + display: flex; + gap: 8px; + align-items: baseline; + } + .cluster-status p.hint b { + flex: 0 0 auto; + color: var(--text); + font-weight: 600; + font-variant-numeric: tabular-nums; + } + .observer-copy { min-width: 0; } @@ -954,8 +974,16 @@ } .observer-copy strong { + display: flex; + align-items: center; + gap: 6px; font-size: 0.8rem; - line-height: 1.2; + line-height: 1.25; + min-width: 0; + } + /* The name gives way to the tag instead of pushing it out of the row. */ + .observer-name { + min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -964,19 +992,24 @@ .observer-copy span { color: var(--muted); font-size: 0.72rem; - line-height: 1.25; + line-height: 1.3; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .observer-reason { - display: inline-block; - margin-top: 1px; + flex: 0 0 auto; padding: 0 6px; border-radius: 999px; - font-size: 0.66rem; + font-size: 0.62rem; font-weight: 600; + letter-spacing: 0.03em; + text-transform: uppercase; background: rgba(255, 159, 67, 0.16); color: #ffb86b; border: 1px solid rgba(255, 159, 67, 0.3); + cursor: help; } .observer-reason.is-range { @@ -1001,7 +1034,10 @@ .observer-support[open] > summary::before { content: "▾ "; } .observer-support[open] { display: grid; gap: 4px; } - .observer-weight { + /* `.step-panel input` (0,1,1) was outspecifying this (0,1,0), so the field + kept the panel's 12px padding and stood 48px tall — it, not the text, + was what made every observer row 60px (#134). */ + .step-panel .observer-weight { width: 100%; border-radius: 8px; border: 1px solid rgba(255, 255, 255, 0.12); @@ -1606,7 +1642,7 @@

Clues + Regions

Step 2

Choose Cluster

-

Candidate clusters will appear here after discovery.

+
Candidate clusters will appear here after discovery.
@@ -4130,6 +4166,28 @@

Unknown Node Estimate

return String(node.public_key || node.key || `${node.shortId}@${node.lat},${node.lon}`).toLowerCase(); } + // Headline plus one line per caveat (#134). Counts are ours and the text is + // built from constants, so there is no user input to escape here; keep it + // that way if a node name ever needs to appear in one of these lines. + function renderClusterStatus(headlineHtml, notes) { + const lines = notes.map((note) => ` +

+ ${note.count}${note.text} +

`).join(""); + clusterStatus.innerHTML = `

${headlineHtml}

${lines}`; + } + + // Beside a name there is room for a tag, not a sentence. The full reason is + // the title, and the cluster status says what each of them means (#134). + const SHORT_REASONS = { + "duplicate prefix": "dup", + "proven-link (range only)": "range", + "outside 1st-hop hull": "outside" + }; + function shortReason(reason) { + return SHORT_REASONS[reason] || reason; + } + function syncClusterEditorFromLockedRegion() { if (!currentResolvedContext) { setClusterEditorVisible(false); @@ -4157,12 +4215,19 @@

Unknown Node Estimate

const weight = activeById.has(id) ? activeById.get(id) : (weightByPrefix.get(pfx) ?? 1); const row = document.createElement("div"); row.className = checked ? "observer-row" : "observer-row is-excluded"; + // Distance to the locked cluster's centre, computed here rather than + // stored: a dragged observer (#33b) has to move this number with it. + // It is the figure dedupeByPrefix() falls back to when proven links do + // not separate a prefix (#54), and until now the list never showed it. + const centreKm = lockedRegionCentroid ? haversineKm(lockedRegionCentroid, node) : null; + const facts = []; + if (Number.isFinite(centreKm)) facts.push(`centre ${centreKm.toFixed(1)} km`); + if (Number.isFinite(node.provenRadiusKm)) facts.push(`reach ${node.provenRadiusKm.toFixed(1)} km`); row.innerHTML = `
- ${pfx} · ${node.adv_name || node.name || "Observer"} - ${node.lat.toFixed(5)}, ${node.lon.toFixed(5)}${Number.isFinite(node.provenRadiusKm) ? ` · ${node.provenRadiusKm.toFixed(1)} km` : ""} - ${reason ? `${reason}` : ""} + ${pfx} · ${node.adv_name || node.name || "Observer"}${reason ? `${shortReason(reason)}` : ""} + ${facts.join(" · ")}
`; @@ -4470,44 +4535,66 @@

Unknown Node Estimate

candidateList.hidden = true; candidateList.innerHTML = ""; changeClusterButton.hidden = false; - const hullNote = hullExcluded.length - ? ` ${hullExcluded.length} 2nd-hop node${hullExcluded.length === 1 ? "" : "s"} outside the 1st-hop hull excluded — review in the list below.` - : ""; - // Same rule as the caps and filters below: nothing is decided silently. - // The nearest-the-centre pick is a guess, not evidence (#54), so it is - // named whenever it actually decided a prefix. - const dupGuessNote = dupCentroidDecided - ? ` Proven links did not separate ${dupCentroidDecided} prefix${dupCentroidDecided === 1 ? "" : "es"}, so the node nearest the cluster centre was kept. That pick biases the estimate toward the centre.` - : ""; - const dedupNote = dupRemoved.length - ? ` Auto-removed ${dupRemoved.length} duplicate-prefix node${dupRemoved.length === 1 ? "" : "s"}, review in the list below.${dupGuessNote}` - : ""; - // Never let a coverage cap look like "there was nothing more" (#38). - const linkCapNote = linkFetchSkipped - ? ` Proven links fetched for the first ${PROVEN_LINK_NODE_BUDGET} nodes only — ${linkFetchSkipped} not queried.` - : ""; - // Same rule for the plausibility filter: a dropped link is reported, never - // silent (#61). - // One consolidated clause list rather than a separate sentence per rule. - // This line already carries the hull and dedupe notes, and three more - // appended sentences read as a wall. Nothing is capped silently (#38, - // #61, #62). - const radiusNotes = []; + // Every cap, filter and fallback is reported — nothing is decided + // silently (#38, #54, #61, #62, #85). These used to be concatenated into + // one paragraph, which in a 300px column ran to eight sentences of prose + // that could not be scanned (#134). Same facts, one per line, count + // first. `caveat` marks the ones that are inference rather than + // measurement, so a guess never reads as evidence. + const notes = []; + if (hullExcluded.length) { + notes.push({ + count: hullExcluded.length, + text: `2nd-hop node${hullExcluded.length === 1 ? "" : "s"} outside the 1st-hop hull, excluded — review below`, + caveat: true + }); + } + if (dupRemoved.length) { + notes.push({ + count: dupRemoved.length, + text: `duplicate-prefix node${dupRemoved.length === 1 ? "" : "s"} removed — review below`, + caveat: true + }); + } + if (dupCentroidDecided) { + notes.push({ + count: dupCentroidDecided, + text: `prefix${dupCentroidDecided === 1 ? "" : "es"} decided by distance to centre, not by links. Biases the estimate inward.`, + caveat: true + }); + } + if (linkFetchSkipped) { + notes.push({ + count: linkFetchSkipped, + text: `node${linkFetchSkipped === 1 ? "" : "s"} not queried for proven links — first ${PROVEN_LINK_NODE_BUDGET} only`, + caveat: true + }); + } if (implausibleLinksDropped) { - radiusNotes.push(`${implausibleLinksDropped} link${implausibleLinksDropped === 1 ? "" : "s"} too long for one hop (over ${MAX_PLAUSIBLE_LINK_KM} km)`); + notes.push({ + count: implausibleLinksDropped, + text: `link${implausibleLinksDropped === 1 ? "" : "s"} over ${MAX_PLAUSIBLE_LINK_KM} km dropped, too long for one hop` + }); } if (outboundLinksExcluded) { - radiusNotes.push(`${outboundLinksExcluded} outbound-only link${outboundLinksExcluded === 1 ? "" : "s"} excluded`); + notes.push({ + count: outboundLinksExcluded, + text: `outbound-only link${outboundLinksExcluded === 1 ? "" : "s"} excluded` + }); } if (observersWithoutReceiveQuorum) { - radiusNotes.push(`${observersWithoutReceiveQuorum} observer${observersWithoutReceiveQuorum === 1 ? "" : "s"} had under ${RECEIVE_QUORUM} inbound links, so used all directions`); + notes.push({ + count: observersWithoutReceiveQuorum, + text: `observer${observersWithoutReceiveQuorum === 1 ? "" : "s"} under ${RECEIVE_QUORUM} inbound links, used all directions` + }); } - const implausibleNote = radiusNotes.length ? ` Coverage radii: ${radiusNotes.join("; ")}.` : ""; - // Which observers got a roof height, and which stay on the constant. - const roofNote = roofs.answered - ? ` Antenna heights: ${roofs.onRoof} of ${roofs.answered} observers have a building within ${ROOF_SEARCH_M} m per 3D BAG and use its roof, the rest assume ${REPEATER_ANTENNA_HEIGHT_M} m.` - : ` Antenna heights: 3D BAG did not answer, all observers assume ${REPEATER_ANTENNA_HEIGHT_M} m.`; - clusterStatus.textContent = `Cluster near ${centroid.lat.toFixed(3)}, ${centroid.lon.toFixed(3)}: ${uniquePrefixCount(dedupedNodes)} observers active.${hullNote}${dedupNote}${linkCapNote}${implausibleNote}${roofNote}`; + notes.push(roofs.answered + ? { count: `${roofs.onRoof}/${roofs.answered}`, text: `antenna heights from 3D BAG roofs, the rest assume ${REPEATER_ANTENNA_HEIGHT_M} m` } + : { count: "0", text: `antenna heights from 3D BAG — it did not answer, all assume ${REPEATER_ANTENNA_HEIGHT_M} m`, caveat: true }); + renderClusterStatus( + `${uniquePrefixCount(dedupedNodes)} observers active near ${centroid.lat.toFixed(3)}, ${centroid.lon.toFixed(3)}.`, + notes + ); syncClusterEditorFromLockedRegion(); setDiscoveryCollapsed(true); renderCandidateMap([]); From 378a0bfac03edce9f8d05c7a56d32cf826a8f02b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kasper=20H=C3=A4gele?= Date: Thu, 10 Sep 2026 11:31:53 +0200 Subject: [PATCH 4/4] feat(web-standalone): move the map's controls to a strip on the map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The controls card sat at the bottom of the left column, after all three steps. Measured on an 8-prefix run near Delft at 1440x900: the map ran y=87 to y=879 and the card sat at y=1852 — 973px below the bottom of the map it acts on. To switch off the range polygons because the map had gone pink, you scrolled the map completely out of view. It becomes a strip inside .map-col, above .map-wrap: with the map, never over it, so there is no overlay against a light basemap and no z-index to keep in step. Relief is a chip that opens its slider in a popover, which keeps the strip one line at the widest state (Clear Map, Resolve on map, Show + 4 chips, Share). ? and the bell move to the topbar, where about-the-app controls belong. The "enter prefixes first" line moves onto the disabled Share button rather than standing in the strip and making it two rows tall before anything has happened. Step panels get a clickable head with a summary of what they hold, so a folded step reads "Step 1 · 8 prefixes" and can be opened again — Step 1 already folded itself when a cluster locked, with no way back and nothing said. On a narrow screen Step 2 folds once an estimate exists: at 375x812 that moves the map from y=1822 to y=742 and the page from 2301px to 1221px. An automatic fold never fires on a panel the operator opened by hand. Two specificity traps found on the way: `.step-panel button` (0,1,1) painted the new heads with the accent fill, the same way it had been inflating the observer weight field. Closes #135 Co-Authored-By: Claude Opus 5 --- web-standalone/changelog.json | 6 + web-standalone/index.html | 374 +++++++++++++++++++++++++++------- 2 files changed, 310 insertions(+), 70 deletions(-) diff --git a/web-standalone/changelog.json b/web-standalone/changelog.json index a3527fd..ddb290b 100644 --- a/web-standalone/changelog.json +++ b/web-standalone/changelog.json @@ -1,4 +1,10 @@ [ + { + "id": "2026-09-10-map-controls-with-the-map", + "date": "2026-09-10", + "title": "The map's buttons sit with the map", + "body": "Clear Map, the layer switches and the relief slider lived at the bottom of the left column, below all three steps — nearly a thousand pixels under the map they act on, so turning off a layer meant scrolling the map out of sight. They are now a strip along the top of the map. Relief became a button that opens its slider, so the strip stays one line. The help and What's new buttons moved up to the bar at the top of the page. Every step can now be folded shut and shows what it holds when it is (Step 1 · 8 prefixes), and on a phone the observer list folds itself once you have an estimate, so the map is no longer three screens down." + }, { "id": "2026-09-10-shorter-observer-list", "date": "2026-09-10", diff --git a/web-standalone/index.html b/web-standalone/index.html index 5e8d4dc..c3a41e0 100644 --- a/web-standalone/index.html +++ b/web-standalone/index.html @@ -144,8 +144,41 @@ .topbar-dropdown-menu a { font-size: 0.82rem; } - .topbar-github { + .topbar-meta { margin-left: auto; + display: flex; + align-items: center; + gap: 8px; + flex: 0 0 auto; + } + .topbar-meta .is-icon { + width: 28px; + height: 28px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + line-height: 1; + font-size: 0.8rem; + border-radius: 9px; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.04); + color: var(--muted); + cursor: pointer; + } + .topbar-meta .is-icon:hover { + background: rgba(255, 255, 255, 0.09); + color: var(--text); + } + /* The panel hangs off the topbar now, so it opens downward from the right + edge rather than from a card in the column. */ + .topbar-meta .whats-new-panel { + top: calc(100% + 10px); + left: auto; + right: 0; + } + + .topbar-github { flex: 0 0 auto; border-radius: 10px; padding: 6px 12px; @@ -194,11 +227,42 @@ flex-wrap: wrap; align-items: center; gap: 8px; - padding: 10px 12px; + padding: 8px 10px; border-radius: 14px; background: rgba(255, 255, 255, 0.05); border: 1px solid rgba(255, 255, 255, 0.08); } + .layer-chips-label { + font-size: 0.64rem; + letter-spacing: 0.09em; + text-transform: uppercase; + color: var(--faint); + margin-right: 2px; + } + /* Share is the one control here that is not about the map, so it sits at + the far end rather than in the run of map buttons. */ + .controls-card .controls-meta { + margin-left: auto; + } + + /* Relief is a slider with a paragraph of explanation; as a chip plus a + popover the strip stays one line (#135). */ + .relief-wrap { + position: relative; + display: inline-block; + } + .relief-wrap .terrain-control { + position: absolute; + top: calc(100% + 8px); + right: 0; + z-index: 20; + width: 250px; + padding: 10px 12px; + border-radius: 12px; + background: rgba(7, 25, 35, 0.97); + border: 1px solid rgba(143, 187, 201, 0.28); + box-shadow: var(--shadow); + } .controls-card button { margin: 0; border-radius: 10px; @@ -564,13 +628,69 @@ pointer-events: none; } + /* The head is the control that folds the panel (#135). Step 1 already + collapsed on its own when a cluster locked, but there was no way to open + it again and nothing said what it held. */ + /* `.step-panel button` (0,1,1) paints every button in the panel with the + accent fill, so the head has to outspecify it or it renders as a big + green primary action. */ + .step-panel .step-head { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + grid-template-areas: + "kicker chevron" + "title chevron"; + align-items: center; + gap: 0 10px; + width: 100%; + margin: 0; + padding: 0; + border: 0; + background: none; + text-align: left; + color: inherit; + font: inherit; + cursor: pointer; + } + .step-panel .step-head .step-kicker { grid-area: kicker; } + .step-panel .step-head h3 { grid-area: title; } + .step-panel .step-head .step-chev { + grid-area: chevron; + color: var(--faint); + font-size: 0.7rem; + transition: transform 0.15s ease; + } + .step-panel .step-head .step-chev::before { content: "▾"; } + @media (prefers-reduced-motion: reduce) { + .step-panel .step-head .step-chev { transition: none; } + } + .step-panel .step-head:hover .step-chev { color: var(--text); } + /* The summary only earns its place once the panel is folded; open, the + panel itself is the summary. */ + .step-summary { + grid-area: title; + justify-self: end; + align-self: center; + font-size: 0.74rem; + color: var(--accent); + font-variant-numeric: tabular-nums; + white-space: nowrap; + display: none; + } + .step-panel.is-collapsed .step-body { display: none; } + .step-panel.is-collapsed .step-chev { transform: rotate(-90deg); } + .step-panel.is-collapsed .step-summary:not(:empty) { display: block; } + .step-panel.is-collapsed h3 { margin-bottom: 0; } .step-panel.is-collapsed { padding-bottom: 12px; } + /* Nothing to fold before a step is reachable. */ + .step-panel.is-locked .step-head { cursor: default; } + .step-panel.is-locked .step-chev { visibility: hidden; } .step-kicker { display: inline-block; @@ -1379,17 +1499,6 @@ It lives in the controls card next to the 3D toggle rather than floating over the map. It is a setting for that view, so it belongs with the control that opens it, and the map is for the map. */ - .terrain-control { - /* The controls card is flex-wrap; this is how its other full-width rows - claim a line of their own (see .share-status). */ - flex: 1 0 100%; - margin-top: 2px; - padding: 9px 11px; - border-radius: 12px; - background: rgba(255, 255, 255, 0.05); - border: 1px solid rgba(255, 255, 255, 0.12); - } - .terrain-control label { display: flex; justify-content: space-between; @@ -1572,15 +1681,30 @@ Mesh-Hunter ↗ MC-Radar ↗ - GitHub ↗ +
+ +
+ + +
+ GitHub ↗ +
- Step 1 -

Clues + Regions

-
+ +
@@ -1640,8 +1764,13 @@

Clues + Regions

- Step 2 -

Choose Cluster

+ +
Candidate clusters will appear here after discovery.
@@ -1651,56 +1780,63 @@

Choose Cluster

+
- Step 3 -

Unknown Node Estimate

- - -

Run a weighted observer estimate first. Terrain and LOS are an optional refinement after that.

-
-
- - - - -
- -
- - + +
+ + +

Run a weighted observer estimate first. Terrain and LOS are an optional refinement after that.

-
- - - - -
+
+ +
+ + + +
+ +
+ + + +