From dde1dd9e48240ba3af2ed0686dcdca24e5710620 Mon Sep 17 00:00:00 2001 From: Felix Stubner Date: Mon, 24 Aug 2026 18:02:41 +0100 Subject: [PATCH 1/4] Write the product documents, and clear 67 dead CSS declarations Follow-ups from an acceptance pass over the site. The acceptance gate went from BLOCK to CONDITIONAL: the three required documents now exist, and what remains unevaluated is structural -- acceptance ran in the builder's own context, and the frontend domain checker is not installed here. PRODUCT.md, design-direction.md and ux-walkthrough.md are reconstructed from what the repository already evidences, with the user ordering and success criteria supplied by the maintainer. Every claim that is inferred rather than evidenced is marked, so the guesses are correctable instead of looking settled. One thing surfaced by writing them: the README and the website both lead with the agent/MCP story, but agent-driven users rank last of four. The code's history explains the emphasis; PRODUCT.md records the current priority and says where the two disagree. The CSS change removes 67 of the 126 declarations css-shadowing.mjs proves can never take effect, and lowers the ratchet from 126 to 59. The budget was sitting exactly at its cap, so the next shadowed declaration anyone added would have failed the check. The remaining 59 were left alone because the removal script could not match them unambiguously; it reports what it skipped rather than guessing. Verified by computed style: a fresh load of /docs/install/ at 1280x900 hashes identically before and after across 15 selectors and 21 properties, and the a11y suite still reports no violations in either theme. --- PRODUCT.md | 102 ++++++++++++++++++ design-direction.md | 65 +++++++++++ site/package.json | 2 +- .../starlight/04-markdown-content-base.css | 14 --- .../src/styles/starlight/05-docs-surfaces.css | 10 -- .../starlight/06-docs-interaction-a.css | 4 - site/src/styles/starlight/08-search-modal.css | 7 -- .../src/styles/starlight/10-docs-feedback.css | 6 -- .../starlight/18-mobile-shell-closeout-b.css | 3 - .../styles/starlight/19-markdown-tables.css | 22 ---- .../starlight/20-docs-shell-closeout.css | 18 ---- .../21-mobile-docs-shell-verified.css | 4 - .../starlight/22-mobile-docs-correction.css | 6 -- .../starlight/23-regression-guardrails.css | 10 -- .../24-mobile-regression-guardrails.css | 19 ---- ux-walkthrough.md | 72 +++++++++++++ 16 files changed, 240 insertions(+), 124 deletions(-) create mode 100644 PRODUCT.md create mode 100644 design-direction.md create mode 100644 ux-walkthrough.md diff --git a/PRODUCT.md b/PRODUCT.md new file mode 100644 index 00000000..41b749d7 --- /dev/null +++ b/PRODUCT.md @@ -0,0 +1,102 @@ +# NetsCLI — product contract + +Written from what the repository already evidences (README, `docs/`, the +site copy, and the shipped interfaces), with the user ordering and success +criteria supplied by the maintainer. Claims that are **inferred** rather +than stated somewhere in the repo are marked `[inferred]` so they are easy +to correct rather than easy to mistake for settled. + +## Purpose + +Answer questions about a local network — what is on it, what is reachable, +what is listening — and return the answer as structured data rather than +text a human has to read and re-type. + +The origin, from the README, was narrower: driving those questions from an +AI agent, which existing tools make awkward ("half a dozen CLI invocations, +brittle output parsing, no shared context"). The MCP server was built +first, then the TUI, CLI, and desktop app, all over one Rust core +(`netscli-core`). + +**A tension worth recording.** The README and the website both lead with +the agent/MCP story, but the maintainer ranks agent-driven users *last* of +four (see Users). The code's history explains the emphasis; the priorities +below are what the product is actually for now. Where the two disagree, +this document is the current intent and the README is the origin story. + +## Users + +In priority order, as stated by the maintainer: + +1. **Specialists** — people whose job includes networks, who know what an + ARP table is and will notice when a tool lies about one. +2. **Operations** — people running or supporting infrastructure, who need a + quick, trustworthy answer during work that is not primarily about + networking. +3. **Anyone on a home or office LAN** — non-specialists who mostly want to + see what is connected. `[inferred]` This is the group the desktop app + serves most directly; the CLI and TUI assume more. +4. **Agent-driven developers** — wiring `netscli serve` into an MCP client + so an agent can answer network questions without parsing CLI output. + +The ordering matters for conflicts: when a change would make the desktop +app friendlier at the cost of a specialist's accuracy, the specialist wins. + +## Success + +All four of the following, as stated by the maintainer: + +- **Agents answer network questions unaided.** An MCP client resolves a + real question — "what just joined the network", "is port 22 open on + 192.168.1.42" — in one exchange, without correction. +- **Downloads and installs grow.** Adoption through GitHub releases and the + package managers is a signal that it is useful beyond one machine. +- **It is the tool the maintainer personally reaches for**, in place of + whatever came before. +- **No wrong answers.** The scanner never silently reports something false. + +The last one is not decoration. The 0.3.1 release exists because three +separate faults *reported success while doing nothing*: ping claimed total +loss against the host's own address, a fresh install ran one probe at a +time while appearing configured for 256, and `arp --clear` printed "ARP +table cleared" after clearing nothing. A wrong answer delivered +confidently is the failure mode this product most has to avoid, because +every surface above the core inherits it. + +## MVP + +Already shipped and in the released product: + +- One core library (`netscli-core`) with scan, ping, traceroute, discover, + sweep, DNS/reverse/mDNS, ARP, and interface enumeration. +- Four interfaces over it: CLI (`netscli`), terminal UI, desktop app + (Tauri), and MCP server (`netscli serve`, nine tools by default). +- Structured output — `--json` on the CLI, typed results across the MCP + boundary — as the primary contract, with human-readable text as a view + of it rather than the source of truth. +- Cross-platform binaries and installers for Linux, macOS, and Windows. + +Out of scope for the current line `[inferred, from what the code does not +attempt]`: exploitation or intrusion of any kind, credentialed access to +discovered hosts, and continuous monitoring. The tools answer questions at +a moment in time. + +## Constraints + +- **Rust 1.96**, one workspace, crates versioned together but not + inheriting a workspace version — each manifest carries its own, and a + release has to touch all of them plus `package.json` and + `tauri.conf.json`. See `docs/PUBLISHING.md`. +- **Privilege boundaries are real and platform-specific.** Raw ICMP needs + administrator rights on Windows; clearing the ARP table needs them + everywhere. Where a capability is unavailable the product must say so and + fail, not degrade quietly into a different answer. +- **The MCP surface is a trust boundary.** `server/targets.rs` restricts + what an agent may scan and `server/limits.rs` caps what a scanned host can + put into a model's context. Both exist so a prompt-injected model cannot + point the scanner at strangers, and neither may be relaxed for + convenience. +- **crates.io publication is irreversible** — a version number, once used, + is spent whether or not the release was any good. +- Network safety limits (concurrency caps, target policy) are deliberate + and are not to be widened to make something faster. diff --git a/design-direction.md b/design-direction.md new file mode 100644 index 00000000..13346795 --- /dev/null +++ b/design-direction.md @@ -0,0 +1,65 @@ +# Design direction — NetsCLI desktop app + +Scope: the Tauri desktop app in `apps/netscli-gui`. The marketing and docs +site (`site/`) is a separate surface with its own Starlight-derived styling +and is not covered here. + +Reconstructed from the shipped implementation — `src/styles/tokens.css` and +the components that consume it — rather than from an interview held before +the design existed. Anything not directly readable from the code is marked +`[inferred]`. + +## Interview + +**What is this, in one line?** +A desktop window over the same Rust core the CLI and TUI use, for when you +want to see what is on the network without opening a terminal. + +**Who is looking at it?** +Per `PRODUCT.md`, specialists first and non-specialists third. The app is +the surface most used by the non-specialist group, but it is not allowed to +buy their comfort with a specialist's accuracy. `[inferred]` — this follows +from the stated user ordering rather than from a design note. + +**What should it feel like?** +A terminal tool that happens to have a window: dense, quiet, and quick to +read, rather than a consumer app with generous whitespace. Evidence: the +compact control heights (tab strip 38px, status bar 27px), the monospace +treatment of commands and addresses, and the CLI command strip under the +results showing the equivalent `netscli …` invocation for whatever the UI +just did. + +**Colour.** +Dark by default, with a light theme defined in the same token block. Near +-black backgrounds layered by elevation (`--bg-body` `#0d1015` → `--bg-base` +`#181c24` → `--bg-elevated` `#20242d`), low-contrast borders +(`--border-subtle` `#2a303b`), and a single mint accent (`--mint` `#3eddb0`) +carrying interaction and success. Cyan (`#1edcff`), red (`#ef4456`) and +amber (`#f5a524`) are reserved for state, not decoration. + +Every colour is a token on `.container`; components reference tokens, never +literals. This is enforced by convention rather than by a checker +`[inferred]` — no lint rule for it exists in the repo. + +**Type.** +Inter (with a Segoe UI / system fallback) for interface text; Cascadia Code +/ JetBrains Mono for anything the user could paste into a shell — commands, +IPs, MACs, ports. The split is semantic: monospace means "this is literal". + +**Density and hierarchy.** +Results are tables, and the table is the primary object on screen; the form +above it and the detail pane below it are supporting. Row selection, +keyboard navigation (arrows, Home/End, Ctrl+A, shift-range) and multi-select +are first-class, because the specialist user copies rows out. + +**Motion.** +Minimal and functional — progress during an operation, a toast that +auto-dismisses. No decorative animation. `[inferred]` from the absence of +any transition longer than ~120ms in the stylesheets. + +**What it must never do.** +Present a result as certain when the underlying probe failed. The error +strip is unconditional and not preference-gated for this reason, and +completion toasts were deliberately narrowed to background tabs only — +announcing what is already on screen trains people to ignore the messages +that matter. diff --git a/site/package.json b/site/package.json index ef7398b2..53fce616 100644 --- a/site/package.json +++ b/site/package.json @@ -8,7 +8,7 @@ "build": "astro build", "preview": "astro preview", "check": "astro check", - "check:css": "node ./scripts/css-shadowing.mjs --max 126", + "check:css": "node ./scripts/css-shadowing.mjs --max 59", "test:a11y": "npm run build && node ./scripts/a11y.mjs" }, "dependencies": { diff --git a/site/src/styles/starlight/04-markdown-content-base.css b/site/src/styles/starlight/04-markdown-content-base.css index 75a41b85..f104a31e 100644 --- a/site/src/styles/starlight/04-markdown-content-base.css +++ b/site/src/styles/starlight/04-markdown-content-base.css @@ -144,11 +144,7 @@ @media (max-width: 42rem) { .sl-markdown-content table { - - - background: transparent; overflow: visible; - max-width: 100%; }.sl-markdown-content table tbody { gap: 0.55rem; @@ -157,21 +153,11 @@ .sl-markdown-content table tr { gap: 0.28rem; - - border: 1px solid rgba(140, 149, 166, 0.18); - border-radius: 8px; - background: rgba(255, 255, 255, 0.03); } .sl-markdown-content table td, .sl-markdown-content table th { - - - min-width: 0; - padding: 0; border: 0; - white-space: normal; - overflow-wrap: anywhere; }.sl-markdown-content table td:first-child { width: auto; padding: 0; diff --git a/site/src/styles/starlight/05-docs-surfaces.css b/site/src/styles/starlight/05-docs-surfaces.css index 42348ef6..de3f1947 100644 --- a/site/src/styles/starlight/05-docs-surfaces.css +++ b/site/src/styles/starlight/05-docs-surfaces.css @@ -96,11 +96,6 @@ site-search dialog .dialog-frame { } .sl-markdown-content .expressive-code .frame { - - - box-shadow: - 0 16px 36px rgba(0, 0, 0, 0.3), - inset 0 1px 0 rgba(255, 255, 255, 0.035); } .sl-markdown-content .expressive-code .frame.is-terminal .header { @@ -113,11 +108,6 @@ site-search dialog .dialog-frame { border-top-left-radius: calc(var(--ec-brdRad) + var(--ec-brdWd)) !important; border-top-right-radius: calc(var(--ec-brdRad) + var(--ec-brdWd)) !important; }.sl-markdown-content .expressive-code .copy button { - - - background: #202631; - border-radius: 4px; - opacity: 0.92; } .sl-markdown-content .expressive-code .copy button::before { diff --git a/site/src/styles/starlight/06-docs-interaction-a.css b/site/src/styles/starlight/06-docs-interaction-a.css index 1bb785d4..781eab77 100644 --- a/site/src/styles/starlight/06-docs-interaction-a.css +++ b/site/src/styles/starlight/06-docs-interaction-a.css @@ -60,10 +60,6 @@ } #starlight__search .pagefind-ui__button { - - - justify-content: center; - text-align: center; } #starlight__search .pagefind-ui__message { diff --git a/site/src/styles/starlight/08-search-modal.css b/site/src/styles/starlight/08-search-modal.css index 38e22fc0..0fa1b28d 100644 --- a/site/src/styles/starlight/08-search-modal.css +++ b/site/src/styles/starlight/08-search-modal.css @@ -36,16 +36,9 @@ site-search dialog .search-container { inset-block-start: 0; flex: 0 0 auto; - padding-block-end: 0.8rem; - background: #252b36; }#starlight__search .pagefind-ui__drawer { min-height: 0; - - - overscroll-behavior: contain; padding-inline-end: 0.2rem; - position: relative; - scrollbar-gutter: stable; } #starlight__search .pagefind-ui__drawer:has(.pagefind-ui__result)::before, diff --git a/site/src/styles/starlight/10-docs-feedback.css b/site/src/styles/starlight/10-docs-feedback.css index 6a1c2902..835defff 100644 --- a/site/src/styles/starlight/10-docs-feedback.css +++ b/site/src/styles/starlight/10-docs-feedback.css @@ -29,9 +29,6 @@ } #starlight__search .pagefind-ui__button { - - - text-align: center !important; } .sl-markdown-content .expressive-code { @@ -44,7 +41,4 @@ } html[data-theme='light'] .sl-markdown-content .expressive-code .copy button[data-copied] { - - - color: #0b4a1f !important; } \ No newline at end of file diff --git a/site/src/styles/starlight/18-mobile-shell-closeout-b.css b/site/src/styles/starlight/18-mobile-shell-closeout-b.css index 885854dc..b97361ca 100644 --- a/site/src/styles/starlight/18-mobile-shell-closeout-b.css +++ b/site/src/styles/starlight/18-mobile-shell-closeout-b.css @@ -140,9 +140,6 @@ @media (max-width: 49.99rem) { .docs-mobile-section-nav { - - - top: var(--sl-nav-height) !important; }mobile-starlight-toc nav { position: fixed !important; inset-inline: 0 !important; diff --git a/site/src/styles/starlight/19-markdown-tables.css b/site/src/styles/starlight/19-markdown-tables.css index dbb2c200..1ac20a85 100644 --- a/site/src/styles/starlight/19-markdown-tables.css +++ b/site/src/styles/starlight/19-markdown-tables.css @@ -60,21 +60,9 @@ html[data-theme='light'] .sl-markdown-content .netscli-table-scroll { @media (max-width: 49.99rem) { .sl-markdown-content .netscli-table-scroll { - - - overflow-y: hidden !important; - border: 0 !important; - border-radius: 8px !important; - background: transparent !important; } .sl-markdown-content .netscli-table-scroll table { - - - width: 100% !important; - table-layout: auto !important; - border: 0 !important; - background: transparent !important; }.sl-markdown-content .netscli-table-scroll tbody { gap: 0.75rem !important; @@ -83,21 +71,11 @@ html[data-theme='light'] .sl-markdown-content .netscli-table-scroll { .sl-markdown-content .netscli-table-scroll tr { gap: 0.75rem !important; - - border: 1px solid rgba(140, 149, 166, 0.2) !important; - border-radius: 8px !important; - background: #252b34 !important; } .sl-markdown-content .netscli-table-scroll th, .sl-markdown-content .netscli-table-scroll td { - - - padding: 0 !important; border: 0 !important; - line-height: 1.42 !important; - overflow-wrap: anywhere !important; - word-break: normal !important; } .sl-markdown-content .netscli-table-scroll td::before { diff --git a/site/src/styles/starlight/20-docs-shell-closeout.css b/site/src/styles/starlight/20-docs-shell-closeout.css index 8e0564eb..9d24a1db 100644 --- a/site/src/styles/starlight/20-docs-shell-closeout.css +++ b/site/src/styles/starlight/20-docs-shell-closeout.css @@ -188,12 +188,6 @@ html[data-theme='light'] .right-sidebar-panel :where(a[aria-current='location']) @media (max-width: 49.99rem) { .right-sidebar-container { - - - margin: 0 !important; - padding: 0 !important; - border: 0 !important; - overflow: visible !important; } .docs-mark { @@ -237,28 +231,16 @@ html[data-theme='light'] .right-sidebar-panel :where(a[aria-current='location']) } site-search dialog { - - - min-height: min(100dvh - 1rem, 44rem) !important; - margin: 0.5rem auto !important; border-radius: 8px !important; } site-search dialog .dialog-frame { - - - align-items: center !important; align-content: start !important; - gap: 0.75rem !important; - padding: 1rem !important; } site-search dialog .dialog-frame > * { align-self: start !important; }site-search button[data-close-modal] { - - - align-self: start !important; } } diff --git a/site/src/styles/starlight/21-mobile-docs-shell-verified.css b/site/src/styles/starlight/21-mobile-docs-shell-verified.css index 34a8991a..b220a7f8 100644 --- a/site/src/styles/starlight/21-mobile-docs-shell-verified.css +++ b/site/src/styles/starlight/21-mobile-docs-shell-verified.css @@ -11,10 +11,6 @@ } .sidebar-content :where(details > ul a) { - - - padding-inline-start: 0.75rem !important; - border-inline-start: 2px solid transparent !important; } .sidebar-content :where(details > ul a:hover), diff --git a/site/src/styles/starlight/22-mobile-docs-correction.css b/site/src/styles/starlight/22-mobile-docs-correction.css index da3d25a7..d6a677cf 100644 --- a/site/src/styles/starlight/22-mobile-docs-correction.css +++ b/site/src/styles/starlight/22-mobile-docs-correction.css @@ -131,15 +131,9 @@ }site-search dialog .search-container, #starlight__search { - - - min-width: 0 !important; } site-search button[data-close-modal] { - - - align-self: center !important; }} site-search button[data-open-modal]:hover, diff --git a/site/src/styles/starlight/23-regression-guardrails.css b/site/src/styles/starlight/23-regression-guardrails.css index fa5d3980..29fba0ec 100644 --- a/site/src/styles/starlight/23-regression-guardrails.css +++ b/site/src/styles/starlight/23-regression-guardrails.css @@ -7,8 +7,6 @@ .sidebar-content :where(details > ul a) { width: calc(100% + 1px) !important; - padding-inline-start: 0.75rem !important; - border-inline-start: 2px solid transparent !important; } .sidebar-content :where(details > ul a[aria-current='page']) { @@ -34,9 +32,6 @@ margin-left: auto !important; margin-right: 3rem !important; flex: 0 0 2.75rem !important; - - - min-width: 2.75rem !important; } starlight-menu-button { @@ -55,11 +50,6 @@ } site-search dialog .dialog-frame { - - - gap: 0.85rem !important; - padding: 1rem !important; - align-items: start !important; } site-search button[data-close-modal] { diff --git a/site/src/styles/starlight/24-mobile-regression-guardrails.css b/site/src/styles/starlight/24-mobile-regression-guardrails.css index 9f7645dc..cfcc68c5 100644 --- a/site/src/styles/starlight/24-mobile-regression-guardrails.css +++ b/site/src/styles/starlight/24-mobile-regression-guardrails.css @@ -30,9 +30,6 @@ margin-inline-end: .35rem !important; }.content-panel table, .sl-markdown-content table { - - - min-width: 100% !important; }.content-panel thead th, .sl-markdown-content thead th { font-weight: 700 !important; @@ -46,21 +43,9 @@ } site-search dialog { - - - min-height: auto !important; - overflow: auto !important; } site-search dialog .dialog-frame { - - - grid-template-areas: - "search close" - "drawer drawer" !important; - align-items: start !important; - gap: .85rem !important; - padding: 1rem !important; } site-search dialog .search-container, @@ -83,8 +68,4 @@ } site-search dialog .pagefind-ui__drawer { - - - max-width: none !important; - min-height: 9rem !important; }} diff --git a/ux-walkthrough.md b/ux-walkthrough.md new file mode 100644 index 00000000..2744b446 --- /dev/null +++ b/ux-walkthrough.md @@ -0,0 +1,72 @@ +# UX walkthrough — NetsCLI desktop app + +The flow an acceptance pass should walk, step by step, against a running +build. Written from the shipped app; each step below was observed rather +than intended. Where a step depends on privileges or the local network, the +condition is stated so a failure can be attributed correctly. + +Scope: `apps/netscli-gui`. The CLI, TUI and MCP surfaces have their own +documentation under `docs/`. + +## Primary job + +**See what is on this network, and confirm whether a given host or port is +reachable — without opening a terminal.** + +Everything else in the app supports that: the other tools are the follow-up +questions you ask once discovery has told you an address exists. + +## Steps + +1. **Launch.** The window opens on a **Discover** tab, pre-filled with the + subnet of the default interface. Discover is the entry point because the + first useful question on an unfamiliar network is "what is on it", and a + scan needs a host you do not have yet. +2. **Run discovery.** Press Run (or the chevron beside it for "Clear ARP + table, then discover", offered only on discover, sweep and the ARP tab). + Progress replaces the empty table while the sweep runs. +3. **Read the results.** Each host is a row: IP, hostname, MAC, vendor, RTT. + A `found_by` value records whether the host answered a probe or is only + known from the OS neighbour table — the latter can outlive the device, so + it is the column to distrust when something looks stale. +4. **Select and inspect.** Click a row, or navigate with the arrow keys; + Ctrl+A selects all. The detail pane below shows the selected row's + fields and the raw result. +5. **Ask a follow-up.** From a host, open a port scan or an inspect tab. + Each tool is a tab, and tabs are independent — a long sweep in one does + not block another. +6. **Read the equivalent command.** The command strip under the results + shows the `netscli …` invocation matching the current form, so anything + done in the UI can be reproduced in a shell or a script. +7. **Take the results away.** Export JSON or CSV, copy selected rows, or + save a result bundle. + +## States + +Each of these is a real state the app can be in, and each is worth +exercising deliberately. + +| State | What should happen | +| --- | --- | +| **Empty** — a tab created but never run | Form and Run control visible; the table shows no rows and does not pretend to. | +| **Running** | Progress with counts; Stop is enabled; other tabs stay usable. | +| **Empty result** — the run succeeded and found nothing | Distinct from "not yet run". A `/30` with nothing on it is a legitimate answer, not a failure. | +| **Operation failure** | The tab's **error strip** shows the reason. It is unconditional and not preference-gated — a failed run leaves the table empty, so without it the cause is invisible. | +| **Privilege failure** | Named explicitly, e.g. "Failed to clear ARP table: … requires elevation." The run that depended on it is **suppressed**, not continued, because a discover after a failed ARP clear looks identical to one after a successful clear. | +| **Capability unavailable** — e.g. packet capture without the driver | The tool reports why rather than failing opaquely. | +| **Completion, tab in background** | A toast, carrying an "Open tab" action. | +| **Completion, tab in foreground** | No toast. The result arriving in the table is the signal; repeating it teaches people to ignore toasts. | +| **Preferences at defaults** | Toasts off; concurrency 256; opens on Discover. A profile left at 1 probe by the pre-0.3.1 defect is repaired once, on upgrade. | + +## Known gaps + +Recorded so an acceptance pass does not report them as new: + +- The end-to-end suite (`npm run test:tauri-render`) runs only locally, never + on hosted CI, because it builds and drives the real app. +- Selenium's native `.click()` does not register as a React click against + the attached WebView2 session; a DOM `.click()` does. A menu item that + appears dead under the harness may be working for a user, and vice versa — + verify interaction findings both ways before believing either. +- The MSI has never been tested on a clean machine without WebView2 + preinstalled. From d5852a3056c03b3646ba8470b0f1aac9f8a08aac Mon Sep 17 00:00:00 2001 From: Felix Stubner Date: Mon, 24 Aug 2026 18:17:02 +0100 Subject: [PATCH 2/4] Put the release notes in the changelog HTML The page shipped "Loading release notes..." and painted the notes in from script. Everything it needed was already known at build time -- the entries come from this repository's own CHANGELOG.md -- so a crawler or a reader without JavaScript got an empty page whose entire purpose is release notes. Static HTML went from 66 visible words to 4,111. The build runs the same renderer the browser runs, under linkedom, rather than gaining a second server-side one. The card markup sits on top of about 400 lines of markdown-to-DOM conversion, and two copies of that would drift apart quietly -- this repo has lost days to exactly that kind of drift in its stylesheets. The one thing the build render skips is the disclosure that collapses long cards: it measures scrollHeight, which is 0 without layout. The client attaches it on load instead. That also means the no-JS rendering shows every entry in full, which is the better direction to fail in. A failed GitHub request no longer replaces the notes with an error. The build-time render is already on screen and is accurate; it just has not been told which tags are published, and throwing it away because a metadata request failed loses the thing the reader came for. Also narrows parseLocalChangelog's filter to a type guard. `.filter(Boolean)` does not narrow the null out, which only surfaced once the result was passed to a typed function instead of into an untyped define:vars. Not verified here: the collapse itself. requestAnimationFrame never fires in the automation pane -- document.hidden is true -- so no rAF-scheduled code can be exercised through it, on this build or the one before it. --- site/package-lock.json | 223 ++++++++++++++++++++ site/package.json | 1 + site/src/pages/changelog.astro | 21 +- site/src/scripts/changelog-page.ts | 27 +-- site/src/scripts/changelog/release-list.ts | 34 ++- site/src/scripts/changelog/render-static.ts | 70 ++++++ 6 files changed, 353 insertions(+), 23 deletions(-) create mode 100644 site/src/scripts/changelog/render-static.ts diff --git a/site/package-lock.json b/site/package-lock.json index f15feb3f..b014b524 100644 --- a/site/package-lock.json +++ b/site/package-lock.json @@ -12,6 +12,7 @@ "devDependencies": { "@astrojs/check": "^0.9.10", "@axe-core/cli": "^4.13.0", + "linkedom": "^0.18.13", "typescript": "^5.5.0" } }, @@ -3637,6 +3638,13 @@ "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==", "license": "CC0-1.0" }, + "node_modules/cssom": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz", + "integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==", + "dev": true, + "license": "MIT" + }, "node_modules/data-uri-to-buffer": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-8.0.0.tgz", @@ -5005,6 +5013,39 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "dev": true, + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/http-cache-semantics": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", @@ -5565,6 +5606,181 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/linkedom": { + "version": "0.18.13", + "resolved": "https://registry.npmjs.org/linkedom/-/linkedom-0.18.13.tgz", + "integrity": "sha512-ES/o9qotMpzpN2MHs+Iq/JcVoOj8Fa5wiQYrTdFpvAnwXL0g66XHHUc9WUMk6nAlBtGsFQ24ne+SYnvnaQ2FSw==", + "dev": true, + "license": "ISC", + "dependencies": { + "css-select": "^7.0.0", + "cssom": "^0.5.0", + "html-escaper": "^3.0.3", + "htmlparser2": "^10.1.0", + "uhyphen": "^0.2.0" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "canvas": ">= 2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/linkedom/node_modules/boolbase": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-2.0.0.tgz", + "integrity": "sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/linkedom/node_modules/css-select": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-7.0.0.tgz", + "integrity": "sha512-snmjEVXy+1LnwXdxhYvTMj1d9tOh4HxkA1YmoayVBeeyR2C14Pum7fcxJIm4SswYspVy866eYNwlH6xC3/VH5g==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^2.0.0", + "css-what": "^8.0.0", + "domhandler": "^6.0.1", + "domutils": "^4.0.2", + "nth-check": "^3.0.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/linkedom/node_modules/css-what": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-8.0.0.tgz", + "integrity": "sha512-DH0Bqq3DNp5tdOReuNyAA+Ev4Y2GS5FMbZpeTLP6C4CDi0h5nL0BmUPChXw3o/qbHLDWHl49sbNqQVY7bMSDdw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/linkedom/node_modules/dom-serializer": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz", + "integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^3.0.0", + "domhandler": "^6.0.0", + "entities": "^8.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/linkedom/node_modules/domelementtype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-3.0.0.tgz", + "integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/linkedom/node_modules/domhandler": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-6.0.1.tgz", + "integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^3.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/linkedom/node_modules/domutils": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-4.0.2.tgz", + "integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^3.0.0", + "domelementtype": "^3.0.0", + "domhandler": "^6.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/linkedom/node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/linkedom/node_modules/nth-check": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-3.0.1.tgz", + "integrity": "sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^2.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, "node_modules/loglevel": { "version": "1.9.2", "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz", @@ -8311,6 +8527,13 @@ "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", "license": "MIT" }, + "node_modules/uhyphen": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/uhyphen/-/uhyphen-0.2.0.tgz", + "integrity": "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==", + "dev": true, + "license": "ISC" + }, "node_modules/ultrahtml": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/ultrahtml/-/ultrahtml-1.7.0.tgz", diff --git a/site/package.json b/site/package.json index 53fce616..4e51de31 100644 --- a/site/package.json +++ b/site/package.json @@ -18,6 +18,7 @@ "devDependencies": { "@astrojs/check": "^0.9.10", "@axe-core/cli": "^4.13.0", + "linkedom": "^0.18.13", "typescript": "^5.5.0" } } diff --git a/site/src/pages/changelog.astro b/site/src/pages/changelog.astro index 9b7e120e..f3e04092 100644 --- a/site/src/pages/changelog.astro +++ b/site/src/pages/changelog.astro @@ -4,6 +4,7 @@ import Nav from '../components/Nav.astro'; import Footer from '../components/Footer.astro'; import { site } from '../data/site'; import changelogText from '../../../CHANGELOG.md?raw'; +import { renderReleaseListHtml } from '../scripts/changelog/render-static'; const { hero, social } = site; const title = 'NetsCLI release notes'; @@ -53,11 +54,21 @@ const parseLocalChangelog = (text: string) => { summary: releaseSummaries[tag], }; }) - .filter(Boolean) + // A type guard rather than `.filter(Boolean)`: the latter does not + // narrow the null out, which only surfaced once the result was passed to + // a typed function instead of into an untyped `define:vars`. + .filter((release): release is NonNullable => release !== null) .slice(0, 8); }; const fallbackReleases = parseLocalChangelog(changelogText); + +// Rendered here, not painted in by script: the entries come from this +// repository's own CHANGELOG.md, so they are known at build time, and a page +// whose whole purpose is release notes should not be empty without +// JavaScript. The client still upgrades this once GitHub confirms which +// tags are actually released. +const staticRelease = renderReleaseListHtml(fallbackReleases, hero.sourceUrl.split('/').slice(-2).join('/'), releaseSummaries); ---
-
-
Loading release notes...
-
+
diff --git a/site/src/scripts/changelog-page.ts b/site/src/scripts/changelog-page.ts index f4dd79c7..2b690e06 100644 --- a/site/src/scripts/changelog-page.ts +++ b/site/src/scripts/changelog-page.ts @@ -1,5 +1,5 @@ import { normalizeTag } from './changelog/summarize'; -import { renderReleaseList } from './changelog/release-list'; +import { hydrateReleaseDisclosures, renderReleaseList } from './changelog/release-list'; import type { ChangelogRelease } from './changelog/types'; export type { ChangelogRelease }; @@ -16,13 +16,15 @@ export function initChangelogPage( fallbackReleases.map((release) => [normalizeTag(release.tag_name || release.name), release]) ); - // Nothing is linked to a tag until GitHub confirms that tag exists. The - // first paint happens before the fetch resolves, and if the fetch fails it - // is the only paint -- linking optimistically is how the v0.3.0 card ended - // up pointing at a 404 for weeks. Same rule as the version in the hero: - // assert it once it is confirmed, not before. - const asUnreleased = fallbackReleases.map((release) => ({ ...release, unreleased: true })); - const renderedFallback = renderReleaseList(asUnreleased, repo, fallbackByTag, releaseSummaries); + // The list is already in the HTML, rendered at build time from this repo's + // CHANGELOG.md by this same renderer, so there is nothing to paint here -- + // only the collapse to attach, which needs a laid-out page. + // + // Nothing in that markup links a tag, for the same reason the fetch below + // is careful: linking optimistically is how the v0.3.0 card ended up + // pointing at a GitHub 404 for weeks. A tag is asserted once confirmed, + // not before. + hydrateReleaseDisclosures(); fetch(`https://api.github.com/repos/${repo}/releases?per_page=8`) .then((response) => { @@ -38,10 +40,9 @@ export function initChangelogPage( renderReleaseList([...localOnlyReleases, ...releases], repo, fallbackByTag, releaseSummaries); }) .catch(() => { - const list = document.getElementById('release-list'); - if (list && !renderedFallback) { - list.innerHTML = - '
Could not load releases from GitHub right now. Use the releases link above.
'; - } + // Deliberately nothing. The build-time render is already on screen and + // is accurate -- it just has not been told which tags are published. + // Replacing it with an error would throw away the notes the reader + // came for because a metadata request failed. }); } diff --git a/site/src/scripts/changelog/release-list.ts b/site/src/scripts/changelog/release-list.ts index 39a97a63..175dc2ac 100644 --- a/site/src/scripts/changelog/release-list.ts +++ b/site/src/scripts/changelog/release-list.ts @@ -23,7 +23,7 @@ function mergeRelease( }; } -function setupReleaseDisclosure(item: HTMLElement, body: HTMLElement, index: number): void { +export function setupReleaseDisclosure(item: HTMLElement, body: HTMLElement, index: number): void { const collapsedHeight = window.matchMedia('(max-width: 42rem)').matches ? 260 : 300; const minimumOverflow = 80; const bodyId = `release-body-${index}`; @@ -63,8 +63,20 @@ export function renderReleaseList( releases: ChangelogRelease[], repo: string, fallbackByTag: Map, - releaseSummaries: Record + releaseSummaries: Record, + /** + * Skip the parts that need a laid-out page. + * + * This same function runs at build time against a DOM with no layout, so + * that the release notes are in the HTML instead of being painted in by + * script. `scrollHeight` is 0 there, which would collapse every card to + * the wrong height; the client re-applies the disclosure once it has a + * real page. Rendering one renderer twice is the point -- the card markup + * and the 400-odd lines of markdown conversion behind it exist once. + */ + options: { interactive?: boolean } = {} ): boolean { + const interactive = options.interactive !== false; const list = document.getElementById('release-list'); if (!list || !Array.isArray(releases) || releases.length === 0) return false; list.textContent = ''; @@ -113,8 +125,24 @@ export function renderReleaseList( const body = renderMarkdown(release.body || '', release, repo, releaseSummaries); item.append(header, body); list.appendChild(item); - setupReleaseDisclosure(item, body, index); + if (interactive) setupReleaseDisclosure(item, body, index); } updateReleaseTimeline(releases.map((release) => mergeRelease(release, fallbackByTag, releaseSummaries))); return true; } + +/** + * Attach the collapse affordance to cards that were rendered at build time. + * + * The server render skips it because it measures `scrollHeight`, which is 0 + * without layout. This runs once on load, against the markup already on the + * page, so the notes are readable before any script runs and gain the + * collapse once one does. + */ +export function hydrateReleaseDisclosures(): void { + const items = document.querySelectorAll('#release-list .release-item'); + items.forEach((item, index) => { + const body = item.querySelector('.release-body'); + if (body) setupReleaseDisclosure(item, body, index); + }); +} diff --git a/site/src/scripts/changelog/render-static.ts b/site/src/scripts/changelog/render-static.ts new file mode 100644 index 00000000..73893b21 --- /dev/null +++ b/site/src/scripts/changelog/render-static.ts @@ -0,0 +1,70 @@ +/** + * Render the release list at build time, using the same renderer the browser + * uses. + * + * The page used to ship "Loading release notes..." and paint the notes in + * from script. Everything needed was already known at build time — the + * entries come from the repo's own CHANGELOG.md — so a crawler or a reader + * without JavaScript got an empty page whose entire purpose is release + * notes. + * + * Running the real renderer under a build-time DOM, rather than writing a + * second server-side one, is deliberate: the card markup sits on top of ~400 + * lines of markdown-to-DOM conversion, and two copies of that would drift + * apart quietly. The one thing skipped is the disclosure that collapses long + * cards, which measures `scrollHeight` and so needs a laid-out page; the + * client attaches it on load. That also means the no-JS rendering shows + * every entry in full, which is the better failure direction. + */ +import { parseHTML } from 'linkedom'; + +import { renderReleaseList } from './release-list'; +import { normalizeTag } from './summarize'; +import type { ChangelogRelease } from './types'; + +export function renderReleaseListHtml( + releases: ChangelogRelease[], + repo: string, + releaseSummaries: Record +): { list: string; timeline: string } { + const { document, window } = parseHTML( + '' + + '
' + + '' + + '' + ); + + // The renderer reaches for these three; none of them exists in Node, and + // all three are only consulted for behaviour we are skipping here anyway. + const globals = globalThis as Record; + const saved = { + document: globals.document, + window: globals.window, + requestAnimationFrame: globals.requestAnimationFrame, + }; + globals.document = document; + globals.window = Object.assign(window, { + location: { origin: 'https://netscli.com' }, + matchMedia: () => ({ matches: false }), + }); + globals.requestAnimationFrame = (fn: () => void) => fn(); + + try { + // Nothing is confirmed released at build time: GitHub has not been asked + // yet. Marking them unreleased keeps the same rule the client follows — + // never link a tag until something has confirmed it exists. + const asUnreleased = releases.map((release) => ({ ...release, unreleased: true })); + const fallbackByTag = new Map( + releases.map((release) => [normalizeTag(release.tag_name || release.name), release]) + ); + renderReleaseList(asUnreleased, repo, fallbackByTag, releaseSummaries, { interactive: false }); + return { + list: document.getElementById('release-list')?.innerHTML ?? '', + timeline: document.getElementById('release-timeline')?.innerHTML ?? '', + }; + } finally { + globals.document = saved.document; + globals.window = saved.window; + globals.requestAnimationFrame = saved.requestAnimationFrame; + } +} From a51bda5256a895f2f60082e29f1c6b54f1983b54 Mon Sep 17 00:00:00 2001 From: Felix Stubner Date: Mon, 24 Aug 2026 18:31:02 +0100 Subject: [PATCH 3/4] Record the desktop app's colour tokens, and what they fail design-tokens.json extracts the two themes from apps/netscli-gui/src/styles/tokens.css so the frontend gate can measure contrast. tokens.css stays the file the app reads; this is a description of it, kept by hand, and worth nothing if it drifts. Adding it moves the gate from CONDITIONAL to BLOCK, which is the point of adding it. --text-muted fails the 4.5:1 body-text bar in both themes -- 3.43:1 dark, 3.49:1 light -- and it is not a decorative token: 43 rules use it for placeholders, form labels and detail-pane text. WCAG allows 3:1 only at 18pt or 14pt bold, and a token carries no size, so the body-text bar is the only safe one to hold it to. Nothing here changes the colours. Fixing the contrast alters how the app looks, which is a design decision rather than a mechanical one; the failing pairs are recorded so that decision is taken deliberately instead of the token quietly passing because nobody measured it. --- design-tokens.json | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 design-tokens.json diff --git a/design-tokens.json b/design-tokens.json new file mode 100644 index 00000000..e738f97a --- /dev/null +++ b/design-tokens.json @@ -0,0 +1,36 @@ +{ + "dark": { + "surface-base": "#181c24", + "surface-body": "#0d1015", + "surface-elevated": "#20242d", + "surface-input": "#11141a", + "surface-selected": "#203738", + "border-subtle": "#2a303b", + "border-strong": "#343b49", + "text-main": "#e4e7ef", + "text-secondary": "#9aa2b3", + "text-muted": "#667085", + "accent": "#3eddb0", + "accent-deep": "#0aae7a", + "info": "#1edcff", + "danger": "#ef4456", + "warning": "#f5a524" + }, + "light": { + "surface-base": "#f8fafc", + "surface-body": "#eef1f5", + "surface-elevated": "#ffffff", + "surface-input": "#ffffff", + "surface-selected": "#dff7ef", + "border-subtle": "#d7dde6", + "border-strong": "#c2cad6", + "text-main": "#17202b", + "text-secondary": "#4d5a6b", + "text-muted": "#7b8797", + "accent": "#008a63", + "accent-deep": "#007052", + "info": "#0476a8", + "danger": "#c6293d", + "warning": "#a96700" + } +} From f68bacdce86409c8caf3a662c42194119aa244f1 Mon Sep 17 00:00:00 2001 From: Felix Stubner Date: Thu, 27 Aug 2026 10:44:32 +0100 Subject: [PATCH 4/4] Lift muted text to the contrast bar it is used at --text-muted was 3.43:1 on the dark base and 3.49:1 on the light one, against a 4.5:1 body-text bar. It is not a decorative token: 43 rules use it for placeholders, form labels and detail-pane text, and WCAG allows 3:1 only at 18pt or 14pt bold. A token carries no size, so whichever component renders it at 13px decides whether the pair was ever legible. Dark #667085 -> #7a8499 (4.54:1), light #7b8797 -> #687484 (4.54:1). Both are about 20/255 per channel, chosen as the smallest step along the existing colour that clears the bar rather than a new grey -- the token is meant to be quiet, and this keeps it quiet while making it readable. Checked in the running app, not only by ratio: the setting descriptions, the empty detail pane and the traffic units still read as secondary to the text beside them. The frontend gate now reports 4 pairs >= 4.5:1 across 2 themes, which takes the acceptance verdict back from BLOCK to CONDITIONAL. --- apps/netscli-gui/src/styles/tokens.css | 4 ++-- design-tokens.json | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/netscli-gui/src/styles/tokens.css b/apps/netscli-gui/src/styles/tokens.css index 685e63b7..a8f51ed3 100644 --- a/apps/netscli-gui/src/styles/tokens.css +++ b/apps/netscli-gui/src/styles/tokens.css @@ -64,7 +64,7 @@ button { --border-strong: #343b49; --text-primary: #e4e7ef; --text-secondary: #9aa2b3; - --text-muted: #667085; + --text-muted: #7a8499; --mint: #3eddb0; --mint-deep: #0aae7a; --focus-ring: color-mix(in srgb, var(--mint), transparent 35%); @@ -145,7 +145,7 @@ button { --border-strong: #c2cad6; --text-primary: #17202b; --text-secondary: #4d5a6b; - --text-muted: #7b8797; + --text-muted: #687484; --mint: #008a63; --mint-deep: #007052; --focus-ring: color-mix(in srgb, var(--mint), transparent 24%); diff --git a/design-tokens.json b/design-tokens.json index e738f97a..75624fd6 100644 --- a/design-tokens.json +++ b/design-tokens.json @@ -9,7 +9,7 @@ "border-strong": "#343b49", "text-main": "#e4e7ef", "text-secondary": "#9aa2b3", - "text-muted": "#667085", + "text-muted": "#7a8499", "accent": "#3eddb0", "accent-deep": "#0aae7a", "info": "#1edcff", @@ -26,7 +26,7 @@ "border-strong": "#c2cad6", "text-main": "#17202b", "text-secondary": "#4d5a6b", - "text-muted": "#7b8797", + "text-muted": "#687484", "accent": "#008a63", "accent-deep": "#007052", "info": "#0476a8",