Repository navigation
Site: sync from product-site-template (star CTA, npm downloads, privacy module) - #550
Merged
Merged
Conversation
* Fix the two real defects a Lighthouse sweep found
Heading order on the changelog page. Each release card's title is an <h2>,
but renderMarkdown mapped CHANGELOG.md's `###` sections to <h5> via
`length + 2` capped at 5 -- a two-level skip on every card, and the only
accessibility failure on the site. Now clamped to start at h3, so the
document reads h2 -> h3 -> h4.
Unsized screenshots on /docs/desktop/. The four GUI images were plain
markdown, which cannot carry width/height, so the browser had no aspect
ratio to reserve space from. Converted to <img> with explicit 2000x1125,
and the three below the fold get loading="lazy".
Measured, desktop preset, before -> after:
changelog a11y 98 -> 100, heading-order 0 -> 1
docs-desktop perf 99 -> 100, CLS 0.046 -> 0.023, LCP 835ms -> 648ms,
unsized-images 0.5 -> 1
Also corrected a stale comment in changelog.astro: it justified the
explicit heading margins by citing the UA default for h5, which these
headings are no longer.
* Add a Lighthouse gate to the site checks
Catches the class of regression the existing gates cannot see: an image
shipped without dimensions, a render-blocking script, a heading level
skipped inside generated markup. The last of those was live on the
changelog page and no gate reported it -- axe passes a skipped heading
order here, and the contrast sweep only reads colour.
Runs the real Lighthouse against every route discoverRoutes finds in
dist/, behind the same startPreview helper the contrast sweep and visual
snapshot use, on its own port.
Two deliberate divergences from its siblings, both documented in the
script so nobody "fixes" them back:
- It does not use resolveChromedriverPath. Every other browser gate
drives Chrome through selenium and needs a chromedriver matching
Chrome's major version exactly. Lighthouse launches Chrome itself via
chrome-launcher and speaks CDP, so there is no version to match.
- It extends the package's own desktop preset rather than restating the
four constants. Setting formFactor alone fails validation, because
screenEmulation stays at its mobile default; hand-copying the
constants is also how desktop layout ends up scored against mobile
throttling unnoticed.
Floors are measured, not round numbers. Accessibility 100,
best-practices 95, SEO 95 -- all three reproduced exactly on every run.
Performance sits at 85 because it is the only score that moves with
machine load: the same three pages measured 91-93 while CI ran on the
same machine and 100 once it was idle, and a later run put an unrelated
page at 91. A floor a developer cannot reproduce gets widened until it
means nothing.
Two exemptions, both harness artefacts rather than site defects:
best-practices is 96 on the non-Starlight pages because the Cloudflare
RUM beacon is CORS-blocked when served from 127.0.0.1, which every run
here is; and /404.html scores SEO 69 on is-crawlable because it carries
noindex, which is correct for a 404. The 404 is exempted by route so a
real SEO regression elsewhere still fails.
Verified: 14/14 routes clear every floor, exit 0.
* Docs affordances, and install copy doing the opposite of its job DOCS Last updated and Edit this page: both are Starlight's own components and cost a line of config each -- but our Footer override REPLACES the footer they render in, so the flags were live and nothing appeared anywhere on the page. Enabled in config and hosted in the override. That is the standing cost of overriding a component: the override inherits responsibility for everything the original rendered. Copy page: a control beside the title that puts the page's markdown on the clipboard, backed by new /docs/<page>.md routes. Hidden until its script wires it, because a visible control that does nothing is worse than none. It keeps the <h1 id="_top"> that the skip link and the table of contents anchor to -- PAGE_TITLE_ID is not in the package's exports map, so the literal is load-bearing and is commented as such. /llms-full.txt: every documentation page's text in one file, the companion to /llms.txt's link map. A model following the map alone has to fetch eleven pages to answer anything. INSTALL PANEL "Hash-verified" is gone. It labelled four package-manager rows so that the direct download would read as different -- saying the unremarkable thing four times to make the remarkable thing stand out once. The verification is the norm; not having it is the news, so only the news is written down, on the row it applies to. That warning now sits under the Download button rather than in the label column, where a caveat about clicking something rendered nowhere near the thing you click. Semicolon dropped. Copy buttons on the Scoop and script rows were never missing -- they were hover-gated. The always-show class already existed for exactly this case and was only applied to the recommended row. Download is right-aligned, and no longer aligned against 50px of padding reserved for a copy button those rows never render. FAQ The command list mixed two axes: "Windows CLI/TUI/MCP", "Windows app", then a bare "Windows" that was Scoop and also the CLI -- three rows saying Windows, meaning three different things. Labelled by package manager, which is what actually varies between them. Dropped the packet-capture paragraph from the licensing answer. It was a duplicate: "Does NetsCLI require libpcap or other system dependencies?" in Limits and dependencies already answers it, in better prose. It existed only in aHtml, so the FAQPage structured data never carried it. Verified: astro check 0 errors, axe clean in both themes, contrast clean across 14 routes, Lighthouse 14/14 with accessibility 100 on every docs page -- the new control included. * Two navigations at once, Arch as the default Linux, and the accent shouting HEADER: the docs bar showed a full nav AND a hamburger between 901 and 1152px. The hamburger appears at 72rem (header.css) and the sidebar leaves at 72rem (sidebar.css), but the nav links and theme control were hiding at 900px -- so in that 251px band the reader got six links, a theme control, and a hamburger whose menu (MobileMenuFooter) lists those same six links and that same control. The 900px was deliberate: it matched the landing bar so the two halves of the site collapsed together, and the note said so. That reasoning is sound and loses anyway -- the duplication is visible on one screen, the cross-page difference only by navigating between pages. Both now collapse at 72rem with the hamburger. Nav.astro keeps its own 900px, because the landing page has no sidebar and no hamburger to duplicate; the two numbers are deliberately different now rather than a pair to keep in step. LINUX INSTALLS: the first entry is the recommended one and renders as the big card, so the order is a recommendation rather than a list. Desktop led with yay -S netscli-gui-bin, recommending Arch to everyone on Linux with the .deb most readers wanted two rows below. Debian/Ubuntu leads now, then AppImage, then AUR. Same reordering on the CLI list, where AUR sat ahead of a package manager that works on every distro. COPY PAGE: right-aligned. The row is inside Starlight's .sl-container, which is itself a flex container, so without width:100% the row was a flex ITEM that shrank to its contents -- leaving space-between nothing to distribute and the button jammed against the title. FOOTER: Last updated and Edit page moved below the Previous/Next cards. They are a footnote about the page you just read; the pagination is where you go next and should not be pushed down by metadata. The built-with line now matches the landing footer instead of sitting in a centred band of its own behind another rule. ACCENT WEIGHT: the table header underline and the code block's top rule were both 2px of the same accent. Two unrelated elements competing with the loudest colour on the page, and at that weight the code rule read as a coloured header band rather than a hairline. Both 1px. COPIED STATE: was #3fb950 hardcoded in three places and Starlight's --sl-color-green in a fourth. None of them this site's accent, which is why it looked borrowed. All four use --ui-accent-bright now. NARROW CODE BLOCKS: below 700px the button is always visible (no hover to reveal it) and opaque, so untitled blocks reserve air above the code. The reservation was the full button plus 0.5rem, which read as an empty row above every one-line command. Button is smaller here now and the air is trimmed with it: 36px to 26.4px. Not removed -- with no reservation the button covers the first line, and a one-line install command is exactly where that hides the part you came to read. Verified: astro check 0 errors, axe clean in both themes, contrast clean across 14 routes in both themes, Lighthouse 14/14 with accessibility 100. Header measured at 1000px: nav links hidden, theme control hidden, hamburger visible. * Move Copy page under the hero, and stop spending the accent on decoration COPY PAGE: it was a PageTitle override, which put the control in the hero band opposite the h1. Even right-aligned it floated -- the hero is a gradient band holding one thing, and a small pill at the far end of it belongs to nothing. Starlight renders the page as two stacked panels, hero then content, so this is a MarkdownContent override now and the control sits at the top of the second panel with the text it copies. Measured: its right edge is 981px, the same as the content's. That also deletes the PageTitle override rather than moving it, which matters beyond tidiness. That override had to hardcode id="_top" on the h1, because Starlight's PAGE_TITLE_ID is not in the package's exports map and both the skip link and the table of contents anchor to it. Starlight's own PageTitle renders again, so that literal is no longer ours to keep correct. Verified: h1 id is _top and the skip link targets #_top. ACCENT: the green rule along the top of every code block is gone, and the table header underline is a hairline rather than the accent, in both themes. Thinning these from 2px to 1px earlier treated the weight when the problem was repetition. A docs page carries six or more code blocks; an accent worn by every one of them marks nothing, and the same green was also underlining every table header, tinting every inline code chip, and colouring the syntax highlighting inside the blocks. The frame's own border already says where a code block starts, and a table header already has its own background, so both rules were decoration on top of boundaries that existed. The accent stays where it marks state or identity rather than repeating: links, the active sidebar item, focus rings, the copied state, and the one inset on the docs header bar (header.css:58), which is a single element seen once per page. The light theme's table rule is a separate declaration and was missed on the first pass -- the green survived there while the dark theme had lost it, which reads as deliberate and is worse than not having started. LIGHTHOUSE: the gate printed its table, passed, and then exited non-zero. chrome-launcher's destroyTmp runs rmSync on its temp profile from the child's exit handler, and on Windows Chrome has not always released the directory by then, so EPERM was thrown from a callback no await can wrap. It gets a profile directory we own now, which skips that path. Linux CI never saw this, which is why #414 went green. Verified: exit 0, no EPERM. Verified: astro check 0 errors, contrast clean across 14 routes in both themes, Lighthouse 14/14 clearing every floor. * Take the fill off the current sidebar item, and the copy button out of the code SIDEBAR: the current page carried four signals at once -- white text, an accent left border, a 12% accent wash, and the weight -- while the rule above it gives that page's GROUP a 13% wash and the same accent border. The two were nearly identical, so the wash was not distinguishing the page from its parent; the border and the text colour were already doing that, and the wash sat on top of both. Dropping it separates them: the page gets border plus white text, the group keeps wash plus border. Same rule as the code blocks and table headers -- the accent marks state, not surface. COPY BUTTON: on a phone there is no hover to reveal it, so it is always visible, and it is opaque -- anywhere over the code it hides a line. Two earlier passes reserved room INSIDE the frame instead: the full button plus 0.5rem, then a trimmed 26.4px. Both read as an empty band above every one-line command, on the screens with the least room, because the button was still over the code and the code was getting out of its way. It is outside the block now and the reservation is gone: measured, the button's bottom edge is 952px against the frame's top at 958, and the pre's padding-block-start is 0. The mechanism is worth stating because it looks wrong at a glance. .copy is a DOM child of .frame, and the frame is overflow:hidden for its rounded corners, so a negative offset would simply be clipped. Un-positioning the frame at this width hands .copy a containing block further up -- the .expressive-code wrapper -- and an absolutely positioned element is not clipped by an intermediate ancestor's overflow when its containing block sits above that ancestor. The frame keeps clipping the code surface itself. Safe because .copy is the only thing anchored to the frame: its own .feedback is positioned against .copy, and the accent ::before that used to sit on the frame no longer paints. Verified: astro check 0 errors, axe clean in both themes, contrast clean across 14 routes in both themes, Lighthouse 14/14 clearing every floor with exit 0. Button measured visible at 0.9 opacity, not clipped. * Shorten the docs title band on wide windows At 1440px the band resolved to 130px around a 37.6px title -- roughly two thirds of it empty, reading as a near-square block rather than a band, and the largest single part of the 266px a reader crossed before the first sentence. clamp(6.75rem, 9vw, 8.25rem) becomes clamp(5.5rem, 7vw, 6.75rem). Measured at 1440: band 131px to 102px, first sentence 266px to 237px. The longest title, "Core library and crates", still sets on one line. Not reduced further: below about 5.5rem the corner wash in shell.css loses the area it needs to read as a gradient, and the title crowds the header. The contents rail reads the same token for its offset (toc.css), so it follows automatically rather than needing a matching edit. Worth knowing, and now written down in theme.css: this token only governs windows at or above 72rem. Below that shell.css sets its own heights -- clamp(8rem, 18vw, 10.5rem) under 71.99rem, a flat 8.5rem under 49.99rem -- so a narrow window measures about 136px whatever this says. The band is therefore now SHORTER on a desktop than on a phone. Nothing is clipped: a long title wraps to two lines down there and the band grows to hold it, because min-height is a floor rather than a cap. Left alone deliberately -- narrowing the band on the screens with the least vertical room is its own decision, not a side effect of this one. Verified: contrast clean across 14 routes in both themes, axe clean in both themes, Lighthouse 14/14 clearing every floor with exit 0.
…P image (#420) * Move the released version into the hero badge The badge above the headline was a static platform list -- "Windows . Linux . macOS" -- repeating what the install section says and pointing nowhere. The released version sat below the headline instead, as the third item on a line of metrics, where it was the one entry that was not a metric and the one that had somewhere useful to point. They swap. The badge becomes "v0.3.1 . What changed ->" linking to the changelog, and the version leaves the social-proof line. No new network call: the tag comes from the releases fetch that already runs for the download counter, which resolves past drafts and prereleases. That fetch can be rate-limited or fail (unauthenticated GitHub is 60/hour per IP and each load spends two), so the badge is SERVER-RENDERED with the platform list and only upgraded once a release confirms a tag -- it is never empty and never names a version nothing confirmed. Composed rather than hard-coded, since the site shares subtree history with product-site-template: the behaviour hangs off a new optional `hero.releaseLink`. Omit it and the badge stays the static string it was, which is the right default for a product with no changelog page or no published releases. Not uppercase, unlike the badge it replaces: the badge's own text-transform rendered the tag as "V0.3.1", and a version string is a literal with a case of its own. Kept in the muted step rather than the accent -- the accent belongs to the install button, and a second accent-coloured thing directly above the headline competes with it. Verified against the running page: badge reads "v0.3.1 . What changed ->" and links to /changelog/ (200); the metrics line reads "15* . 2.6K+ downloads . View source" with no dangling separator; no #latest-version element remains. astro check 0 errors, css-shadowing 0/1114, css-regions OK, build clean. * Prioritise the docs header wordmark, which is the LCP element On a docs page this image IS the Largest Contentful Paint. There is no hero down there, so the wordmark is the largest thing painted, and it is on the critical path by default rather than by choice. It is also discovered late: it sits behind the inlined stylesheet in <head> and competes with nothing that announces itself as urgent. Cloudflare Web Analytics, 24h to 2026-09-15, bots excluded, names this element as the LCP element at 5,415ms against a 599ms P50 on the same day. The asset is 8,845 bytes, so those five seconds are discovery and connection rather than transfer, which is what this hint addresses and what shrinking the image would not. Read that field data with its sample size in view: five LCP samples in the window, so P90 and P99 are both 5,415ms because they are the same single observation. This is a hint worth acting on because the fix is one attribute and cannot regress the warm-cache case, NOT a measured typical page load. The Lighthouse gate added in #414 passes at the same time, and lab and field disagreeing at N=5 is expected rather than evidence of a regression. Deliberately not set on the landing page's copy of the same wordmark in components/Nav.astro. There the hero screenshot is the LCP element and already carries fetchpriority="high"; raising the logo too would put two images at the front of the queue and demote the one that actually is the largest paint.
…ting (#421) ALIGNMENT. A Copy button and a Download button on rows stacked directly on top of each other sat on different vertical lines. Measured before the change: copy 10px from the row's right edge, Download 16px. They disagreed because they are positioned by different mechanisms. The Download button lives in `.alt-action`, which is `justify-self: end` in the row's grid, so it lands on the content-box edge -- `.alt`'s own 16px padding. The copy button is absolutely positioned against `.alt` and so answers only to its own `right`. Nothing tied the two numbers together, so they drifted apart the moment one was set. Moved the copy button to 16px rather than pulling the Download button in, because the padding is the row's real edge and everything else in the row already respects it. Scoped to `#install .alt` so the recommended command's button, which sits inside a different container with its own inset, is not dragged along with it. HOVER GATING. The alternatives show their copy buttons on hover and keyboard focus again, instead of permanently. This reverses part of #416, which extended `always-show` to these rows on the grounds that a hover-hidden control reads as a missing one. That reasoning holds for the recommended command -- one row, the thing most visitors act on -- and does not survive being applied to the list beneath it, where six permanently lit buttons compete with the route the panel is steering people towards. The recommended row keeps `always-show`. No layout shift: `.alt-cmd` already reserves the button's width whether or not it is painted, verified by measuring the command's right edge with the button hidden and shown (581px both). Touch is unaffected. With no hover to reveal them, the (hover: none) rule in landing/base.css keeps every copy button at 0.7 opacity -- confirmed at 375px with the media query matching. Verified: astro check 0 errors, css-shadowing 0 of 1114.
…nmap (#422) NMAP. Dropped "raw packet workflows" from the list of things nmap does better. Two reasons, and the first is that it is not true as written: pcap-enabled builds capture to a file and summarise an existing one, so the answer was conceding ground the product holds. The second is that the site already concedes this territory elsewhere, to a different tool. /docs/packet-capture/ says "not a Wireshark replacement... use Wireshark or tshark for deep protocol dissection". Two pages naming two different winners for the same ground is worse than either claim alone. Deep packet work is Wireshark's, and that page keeps it. The three that remain -- advanced service detection, NSE scripts, OS fingerprinting -- are real and not closable. An NSE engine with 600+ community scripts and a stack-signature database built since the 90s are not gaps to close, they are a different product. Conceding them is what makes the rest of the answer credible. IP SCANNERS. The Angry IP Scanner / Advanced IP Scanner answer listed our own adjectives ("cross-platform desktop app/TUI/CLI/MCP interfaces, structured output, MIT-licensed Rust core") and never said anything checkable about either tool it names. It now names facts that are properties of THOSE tools, verified against their own sites rather than recalled: - advanced-ip-scanner.com states "Compatible with Windows 11, 10, 8, 7" and is Famatech freeware, not open source. Windows-only and closed-source are the two differences that actually hold. - angryip.org advertises "Provides command-line interface" on its front page, and is open source and cross-platform. So a CLI is NOT a difference from that one, and the answer no longer implies it is. That claim was about to be written before checking. The answer also now concedes remote administration, which neither the old text nor the plan for this change included. Advanced IP Scanner's front page leads with RDP/Radmin control, shared folders and remote shutdown. NetsCLI has no equivalent, and an answer that omitted the thing a reader can see on that page in ten seconds would discredit everything around it. Both `a` and `aHtml` carry the change: `a` is used verbatim in the FAQPage JSON-LD, so a change made only to the visible copy leaves the structured data asserting the old text to search engines and answer engines. Verified on the running page: "raw packet workflows" appears nowhere in the rendered text or the JSON-LD, and the new IP-scanner answer is present in both. astro check 0 errors. No semicolons in either answer.
Three advisories were each blocking the others. cargo audit and npm audit are
separate required checks, and every open pull request failed at least one of
them, so none of the three single-fix branches could merge:
RUSTSEC-2026-0285 rustls 0.23.40 medium TLS 1.3 handshake messages
accepted across encryption
level boundaries
GHSA-vwc7-r8mq-g2x9 adm-zip <=0.6.0 HIGH extraction follows symlinks,
+ GHSA-7q85-xj36-vmfc arbitrary file overwrite;
uncontrolled allocation (DoS)
GHSA-9rgm-9g3h-6x36 devalue <5.9.1 moderate DoS via malformed input
The deadlock is why this is one branch rather than three. #427 carried the
rustls fix and failed npm audit on the other two; #424 and #425 carried the
npm fixes and failed cargo audit on rustls. Each was red for something it did
not cause, and branch protection correctly refused all of them. Fixing them
together is the only ordering that passes.
The npm halves are Dependabot's own lockfile changes from #424 and #425,
applied unmodified, so this supersedes both rather than competing with them.
The Rust half is `cargo update -p rustls`: a patch release inside the 0.23
line, no manifest change, carrying rustls-webpki 0.103.13 -> 0.103.15.
WORTH NOTING for whoever reads this later: the adm-zip advisory is HIGH and
had been sitting unmerged because its PR showed a failing cargo audit -- a
check an npm bump cannot break. A failure attributed to the wrong cause is
how a high-severity fix waits. When an audit fails on a PR that cannot have
caused it, check main before debugging the branch.
Verified locally: `cargo audit` exits 0 with only the three warnings the
config already allows (event-listener unsound, chacha20 and spin yanked), and
`npm audit` in site/ reports 0 vulnerabilities. Lockfiles only -- no manifest
and no source changed.
…te (#407) Bumps [selenium-webdriver](https://github.com/SeleniumHQ/selenium) from 4.48.0 to 4.49.0. - [Release notes](https://github.com/SeleniumHQ/selenium/releases) - [Commits](SeleniumHQ/selenium@selenium-4.48.0...selenium-4.49.0) --- updated-dependencies: - dependency-name: selenium-webdriver dependency-version: 4.49.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…408) Bumps [chromedriver](https://github.com/giggio/node-chromedriver) from 152.0.3 to 153.0.1. - [Commits](giggio/node-chromedriver@152.0.3...153.0.1) --- updated-dependencies: - dependency-name: chromedriver dependency-version: 153.0.0 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…avy (#426) PARAGRAPH BREAK. `.release-body>*+*` sets 14px between every block in a release card. `.release-paragraph` then set `margin:0`, whose implicit `margin-top:0` beat it -- both selectors are one class deep, so source order decided, and the reset came later. On v0.3.1 that put the curated summary and the body's own first paragraph flush against each other, so two paragraphs rendered as one unbroken run of text with a line wrap between them. Measured before the change: the paragraph's computed margin-top was 0px where the flow rule asks for 14px. Only the bottom and inline margins are reset now, which hands the top back to the flow rule. Nothing wanted a zero there: a paragraph is never a card's first child, because `.release-summary` always is. Headings are untouched and keep their own 26px. FADE COLOUR. The collapsed-card overflow gradient ended at `--ui-surface` -- #191d25 in dark, rgb(25,29,37), with blue twelve points above red. The card underneath is `.release-item`, `--ui-lift-rgb` at 2.8% over the page background, which composites to a neutral grey. Fading a neutral card into a blue-tinted stop is what made the shadow read as navy instead of as the card continuing past the cut. Every stop is on the page-background channel now. That lands ~2% off the card's exact composite (17,17,17 against 23,23,23 in dark), which is below perception at the end of a gradient; the old stop was wrong by twenty points on one channel, which was not. Both tokens flip with the theme, so there is no light-theme variant to keep in step -- verified in light, where the fade ends on rgb(251,251,251) against the same neutral card. Verified on the running page in both themes: paragraph margin-top 14px, heading margin-top still 26px, fade ending neutral. astro check 0 errors, css-shadowing 0 of 1114, css-regions OK.
…ing (#430) Dependabot's astro-group bump (#406) failed `astro check` with one error: src/components/starlight/Header.astro:2:20 - error ts(2307): Cannot find module 'virtual:starlight/user-config' Starlight builds its virtual modules at runtime through a Vite plugin, so nothing on disk corresponds to them and TypeScript has to be told they exist. Up to 0.41.9 the package shipped a root `virtual.d.ts` declaring `user-config`. 0.42 moved every type under `dist/` and dropped that file. What remains is `dist/integrations/vite-virtual-modules.d.ts`, which types the plugin itself rather than declaring anything ambient for a consumer. So the import that had been relying on the package to declare it no longer has a declaration, and the upgrade fails to typecheck. This repository already keeps `src/starlight-virtual.d.ts` for exactly this problem: Starlight has never declared the per-component virtual modules, and all seven the site imports are shimmed there. `user-config` was the only one the package still covered. It now sits with its neighbours, which makes the file complete rather than partial -- every `virtual:starlight/*` the site imports is declared in one place. `any`, matching the rest of the file. The alternative is importing Starlight's own config type out of `dist/utils/`, a path the package does not export and is free to move again -- which is the failure mode being fixed, not a precedent to extend. Header.astro reads two properties off it. Supersedes #406 rather than competing with it: the version bump is that PR's, applied unchanged. Verified against 0.42.1 locally: astro check 0 errors, the site builds all 14 pages, the docs shell still renders through our Header override, and both CSS gates pass.
* Name hosts from mDNS when reverse DNS cannot (#418) Consumer routers do not serve PTR records for their own DHCP clients, and appliances ignore LLMNR and NetBIOS, so `reverse_lookup_best_effort_timeout` returns nothing for exactly the devices someone opened the app to identify. Meanwhile those devices announce their names over mDNS continuously, and netscli has shipped an mDNS browser the whole time -- as a separate operation discover never consulted. WHAT RUNS. A browse starts at the top of `scan_subnet_with_progress`, before the ping sweep, and is awaited after the reverse-DNS stage. It overlaps both. Names fill only hostnames still empty, so nothing that resolves today changes and the output is additive in effect as well as in shape. Fusion happens after `hostname_map` rather than inside the resolve stage, so ARP-only neighbours are named too -- the devices that never answered a probe are the same ones least likely to have a PTR record. THE WINDOW IS MEASURED, not picked. On an ordinary home LAN, unique hosts reached their ceiling of 7 at 1000ms and did not move at 1500, 2000 or 3000ms; 500ms was unstable across repeats (3 hosts, then 5) and 250ms returned nothing twice. 1500ms sits past the plateau with margin, and well under the 3000ms the explicit `/mdns` browse uses -- that one is a deliberate "go and look", this rides along. COST, measured on the same network: a /24 discover takes 2317-3466ms, so the browse is absorbed whole. A /30 takes 1631ms, where the window dominates and adds roughly 1.4s. That is the deliberate trade -- issue #418 asked for no extra wall-clock at all, which would have meant cutting the browse off when the sweep finished and starving small subnets. The issue's acceptance criterion is updated to say what this actually does. ATTRIBUTION. New additive field `hostname_source: Option<NameSource>` with `Reverse` and `Mdns`, following `found_by`'s precedent. Deliberately a separate axis rather than more `FoundBy` variants: a host can be found by probe and named by mDNS, or found in the neighbour table and named by reverse DNS, in any combination, and one enum cannot carry two independent facts without becoming a product of them. SAFETY. Every mDNS name goes through `normalize_hostname`, the same function the reverse lookups use -- now `pub(crate)`, gated to the mdns feature so the default build does not carry an unused import. This is not tidiness. An mDNS hostname is a string chosen by whoever runs the other machine, exactly the untrusted remote text ARCHITECTURE.md requires be treated as data and never as control, and that function is what rejects a device calling itself an escape sequence that would repaint the terminal or forge the row above it. NO NEW DiscoverPhase VARIANT. `DiscoverPhase` is not `#[non_exhaustive]`, so adding `Mdns` would break any library consumer matching on it -- a breaking change to fix a cosmetic gap in progress reporting. The browse runs silently. FEATURE GATED throughout. Without `mdns`, discover behaves exactly as before. Both shipped binaries have it: the CLI through `features = ["db", "mdns"]`, the desktop app through `default = ["mdns"]`, so this reaches every surface. Verified on a real /24: 26 hosts, 21 named -- 18 from reverse DNS and 3 that were previously blank, now named from mDNS. Full workspace `clippy -D warnings` clean on both feature paths, cargo fmt clean, 86 core + 35 CLI tests pass, GUI typecheck passes. The serialization contract test now pins `hostname_source` and its lowercase wire form alongside `found_by`. * Split discover's data types out of its engine The mDNS fusion took discover.rs from 269 lines to 318, past the 300-line module guidance, and it has no transition exception. ARCHITECTURE.md's answer to that is a new owner module rather than a bigger facade, so the split follows the seam that was already there. discover/types.rs now holds FoundBy, NameSource, Host, DiscoverPhase and DiscoverProgress -- the shapes every surface reads, which change for different reasons than the scan logic does. discover/mdns_fusion.rs holds the browse and the name map. discover.rs is back to 258 lines and is the engine again. One behavioural simplification came with it: the name/source resolution in `build` had duplicate cfg arms for mdns on and off. The map is empty without the feature, so the fallback simply never fires and one path serves both. Verified after the move: clippy -D warnings clean on both feature paths and across the workspace, cargo fmt clean, file-size guard passes, 86 core and 35 CLI tests pass, and a real /24 still reports 26 hosts with 3 named from mDNS that reverse DNS left blank.
The Linux desktop AppImage aborted before opening a window: Could not create default EGL display: EGL_BAD_PARAMETER. Aborting... linuxdeploy's AppRun puts $APPDIR/usr/lib first on LD_LIBRARY_PATH, and the image carries its own copies of the wayland client stack, libxkbcommon, and part of the xcb/X11 stack. So the host's Mesa was made to talk to the wayland client library from the runner that built the release. On a host new enough for those to diverge, eglGetPlatformDisplay fails. Tauri's bundler cannot be configured out of this. Its linuxdeploy invocation takes no exclusion arguments (tauri-bundler 2.9.4, src/bundle/linux/appimage/linuxdeploy.rs), and the GTK plugin it ships deploys with `--library=`, which overrides linuxdeploy's own exclude list. libwayland-client.so.0 is on the official AppImage exclude list and was bundled anyway, which is how we know that list is not consulted on this path. The image is therefore corrected after it is built, at the first point this repository controls. Verified against the real v0.3.1 release asset: 159 bundled libraries -> 150, all nine gone from the repacked image, no unresolved dependencies, and the app still launches. Running the script twice removes nothing the second time. The step fails when its glob matches nothing. A change to Tauri's output path would otherwise turn it into a silent no-op, and the first sign of that would be another bug report against a shipped release. Not fixed here: the blank-window half of #378, which is WebKitGTK compositing failing against a partly-supporting driver. Forcing WEBKIT_DISABLE_COMPOSITING_MODE would disable hardware compositing for every user to help the few on such a driver, so the install page names the variable instead.
On some Linux hosts the desktop window opens and never paints. WebKitGTK's hardware compositing fails against a driver that only partly supports it, with no error and no crash. Reported on a vmwgfx VM. `WEBKIT_DISABLE_COMPOSITING_MODE=1` fixes it and costs hardware compositing, so it is not set for everyone. A settings toggle was not an option: every GUI preference lives in the webview's localStorage, and the webview is the part that is not rendering. Someone looking at a blank window cannot reach it. So the controls are a command-line flag, which they can still use, and recovery that needs no control at all. main() arms a marker before the Tauri builder runs, which is before the web process is spawned and so before WebKit reads its environment. The UI clears the marker after two animation frames -- a mounted React tree is not a painted one, and paint is the step that fails. A launch that finds the marker still armed concludes the previous one never drew anything, disables compositing and prints why. `--disable-gpu-compositing` and `--gpu-compositing` set it explicitly and are remembered. An explicit flag always beats the marker, or the flag would look broken. Linux only. WEBKIT_DISABLE_COMPOSITING_MODE means nothing to WebView2 or WKWebView, so there is no misfire to worry about on the other two platforms. The decision is a pure function over (previous state, flag) so it is tested without a filesystem or a window. The frontend test guards the reporter specifically: if that call stops working, nothing clears the marker and every Linux user loses hardware compositing on their second launch, which is invisible to everything else -- the app looks identical, only slower, and the e2e render harness does not run on CI. Verified the test fails when the double frame wait is reduced to one.
Three changes that only make sense together, because they share one CI job and one release gate. adm-zip 0.6.1 clears both advisories filed against it: the symlink traversal one an exception in `scripts/audit-production.mjs` covered (GHSA-vwc7-r8mq-g2x9), and the uncontrolled memory allocation one that had no exception and was failing `audit:production` outright (GHSA-7q85-xj36-vmfc). The second is why `verify:release` could not pass, so no release could be cut today whatever else was ready. The exception then has to go. The script rejects an entry matching no advisory, deliberately, so the list cannot accumulate permission nobody re-examined -- which is why the dependabot bump went red while its tests passed on all three platforms. The bump fixing the advisory is what invalidated the exception for it. devalue 5.9.1 in the landing lockfile fixes the step immediately after: `npm --prefix landing audit` started failing on GHSA-9rgm-9g3h-6x36 sometime after 11 September. That audit has no exception list, so any advisory fails it. astro asks for ^5.8.1 and 5.9.1 satisfies it, so this is the lockfile entry alone -- independent of #378, which moves astro. The production tree and the landing tree now both audit clean.
…436) * Put the docs header's controls back where they can be reached (#436) Two faults, one cause. Header.astro moved the width at which the nav links hide from 900px to 72rem, to stop the bar showing six links beside a hamburger whose menu lists the same six. Two rules that depended on 900px stayed where they were. The layout seam. header.css hands the auto margin to .docs-nav-links in the collapsed band, with a second rule giving it to search instead below 900px "because the links are hidden there". Above 900px the links are now hidden too, and an element with display:none takes no part in flex layout, so nothing carried the seam. Measured on the built site: search at x=207 with 861px of empty bar to its right at 1142px, and 621px at 902px. Search now carries the seam across the whole band and the special case is gone. The theme control. Hidden at 72rem, with a note saying the mobile menu's footer carried it from there down. It did not. That footer opens from Starlight's .sl-menu-button, which is md:sl-hidden and only appears below 50rem, so between 800px and 1152px there was no button to open it and no control in the header. Measured: two controls in the DOM at 1082, 982, 882 and 832px, neither visible at any of them. The rule is gone; the element's own sl-hidden/md:sl-flex classes already hide it below 50rem, which is where the menu genuinely does carry it. Neither was visible to anything in CI. Both are about where an element sits, so axe passes (nothing is unlabelled), the contrast sweep passes (every colour pair is fine) and Lighthouse passes (the page is fast). A human noticed the search button. scripts/header-controls.mjs measures the header's controls at seventeen widths, clustered around the breakpoints that carry the layout, and asserts the outcome rather than the numbers so it survives the next time they move. Verified it fails against the build without this change, reporting all four bands. * Put the header's links back too, not just the theme control The first pass moved the theme control back into the header for the 800-1152px band and left the links hidden there, reasoning that the sidebar lists the same pages. It does not. The sidebar lists the pages of the docs; the header links are Features, Install, FAQ, Docs, Changelog and GitHub, and five of those six appear nowhere else on the page. So that band still had no route to the rest of the site, which is what a reader noticed. The hide moves to 50rem, where Starlight's .sl-menu-button actually appears and MobileMenuFooter genuinely does carry them. The old 72rem was chosen to avoid showing the links beside a hamburger listing the same links, but that hamburger does not appear at 72rem -- it is md:sl-hidden. Room is not the constraint: at 802px the four elements come to 612px of the 744px the bar has. With the links back, the `order: 2`/`order: 3` that moved search and the theme control into a right-hand cluster are unnecessary -- they existed because the links were gone -- so DOM order applies again and the bar reads the same at every width above 50rem: wordmark, seam, search, links, theme. Header.astro's own `margin-left: auto` on the links goes with them: two auto margins on one flex row split the free space rather than adding to it, which put 206px between the search icon and the first link at 1142px. header-controls.mjs gains the assertion this pass needed and did not have: that the site links are on screen wherever there is no menu button to reach them. Written without it, the check passed a build that had just lost them.
#380) Two paths deleted user content while reporting success. Both are in the half of the product whose promise is that it does not: PRODUCT.md says setup is reversible and that content outside the managed block survives. `.claude/settings.json` was replaced whenever it failed to parse. `readJsonIfExists` answers `null` for a missing file and for an unparsable one alike, and `installClaudeHook` read that as "nothing there yet", built a fresh document and wrote it -- so a trailing comma or a `//` comment cost the user their model, env, other hooks and `permissions.deny`, and setup printed `ok`. `writeMcpConfig` already refuses in exactly this situation ("leaving it unchanged"); this path now does the same and reports the refusal through setup's `failures`, which exits nonzero. A refusal cannot be returned as "no change": the hook is what puts handoff in front of the agent without it asking, so skipping it silently looks identical to a working setup. `disconnect` deleted skills the user wrote. Setup offers skills it finds in `.claude/skills/`, and for claude-code that same path is the `native-skill` sync target -- so selecting your own pre-existing project skill meant disconnect removed the original, then pruned the empty directory. A content hash cannot catch this: the synced copy is byte-identical to the canonical one precisely because xtctx copied it from there. Provenance can, and `.xtctx/config.yaml` has recorded it as `skills.selected.<id>.source` all along -- it was simply never read back. A target whose recorded source resolves to that same file is now kept and reported as `:kept`. Tests fail against the previous behaviour: the settings one on the file having been overwritten, the skills one with ENOENT on the user's own SKILL.md.
Measurements from 2026-09-20, taken while looking for a way to make indexing faster. None of them changed code. Three of the four ideas were wrong, which is most of why this is worth keeping. All of it measured on real segments from this project's index, mean 883 characters. That is the point: the last round of this work measured 18ms per embed on strings like "warm query number 5", concluded mpnet was affordable, and had to be reverted the next day. What won: DirectML at ~10ms/segment against ~60-82ms on CPU, producing numerically identical vectors (mean cosine 1.000000, worst 0.999999) -- so no re-index and no threshold re-sweep. Batch 16 beat batch 32 in three paired runs. bge-small beat MiniLM on every metric once swept to its own thresholds. What lost, with numbers: q8 gave 16% rather than the assumed 2x and moves every vector; a duplicate-segment cache would save 5.6%, not half, because pack boundaries shift with each window's start; multi-process embedding has little headroom because ONNX Runtime already uses 9-11 of 24 cores. Also two README corrections found while checking it. opencode does have a plugin system -- JS modules or npm packages -- it just does not implement the Agent Plugins standard, which is the accurate and narrower claim. And the "19 GB Codex store in under ten seconds" figure cannot be reconciled with a 1m 06s scan measured on this project today, so it is replaced by what the code actually guarantees: resume from a per-file offset.
) Design only; nothing is implemented. The interesting part is not the HTTP call -- `EmbeddingProvider` is already an interface the index takes by injection, so a second implementation is about eighty lines. It is the three things around it. Vector identity: the table is keyed by model name, so two services both serving `text-embedding-3-small` would share a key while producing vectors in different spaces. The stored identity has to include the endpoint. Thresholds: measured today rather than assumed. On the 60-query eval, bge-small and gte-small both scored a false-positive rate of 1.00 at MiniLM's thresholds -- every unanswerable query, gibberish included, returned something. They are not worse models; they place their cosine values higher, and a floor tuned to one distribution does not transfer. An unknown remote model arrives in exactly that state, so thresholds are per-provider and status warns when they are unswept. Local-only: unchanged as the default and as the shipped behaviour. The claim is reworded to say what it already means -- local-only by default, with an opt-in a project writes deliberately -- rather than retracted.
The site had no route back. A visitor who hit a broken install command or a docs page that skipped a step had nowhere to put that except finding the repo themselves, and most will not. Two buttons above the footer, opening a prefilled GitHub issue, plus a Feedback link in the footer row. Issue links rather than a form because the site is static and stays that way: a form means a backend or a third party, inherited by every product cloned from this template, plus spam handling and a privacy surface none of these sites currently have. Issues land where the work already is, and the reporter can watch what happens next. The cost is real and is written into feedback.ts rather than left implied: a visitor without a GitHub account will not file one. That trades volume for reports from people already close to the project, which is the right way round for a developer tool but is not free. `template` and `issueLabels` are optional -- GitHub ignores a template that does not exist and opens a blank issue, so a repo with no issue forms still gets working links, and a typo degrades rather than breaks. Nothing warns about that typo, which the type documents. Deliberately not a section in `sections.ts`: the feedback block is not part of the product's pitch, and reordering it is not a content decision.
Bumps [svgo](https://github.com/svg/svgo) from 4.0.2 to 4.1.0. - [Release notes](https://github.com/svg/svgo/releases) - [Commits](svg/svgo@v4.0.2...v4.1.0) --- updated-dependencies: - dependency-name: svgo dependency-version: 4.1.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 4.3.1 to 4.3.2. - [Changelog](https://github.com/nodeca/js-yaml/blob/4.3.2/CHANGELOG.md) - [Commits](nodeca/js-yaml@4.3.1...4.3.2) --- updated-dependencies: - dependency-name: js-yaml dependency-version: 4.3.2 dependency-type: indirect ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [nanoid](https://github.com/ai/nanoid) from 3.3.11 to 3.3.18. - [Release notes](https://github.com/ai/nanoid/releases) - [Changelog](https://github.com/ai/nanoid/blob/3.3.18/CHANGELOG.md) - [Commits](ai/nanoid@3.3.11...3.3.18) --- updated-dependencies: - dependency-name: nanoid dependency-version: 3.3.18 dependency-type: indirect ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
The self-hosted Windows runner went offline and every queued run sat there indefinitely -- not passing, not failing, never arriving. PR #26 waited on a check that could not report, and the pull request looked like it was waiting on CI rather than on a machine that was not coming back. A check that cannot run is worse than a metered one, because nothing about it looks broken. This repo is private, so hosted minutes are billed; that is the trade being made deliberately. Four things in the workflow were load-bearing on the self-hosted assumption and are corrected with it: - The guard step refusing to run self-hosted on a public repo is dead code once the runner is hosted, and is removed. The security condition it protected against -- a fork's PR executing its own code on a personal machine -- cannot arise on a hosted runner. - setup-node's cache: npm is now on. Its own comment said caching is "a win on a fresh hosted VM and a loss on this runner"; the runner is now the former. - The leftover-preview-server cleanup existed because a cancelled sweep held a port into the NEXT run. A hosted runner is destroyed after the job, so nothing survives to conflict. - The timeout and single-job comments described queuing behaviour that only applied with one machine. The same gates already run on ubuntu-latest in netscli's site.yml, including test:a11y and check:contrast, so the browser sweeps are known to work on Linux.
* site: say when the preview port is held by something on another host (#359)
The guard that refuses to reuse an existing server probes the URL these
checks use, 127.0.0.1. A hand-started `astro preview` daemonises and binds
`localhost`, which resolves to ::1 first on Windows -- so the probe reports
the port free, the spawn cannot bind it, and the run ends 30 seconds later
with "Timed out waiting for http://127.0.0.1:4322" and a build log that
looks like the site is broken.
It is not a rare corner: the sweeps leave a daemon behind whenever they are
interrupted, and it cost three debugging detours this afternoon before the
cause was clear. The port is already enumerable -- listenersOnPort() exists
for cleanup -- so this is a lookup, not new machinery.
Verified by reproducing it: a daemon on 4322, then `npm run test:a11y`,
which now exits 1 naming the pid and the fix instead of timing out. Both
sweeps pass again once the daemon is stopped.
* site: make the theme control a real menu, and group the docs bar's buttons (#362)
The theme control was a native <select> on both bars. The list a select drops
down is drawn by the operating system: on Windows it is white rows with black
text, positioned by the platform rather than under the trigger, and no CSS
reaches it. Replaces it with a button and a menu -- ordinary elements that
answer to ordinary rules -- in one component that both bars render, so the
landing page and the docs cannot drift apart.
Three bugs the replacement had, all found by driving the built page:
- The menu was permanently open. `.ui-theme-menu` sets `display: flex`, and
the `hidden` attribute's `display: none` lives in the browser's own
stylesheet, which any author rule beats. The script was setting
`menu.hidden` correctly the whole time; nothing was listening.
- On docs pages the control did nothing. Only Nav.astro called the init, and
the docs bar renders through starlight/ThemeSelect.astro, which Nav.astro
knows nothing about. The component wires itself now.
- A docs page renders the control twice -- header and mobile menu -- so the
menu's fixed id was duplicated. Dropped the id and the trigger's
`aria-controls`; `aria-haspopup` plus `aria-expanded` is what the pattern
needs.
The docs bar's layout, at widths where the search collapses to an icon: it
sat beside the wordmark with 454px of nothing before the next element
(measured at 1150px, x=207 against a wordmark ending at 188). It joins the
theme control and the menu button now, which are the other two icons that
size. Above 72rem the labelled box stays beside the logo as before.
Also: the nav separator's space is a margin rather than padding on both bars,
so the current-page underline -- a border on the same box -- starts at the
word instead of 15px to its left; and the sidebar rail inset drops from
2.2-3.15rem to 1.5-2.25rem, which the table of contents mirrors because both
rails read one token. Deleted a stale second copy of the old inset in
toc.css, and one of --ui-control-menu-bg, for the same reason: a duplicate
number can only drift.
Verified on the built site: contrast sweep clean across 14 routes in both
themes; axe clean on all 14 pages in both themes; css-shadowing 0 of 1116;
region and wordmark guards pass; no type errors under src.
* site: give the TUI screenshot's gradient the same span as the nav wordmark (#363)
The screenshot's linearGradient was userSpaceOnUse across x=0..1200, the full
canvas, while the ASCII logo occupies only the middle ~636px of it. The word
therefore sampled t=0.24..0.76 -- the teal middle of the ramp, never the dark
green start or the cyan end. Beside the nav wordmark, which does span its own
width, the two logos read as different colours:
nav wordmark #047f46 #09a570 #0fba9b #16cbcd #1bd5eb
screenshot #07a86c #09b97f #0dc69a #12d3bc #15d7cd
Same stops, different slice. The gradient is objectBoundingBox now, so it
spans each element it fills. After: #047e45 #09a570 #0eba9d #0ea1ce #17dbfc.
That is also what the TUI itself does. Both gradient_text_line_with_left_pad
and draw_gradient_rounded_border in apps/netscli-cli/src/tui/widgets/
gradient.rs walk t from 0 to 1 across the element's own width, so the input
box's border now matches the running program too.
The stops are untouched: #005a1e -> #0aae7a -> #1edcff is what the TUI
renders and what the wordmark uses. The site's own brand gradient token is a
different ramp, picked for contrast on text the page draws itself; this asset
depicts the terminal, so it follows the terminal.
PNG and WebP re-rendered from the SVG through headless Chrome at 1640x930,
the committed size. Verified first by re-rendering the UNCHANGED svg and
diffing against the committed PNG, so the pipeline was known to reproduce the
asset before it was used to replace it. The re-render also restores a space
in "host workstation" that the previous PNG had lost.
* site: stop the changelog printing a release's intro prose twice (#372)
* site: stop the changelog printing a release's intro prose twice
The 0.3.1 entry rendered its whole intro as the summary and then again as the
body. Two causes, both fixed here.
summarizeRelease filtered blank lines out before looking for the first
paragraph, which left the paragraph loop nothing to stop at -- it ran to the
next `###` heading and returned the entire intro. Blank lines are kept now and
end a paragraph. 0.3.1 is the first entry whose CHANGELOG section opens with
prose, which is why a bug this old only just showed.
That derivation only runs when a tag has no curated summary, and 0.3.1 had
none: the map held 'v0.3.0', which was tagged but never published and has no
release for the page to describe. Replaced with a 'v0.3.1' line, and noted on
the map that any release opening with prose needs one or the page prints that
prose twice.
Verified by rebuilding and capturing /changelog/ before and after: before, the
three intro paragraphs appeared as the summary and again below it; after, one
curated line then the body. check:changelog, check:css, check:regions and tsc
over src all pass.
* site: rewrite the 0.3.1 summary without a mid-line colon or an em dash
The line this replaces broke two of Felix's own rules for release copy in one
sentence: a colon mid-line ("since 0.2.6: richer port-scan results") and an em
dash opening a trailing clause ("workspace -- plus a long tail"). Both were
introduced in the previous commit on this branch, so nothing published carried
them.
Two plain sentences instead, matching the shape the other entries already use.
Leads with what changed for someone using it rather than with how long it took.
Vale's AIProseTells style passes on the new line, and it contains no colon
mid-line, no em dash and no en dash.
* site: give 0.3.1 a summary proportionate to the release, and drop the colons
The 0.3.1 line undersold what shipped. The headline of this release is that
the desktop app was redesigned around a denser diagnostic workspace, and the
summary opened on how long it took instead. Rewritten from the CHANGELOG's own
entries: the redesign and its tab handling first, then the cross-interface
changes, then the fixes.
Also removes the mid-line colon from the two older summaries that had one
(v0.1.1, v0.1.0) and from the CHANGELOG's 0.3.1 intro, matching the rule the
0.3.1 line was just fixed for. Wording is otherwise untouched in those three.
Verified across all 11 blocks: no mid-line colon, no em dash, no en dash, and
Vale's AIProseTells style passes. check:changelog still reports 8 entries
rendering the date CHANGELOG.md declares.
* site: remove the white strip down the right of the TUI screenshot (#373)
Self-inflicted in aef5815. The script that re-rendered the asset set both
width and height in CSS, so the svg's preserveAspectRatio fit the artwork
inside that box and left an uncovered strip down the right edge, which the
capture painted white. Measured on the committed files: the previous PNG had
0 white pixels on its middle row, the re-rendered one had an 18px white bar,
and the bottom row carried 18 white pixels too. It showed on the page wherever
the screenshot appears -- the hero and the Terminal UI surface card.
Re-rendered at a fixed width with the height left to follow, so nothing is
letterboxed, and painted the render page the artwork's own #0b0d0e as a second
line of defence against a rounding sliver. The asset is 1640x929 rather than
1640x930: that is the svg's true aspect at this width, and the extra row was
where the letterboxing was hiding.
Verified: 0 white pixels on the top and bottom rows, both mid-row edge pixels
are the artwork background (11,13,14), and the logo gradient still tracks the
nav wordmark (#047e45 #09a570 #0eba9d #0ea1ce #17dbfc, unchanged by this fix).
Rebuilt and captured the hero to confirm the strip is gone on the page.
* site: cut the retrospective sentence from the 0.3.1 summary (#374)
The line ended "Four months of work since 0.2.6, and much of the long tail is
fixes for code that reported success while doing nothing." Two sentences
describing the release, then a third narrating the work on it. That shift into
retrospective voice is what made the card read like a debrief rather than a
summary, and "the long tail" appeared twice on the same page because the
phrase was lifted from the CHANGELOG body directly below it.
Enumerating individual fixes instead was the other option and reads
defensively. The body is one click away and lists all of them, so the summary
does not need to.
Also drops "four months of work since 0.2.6", which spent a sentence on
something the release date on the same card already says.
* Count cargo installs, refresh the README shots, and clean up packaging (#382)
* Count cargo installs, refresh the README's desktop shots, drop a stale token file
Three things found while auditing what we would be publishing.
**Downloads said "total" while counting one source.** The figure summed
GitHub release assets only, so `cargo install netscli` was invisible.
crates.io sets `access-control-allow-origin: *`, so the count is fetched
client-side alongside the GitHub one, with each source recorded separately
and the label re-rendered from whichever arrive -- a partial count beats a
spinner that never resolves.
Only the `netscli` crate is counted. netscli-core and netscli-mcp are
libraries, so their 615 downloads are dependency resolution and docs.rs
builds; adding them would count one `cargo install` three times. Measured on
the built site: 2,501 total against 2,335 before, and crates.io reports
`downloads: 166`.
**The README's desktop screenshots were from 17 April**, before the 0.3.1
redesign, and `gui-dashboard.png` showed a screen the app no longer has --
"Dashboard" does not appear anywhere in the GUI source. Replaced with the
e2e suite's current 2000x1125 captures of Scan, Discover, DNS and
Interfaces, so a run regenerates them rather than a person, and the Dashboard
entry is gone.
**The root `design-tokens.json` was read by nothing and had drifted.** It
claimed `danger #ef4456` and `accent #3eddb0` where the GUI is `#f47582` and
the site is `#22c55e`. scripts/design-tokens.mjs generates and CI verifies
`apps/netscli-gui/design-tokens.json` and `site/design-tokens.json`; a third
root copy could only duplicate or lie, and lied. Deleted. design-direction.md
quoted the same dead red, fixed to `#f47582` -- the other seven hexes in that
paragraph were checked against tokens.css and are correct -- and it now says
which files are the checked ones.
Verified: `node scripts/design-tokens.mjs` reports 196 contrast pairs at or
above 4.5:1 across 2 surfaces; tsc over src clean; the site builds; and no
reference to gui-dashboard.png or the deleted root token file remains.
* docs(packaging): correct the Scoop README's bucket path and hash provenance
Two claims in packaging/scoop/README.md described behaviour that is not
what the release pipeline does, both of them the residue of corrections
that landed elsewhere and never reached this file.
The bootstrap step said to put the manifests in the bucket repo root.
Scoop accepts that layout; the automation does not. publish-scoop.sh and
publish-scoop-gui.sh edit bucket/netscli.json and bucket/netscli-gui.json
by name, so a root manifest would leave every release editing a file that
is not there. The live bucket has always used bucket/.
The after-each-release paragraph said the scripts parse the .sha256
sidecar and write its value straight into the manifest. That was the
circular-verification bug fixed in lib.sh's verified_sha, which downloads
the asset, hashes the bytes, and aborts unless the sidecar agrees. The
correction was written into packaging/README.md at the time and missed
this copy.
Also records, in the validation section, that the GUI manifest's
"installer": { "type": "msi" } is not in Scoop's current manifest
schema -- which defines installer with additionalProperties: false and no
type -- and that Scoop documents the MSI mechanism as deprecated. Written
as an open question rather than a fix: it needs a real scoop install on
Windows to say what users actually get.
* chore(packaging): clean up the manifest templates and stop the tap drifting
Follow-up to the packaging audit. Four unrelated pieces of staleness, all
of the same kind: a file that reads as a description of what ships, and
is not one.
Templates: all six manifest templates sat at 0.3.0, a version that was
tagged, never published, and whose tag has since been deleted. Bumped to
0.3.1 and added to the version-bump procedure in docs/PUBLISHING.md, with
anchored greps -- three of these files carry an old version inside a
comment describing a past mistake, so a bare version grep over them would
report a second value forever.
Winget snapshots: cli/0.3.0/ and gui/0.3.0/ described that same
never-released version, and gui/0.2.4/ was a submission a moderator
rejected, so no such manifest is in the catalog. All three were also
hand-written to a shape the pipeline does not emit -- ManifestVersion
1.9.0 with InstallerType nested under Installers:, where winget-releaser
via komac produces 1.12.0 with InstallerType and Commands at the document
root. Replaced with byte-identical copies pulled from microsoft/winget-pkgs
(cli/v0.2.6/, gui/0.2.6/), which is what a reference copy is for: comparing
a generated PR against a real accepted manifest.
Homebrew tap: publish-homebrew.sh patched only `version` and the four
`sha256` lines, so the rest of Formula/netscli.rb in the tap was frozen at
whatever bootstrapped it and survived seven releases -- a header describing
a manual release process that no longer exists, naming VERSION_SHA256_*
placeholders the file does not contain, and a `desc` that had diverged
from the template. It now regenerates the formula from
packaging/homebrew/netscli.rb, which already carries @@...@@ digest
placeholders in the right positions, so there is still exactly one copy of
the formula body. Adds a leftover-placeholder check and a `ruby -c` syntax
check, since a template that no longer parses would otherwise reach the
tap and break every brew install.
Winget version comparison: docs/PUBLISHING.md said winget "normalises a
leading v when comparing". It does not -- Versions.cpp trims whitespace
only, and no code strips a letter prefix. Each part splits into a leading
integer and a remainder, and a part with a non-empty remainder sorts below
one without, so v0 < 0 and every v0.2.x sits under any 0.3.x. The measured
result in that section is unchanged and still correct; only the
explanation was wrong, and it mattered: normalisation would make v0.2.6
and 0.2.6 the same version, where the real rule makes them two, with 0.2.6
the higher.
* Take the in-range fixes for six npm advisories (#384)
Lockfile only -- no package.json change, because all three bumps sit
inside ranges the tree already asks for:
svgo 4.0.2 -> 4.1.0 (astro wants ^4.0.1)
fast-uri 3.1.5 -> 3.1.7 (ajv wants ^3.0.1)
js-yaml 4.3.1 -> 4.3.2 (astro/starlight want ^4.3.0)
Clears all six open Dependabot alerts (svgo x2, fast-uri x4) and the
js-yaml advisory npm audit reports but Dependabot has not raised.
None of them is exploitable here, and the bump is not being sold as a
fix for a live hole. svgo's is that `removeScripts` fails to strip
executable content from an SVG you handed it expecting sanitisation;
every SVG in this repo is first-party and checked in. fast-uri's four
are SSRF and host confusion when parsing an attacker-controlled URI, and
it is reached only through ajv resolving schema $refs during a build.
js-yaml's is CPU exhaustion on malicious YAML, and the YAML parsed here
is our own. The site ships static HTML, CSS and JS -- no node_modules
reach a visitor -- so the whole exposure is the build host.
They are taken because they are free, not because they are urgent.
Left alone: adm-zip 0.6.0, reached via chromedriver via @axe-core/cli,
three moderate alerts for symlink-following zip extraction. npm's only
offered fix is @axe-core/cli 4.7.3, which is a MAJOR DOWNGRADE from the
4.13.0 in the tree. Taking a two-year-old accessibility runner to silence
a moderate advisory about extracting the ChromeDriver zip that Google
serves over TLS is the worse trade. Revisit when @axe-core/cli ships a
chromedriver with adm-zip > 0.6.0.
Not verified locally: `npm ci` refuses to run here because nvx's
supply-chain guard blocks astro@7.3.2 for being under 24 hours old --
which is already in the lockfile and unrelated to this change -- so the
installed node_modules still holds the old versions and a local build
exercises them, not these. CI's `npm ci` is the check that this resolves.
* Fix two wrong field tables in the result model (#387)
* docs: fix two wrong field tables in the result model
Found auditing every docs page against the code it describes.
Interfaces and ARP were merged into one table that gave `addresses`,
`state` and `loopback` as result fields. None of the three is a field.
`addresses` is the desktop app's column *header* over the `ips` field;
`state` and `loopback` are desktop row keys derived from `is_up` and
`is_loopback`, and `loopback` is not even shown as a column. `vendor` was
listed as though it applied to interfaces, which it does not -- it is an
ARP field. So the one page whose job is telling a consumer what comes back
named three things that are not in any JSON, YAML or MCP payload.
Split into two tables, one per shape, with a second column giving the
desktop heading where it differs. Everywhere else on the page the wire
name and the displayed name coincide; here they do not, and the page had
been silently merging them.
Sweep's shape was wrong in the other direction. The host table listed
`open_ports` as a "sweep-only" field, which reads as a sibling of `ip`.
`SweepEntry` has no serde flatten, so both the CLI and the MCP
`sweep_network` tool emit `{"host": {...}, "open_ports": [...]}` -- anyone
following the table would write `jq '.[].ip'`, which is right for discover
and empty for sweep. Given its own subsection with the real JSON and the
`jq '.[].host.ip'` that works.
Also adds `found_by` to the host table, `ip` to the ARP table, and `ping`
and `trace` to the core-library module map, all of which exist and were
simply missing.
Verified by reading each table against the structs in netscli-core,
`columns.ts`/`rows.ts` in the desktop app, and the rendered HTML.
* ci: correct two stale winget-GUI comments that outlived their situation
Both were written while the initial `fstubner.netscli.gui` submission was
still open, and both now describe a state that ended when PR #368471
landed.
One said the `netscli-gui` moniker "may resolve after the package is
accepted into the Winget catalog". It resolves: 0.2.6 is live at
manifests/f/fstubner/netscli/gui/0.2.6/ and publishes
`Moniker: netscli-gui`, exactly as the CLI publishes `Moniker: netscli`.
This one had teeth. The landing hero and install section lead with the
short form deliberately -- there is a comment in hero.ts explaining the
choice -- while the install guide gives the identifier. Read against this
warning, that looks like the site contradicting itself on how to install
the desktop app, and it was reported as a defect during a docs audit
before the catalog was checked. Nothing on the site was wrong.
The other warned that the job "will still fail" until winget-pkgs had
accepted a first version, since the action looks for an existing package
directory. The directory exists.
* docs: keep the new three-column tables from blowing out on mobile
The interfaces table added in the previous commit was 1158px wide at a
375px viewport -- the widest table on the page by 370px, and nearly four
times its 304px container.
Below 50rem the docs stack gives every table `table-layout: fixed;
width: max-content` inside a horizontally scrolling wrapper, so a table is
exactly as wide as its longest cell needs. Adding a third column while
writing 91- and 134-character sentences into the last one is what did it:
the final column alone was sized to 856px.
No CSS change. The prose belonged outside the table anyway -- a
134-character cell with a semicolon in it is not a table cell. The
boolean-to-string rendering note now sits in the paragraph under the
table, where it also has room to explain the Kind column properly, and the
cells are short labels again.
Measured at 375px on the built site: the interfaces table is 672px, the
floor set by `min-width: 42rem`, down from 1158px, and both new tables are
now narrower than four of the six that were already there. Page horizontal
overflow stays 0.
* Site audit fixes: download counter, winget alignment, chromedriver bump (#396)
* Only claim a download total once both sources have reported
renderDownloads summed GitHub release assets and crates.io but wrote the
label as soon as either resolved, so "total" was asserted before it was
true. Measured on the running site: the same page showed "Downloads: 178
total" with only crates.io in, and "Downloads: 2,635 total" once the
GitHub count landed -- a fifteenfold difference under one label.
Track whether each source has settled separately from its count: a
rate-limited source never sets a value, so gating on the counts alone
would leave the label waiting forever. Settle in .finally so a failed or
rate-limited fetch settles too. Until both are in, the figure is a lower
bound and says so.
Verified in both directions by forcing the crates.io fetch to fail:
partial renders "2,458 so far; one source has not reported", complete
renders "2,636 total".
* Use the short winget identifiers in the install docs
The landing page and FAQ said `winget install netscli`; the install docs
said `winget install fstubner.netscli`. Both resolve, so this was
inconsistency rather than error, and the short form is what the landing
page leads with.
The full identifiers stay documented in one line, because a bare name
match can become ambiguous if another netscli ever enters the catalog
and `fstubner.netscli` cannot.
* Bump chromedriver to 152 to match the installed Chrome
visual:check drives Chrome through chromedriver, and the pinned 151
crashes against Chrome 152 with a GetHandleVerifier stack trace rather
than failing cleanly -- so the gate reported nothing at all.
NOT verified locally: the sandbox here records the lockfile change but
discards the postinstall binary download, leaving chromedriver.exe at
151 on disk. The bump is correct for a clean `npm ci`; it has not been
proven to fix the crash on this machine.
* Show the interfaces, and tell answer engines what the docs pages are (#398)
Six changes from a documentation pass, each verified against the running
site rather than by reading the diff.
README
Winget commands move to the short identifiers, matching the site and the
landing page; the full identifiers stay documented once, because a bare
name match can become ambiguous and `fstubner.netscli` cannot. The pin
example was `NETSCLI_VERSION=v0.1.0`, nine releases stale, and the GUI
installer note claimed installers were attached "as of v0.2.1" when they
have shipped with every release since.
Architecture diagram
Redrawn. It showed CLI/TUI, desktop and MCP as three peer boxes, which
reads as three things to install -- while the docs lead with the opposite:
`netscli` with a command is the CLI, with none it opens the TUI, and
`netscli serve` is the MCP server. The dashed group is that fact, and the
desktop app sits outside it because it genuinely is a separate download.
A prose line above the diagram now says so too.
Docs search position
The seam that splits the header's two groups moves from .docs-nav-links to
.docs-header-search, so search sits with the navigation instead of beside
the wordmark. Measured at four widths, because the band below 72rem resets
search to `margin-inline: 0` and takes the seam with it -- without the
compensating rule there, nothing carries the seam between 900px and 72rem
and the whole bar bunches left.
Screenshots
/docs/desktop/ had no picture of the desktop app and /docs/tui/ none of
the terminal UI. Four GUI shots and one TUI shot, from the set the README
already uses.
Captured output for the CLI and MCP pages
Every fence on those pages was a command with no output. These are real
runs against loopback, not written-out examples. Three commands were
dropped rather than sanitised: `interfaces` and `inspect` report the
machine's own addresses, and the human-readable `scan` prints its source
address, so all three leaked a real Tailscale address, LAN address and
internal hostname. What ships is verbatim.
Dependency diagram
The box-drawing art did not align. Replaced with an inline SVG that reads
the theme's own tokens. Note for the next person: no blank lines inside
raw HTML in Markdown. The first attempt had them, the parser closed the
<svg> early, and every label rendered as loose paragraphs under the
heading while the shapes rendered inside an empty SVG.
Structured data on the docs pages
The landing page and changelog get JSON-LD from layouts/Page.astro, which
Starlight does not use -- so the eleven docs pages, the ones that answer a
question someone typed, shipped none. A Head override adds a TechArticle
and a BreadcrumbList per page, the breadcrumb's middle rung read from the
sidebar config so it cannot drift from the navigation.
The SoftwareApplication node in Page.astro gains an `@id`. Without it the
docs pages' `isPartOf` pointed at an entity that did not exist, which is
worse for a crawler than no reference: twelve unrelated things instead of
one product described once.
Verified: build passes (14 pages), JSON-LD present in the BUILT html and not
only the dev server, breadcrumbs resolve to the right sidebar group on two
sampled pages, all five images load, no host data in any docs source, and
check:css / check:regions / check:changelog all clean.
Not verified: visual:check has never run here -- chromedriver 151 against
Chrome 152, and the sandbox discards the postinstall binary download. This
branch adds five images and two diagrams to a site with a 240-image
baseline, so that gap matters more than usual.
* site: hide the CHANGELOG's internal section from the release notes page (#409)
CHANGELOG.md keeps 'Changed (internal)' -- refactors, CI plumbing and
test-harness work belong in the repo's history -- but nobody reading
netscli.com/changelog can act on 'internals reduced from monolithic files
into facades', and in 0.3.1 that section ran to fifty-odd lines sitting
above the fixes people came for.
Cut at the parse rather than in the renderer. The raw body is serialised
into the page for the client to re-render from as well as being rendered
to DOM, so filtering downstream would have left the full text in the page
source -- which is most of what this was hidden from.
Matched before any normalisation, deliberately: 'Changed (internal)' with
the parenthetical stripped is just 'changed', which is also why
summarize.ts's 'changed internal' branch has never fired.
* site: stop curated release summaries repeating the body's intro (#410)
A curated summary is written independently of CHANGELOG.md's body, so it
shares no prefix with it -- and trimSummaryPrefix only strips a paragraph
that literally starts with the summary text. That test can only pass for an
auto-derived summary, which IS the body's first paragraph. So every curated
card opened by saying the same thing twice in two voices: the summary, then
the body's own intro paraphrasing it.
Dropping the opening paragraph in the curated case is the same rule the
auto path already applies, not a new one -- the summary stands in for that
paragraph either way.
The one-shot latch keeps this to the FIRST paragraph. 0.3.1's second
paragraph carries content the summary does not cover, and a rule that
dropped all leading prose would have lost it.
* Drop the internal-changes section from the changelog (#411)
* Drop the internal-changes section from the changelog
Refactors, CI plumbing and dependency bumps are already in git history and
in the PRs they link to, more precisely than a prose summary states them.
Nobody reading release notes can act on 'internals reduced from monolithic
files into facades' -- a line whose own second half says the public API did
not change.
Removed from 0.3.1 and 0.2.6, the only two releases that carried one.
sectionLabel's 'changed internal' branch goes with it. It was already dead:
the function strips parentheticals before comparing, so 'Changed (internal)'
normalised to 'changed' and matched the branch above it. Nothing referenced
the 'internal changes' label it returned.
* Restructure the 0.3.1 notes around what changed for users
The 0.3.1 entry was written as diffs between working states during the
cycle, not as a description of what changed for someone on 0.2.6. The
tabbed desktop workspace is new in this release, so eleven entries
describing its parts -- tab reordering, right-click tab menus, the
context menu's danger styling, which tab the app opens on, notification
routing between tabs -- documented constituents of one new thing as
though each were a separate change.
Git archaeology against v0.2.6 sorted the Fixed section by path. The new
GUI never shipped, so a fix touching only apps/netscli-gui was invisible
to users: the Stop camelCase bug (#290), crash-vs-cancel (#289), the
refused-stop message (#291), the concurrency-default-1 preference bug
(#248, #223) and the settings dialog centring all came out. Fixes in
crates/ or apps/netscli-cli shipped in 0.2.6 and stayed: ping on Windows
(#246), IPv6 ping (#247), the three ARP honesty fixes (#262), the
neighbour-table merge (#243), engine safety limits (#198) and the MCP
hardening (#225).
Also dropped: GUI render automation and dependency bumps, which are
internal, and the per-PR Cloudflare previews.
438 lines to 202. No user-facing entry was removed -- the desktop app's
capabilities are now listed under the one entry that introduces it.
* Fix the two real defects a Lighthouse sweep found (#413)
Heading order on the changelog page. Each release card's title is an <h2>,
but renderMarkdown mapped CHANGELOG.md's `###` sections to <h5> via
`length + 2` capped at 5 -- a two-level skip on every card, and the only
accessibility failure on the site. Now clamped to start at h3, so the
document reads h2 -> h3 -> h4.
Unsized screenshots on /docs/desktop/. The four GUI images were plain
markdown, which cannot carry width/height, so the browser had no aspect
ratio to reserve space from. Converted to <img> with explicit 2000x1125,
and the three below the fold get loading="lazy".
Measured, desktop preset, before -> after:
changelog a11y 98 -> 100, heading-order 0 -> 1
docs-desktop perf 99 -> 100, CLS 0.046 -> 0.023, LCP 835ms -> 648ms,
unsized-images 0.5 -> 1
Also corrected a stale comment in changelog.astro: it justified the
explicit heading margins by citing the UA default for h5, which these
headings are no longer.
* Add a Lighthouse gate to the site checks (#414)
* Fix the two real defects a Lighthouse sweep found
Heading order on the changelog page. Each release card's title is an <h2>,
but renderMarkdown mapped CHANGELOG.md's `###` sections to <h5> via
`length + 2` capped at 5 -- a two-level skip on every card, and the only
accessibility failure on the site. Now clamped to start at h3, so the
document reads h2 -> h3 -> h4.
Unsized screenshots on /docs/desktop/. The four GUI images were plain
markdown, which cannot carry width/height, so the browser had no aspect
ratio to reserve space from. Converted to <img> with explicit 2000x1125,
and the three below the fold get loading="lazy".
Measured, desktop preset, before -> after:
changelog a11y 98 -> 100, heading-order 0 -> 1
docs-desktop perf 99 -> 100, CLS 0.046 -> 0.023, LCP 835ms -> 648ms,
unsized-images 0.5 -> 1
Also corrected a stale comment in changelog.astro: it justified the
explicit heading margins by citing the UA default for h5, which these
headings are no longer.
* Add a Lighthouse gate to the site checks
Catches the class of regression the existing gates cannot see: an image
shipped without dimensions, a render-blocking script, a heading level
skipped inside generated markup. The last of those was live on the
changelog page and no gate reported it -- axe passes a skipped heading
order here, and the contrast sweep only reads colour.
Runs the real Lighthouse against every route discoverRoutes finds in
dist/, behind the same startPreview helper the contrast sweep and visual
snapshot use, on its own port.
Two deliberate divergences from its siblings, both documented in the
script so nobody "fixes" them back:
- It does not use resolveChromedriverPath. Every other browser gate
drives Chrome through selenium and needs a chromedriver matching
Chrome's major version exactly. Lighthouse launches Chrome itself via
chrome-launcher and speaks CDP, so there is no version to match.
- It extends the package's own desktop preset rather than restating the
four constants. Setting formFactor alone fails validation, because
screenEmulation stays at its mobile default; hand-copying the
constants is also how desktop layout ends up scored against mobile
throttling unnoticed.
Floors are measured, not round numbers. Accessibility 100,
best-practices 95, SEO 95 -- all three reproduced exactly on every run.
Performance sits at 85 because it is the only score that moves with
machine load: the same three pages measured 91-93 while CI ran on the
same machine and 100 once it was idle, and a later run put an unrelated
page at 91. A floor a developer cannot reproduce gets widened until it
means nothing.
Two exemptions, both harness artefacts rather than site defects:
best-practices is 96 on the non-Starlight pages because the Cloudflare
RUM beacon is CORS-blocked when served from 127.0.0.1, which every run
here is; and /404.html scores SEO 69 on is-crawlable because it carries
noindex, which is correct for a 404. The 404 is exempted by route so a
real SEO regression elsewhere still fails.
Verified: 14/14 routes clear every floor, exit 0.
* Docs affordances, and install copy doing the opposite of its job (#416)
* Docs affordances, and install copy doing the opposite of its job
DOCS
Last updated and Edit this page: both are Starlight's own components and
cost a line of config each -- but our Footer override REPLACES the footer
they render in, so the flags were live and nothing appeared anywhere on the
page. Enabled in config and hosted in the override. That is the standing
cost of overriding a component: the override inherits responsibility for
everything the original rendered.
Copy page: a control beside the title that puts the page's markdown on the
clipboard, backed by new /docs/<page>.md routes. Hidden until its script
wires it, because a visible control that does nothing is worse than none.
It keeps the <h1 id="_top"> that the skip link and the table of contents
anchor to -- PAGE_TITLE_ID is not in the package's exports map, so the
literal is load-bearing and is commented as such.
/llms-full.txt: every documentation page's text in one file, the companion
to /llms.txt's link map. A model following the map alone has to fetch
eleven pages to answer anything.
INSTALL PANEL
"Hash-verified" is gone. It labelled four package-manager rows so that the
direct download would read as different -- saying the unremarkable thing
four times to make the remarkable thing stand out once. The verification is
the norm; not having it is the news, so only the news is written down, on
the row it applies to.
That warning now sits under the Download button rather than in the label
column, where a caveat about clicking something rendered nowhere near the
thing you click. Semicolon dropped.
Copy buttons on the Scoop and script rows were never missing -- they were
hover-gated. The always-show class already existed for exactly this case
and was only applied to the recommended row.
Download is right-aligned, and no longer aligned against 50px of padding
reserved for a copy button those rows never render.
FAQ
The command list mixed two axes: "Windows CLI/TUI/MCP", "Windows app", then
a bare "Windows" that was Scoop and also the CLI -- three rows saying
Windows, meaning three different things. Labelled by package manager, which
is what actually varies between them.
Dropped the packet-capture paragraph from the licensing answer. It was a
duplicate: "Does NetsCLI require libpcap or other system dependencies?" in
Limits and dependencies already answers it, in better prose. It existed
only in aHtml, so the FAQPage structured data never carried it.
Verified: astro check 0 errors, axe clean in both themes, contrast clean
across 14 routes, Lighthouse 14/14 with accessibility 100 on every docs
page -- the new control included.
* Two navigations at once, Arch as the default Linux, and the accent shouting
HEADER: the docs bar showed a full nav AND a hamburger between 901 and
1152px. The hamburger appears at 72rem (header.css) and the sidebar leaves
at 72rem (sidebar.css), but the nav links and theme control were hiding at
900px -- so in that 251px band the reader got six links, a theme control,
and a hamburger whose menu (MobileMenuFooter) lists those same six links
and that same control.
The 900px was deliberate: it matched the landing bar so the two halves of
the site collapsed together, and the note said so. That reasoning is
sound and loses anyway -- the duplication is visible on one screen, the
cross-page difference only by navigating between pages. Both now collapse
at 72rem with the hamburger. Nav.astro keeps its own 900px, because the
landing page has no sidebar and no hamburger to duplicate; the two numbers
are deliberately different now rather than a pair to keep in step.
LINUX INSTALLS: the first entry is the recommended one and renders as the
big card, so the order is a recommendation rather than a list. Desktop led
with yay -S netscli-gui-bin, recommending Arch to everyone on Linux with
the .deb most readers wanted two rows below. Debian/Ubuntu leads now, then
AppImage, then AUR. Same reordering on the CLI list, where AUR sat ahead
of a package manager that works on every distro.
COPY PAGE: right-aligned. The row is inside Starlight's .sl-container,
which is itself a flex container, so without width:100% the row was a flex
ITEM that shrank to its contents -- leaving space-between nothing to
distribute and the button jammed against the title.
FOOTER: Last updated and Edit page moved below the Previous/Next cards.
They are a footnote about the page you just read; the pagination is where
you go next and should not be pushed down by metadata. The built-with line
now matches the landing footer instead of sitting in a centred band of its
own behind another rule.
ACCENT WEIGHT: the table header underline and the code block's top rule
were both 2px of the same accent. Two unrelated elements competing with
the loudest colour on the page, and at that weight the code rule read as a
coloured header band rather than a hairline. Both 1px.
COPIED STATE: was #3fb950 hardcoded in three places and Starlight's
--sl-color-green in a fourth. None of them this site's accent, which is
why it looked borrowed. All four use --ui-accent-bright now.
NARROW CODE BLOCKS: below 700px the button is always visible (no hover to
reveal it) and opaque, so untitled blocks reserve air above the code. The
reservation was the full button plus 0.5rem, which read as an empty row
above every one-line command. Button is smaller here now and the air is
trimmed with it: 36px to 26.4px. Not removed -- with no reservation the
button covers the first line, and a one-line install command is exactly
where that hides the part you came to read.
Verified: astro check 0 errors, axe clean in both themes, contrast clean
across 14 routes in both themes, Lighthouse 14/14 with accessibility 100.
Header measured at 1000px: nav links hidden, theme control hidden,
hamburger visible.
* Move Copy page under the hero, and stop spending the accent on decoration
COPY PAGE: it was a PageTitle override, which put the control in the hero
band opposite the h1. Even right-aligned it floated -- the hero is a
gradient band holding one thing, and a small pill at the far end of it
belongs to nothing. Starlight renders the page as two stacked panels, hero
then content, so this is a MarkdownContent override now and the control
sits at the top of the second panel with the text it copies. Measured: its
right edge is 981px, the same as the content's.
That also deletes the PageTitle override rather than moving it, which
matters beyond tidiness. That override had to hardcode id="_top" on the
h1, because Starlight's PAGE_TITLE_ID is not in the package's exports map
and both the skip link and the table of contents anchor to it. Starlight's
own PageTitle renders again, so that literal is no longer ours to keep
correct. Verified: h1 id is _top and the skip link targets #_top.
ACCENT: the green rule along the top of every code block is gone, and the
table header underline is a hairline rather than the accent, in both
themes.
Thinning these from 2px to 1px earlier treated the weight when the problem
was repetition. A docs page carries six or more code blocks; an accent
worn by every one of them marks nothing, and the same green was also
underlining every table header, tinting every inline code chip, and
colouring the syntax highlighting inside the blocks. The frame's own
border already says where a code block starts, and a table header already
has its own background, so both rules were decoration on top of boundaries
that existed.
The accent stays where it marks state or identity rather than repeating:
links, the active sidebar item, focus rings, the copied state, and the one
inset on the docs header bar (header.css:58), which is a single element
seen once per page.
The light theme's table rule is a separate declaration and was missed on
the first pass -- the green survived there while the dark theme had lost
it, which reads as deliberate and is worse than not having started.
LIGHTHOUSE: the gate printed its table, passed, and then exited non-zero.
chrome-launcher's destroyTmp runs rmSync on its temp profile from the
child's exit handler, and on Windows Chrome has not always released the
directory by then, so EPERM was thrown from a callback no await can wrap.
It gets a profile directory we own now, which skips that path. Linux CI
never saw this, which is why #414 went green. Verified: exit 0, no EPERM.
Verified: astro check 0 errors, contrast clean across 14 routes in both
themes, Lighthouse 14/14 clearing every floor.
* Take the fill off the current sidebar item, and the copy button out of the code
SIDEBAR: the current page carried four signals at once -- white text, an
accent left border, a 12% accent wash, and the weight -- while the rule
above it gives that page's GROUP a 13% wash and the same accent border.
The two were nearly identical, so the wash was not distinguishing the page
from its parent; the border and the text colour were already doing that,
and the wash sat on top of both.
Dropping it separates them: the page gets border plus white text, the group
keeps wash plus border. Same rule as the code blocks and table headers --
the accent marks state, not surface.
COPY BUTTON: on a phone there is no hover to reveal it, so it is always
visible, and it is opaque -- anywhere over the code it hides a line.
Two earlier passes reserved room INSIDE the frame instead: the full button
plus 0.5rem, then a trimmed 26.4px. Both read as an empty band above every
one-line command, on the screens with the least room, because the button
was still over the code and the code was getting out of its way. It is
outside the block now and the reservation is gone: measured, the button's
bottom edge is 952px against the frame's top at 958, and the pre's
padding-block-start is 0.
The mechanism is worth stating because it looks wrong at a glance. .copy is
a DOM child of .frame, and the frame is overflow:hidden for its rounded
corners, so a negative offset would simply be clipped. Un-positioning the
frame at this width hands .copy a containing block further up -- the
.expressive-code wrapper -- and an absolutely positioned element is not
clipped by an intermediate ancestor's overflow when its containing block
sits above that ancestor. The frame keeps clipping the code surface itself.
Safe because .copy is the only thing anchored to the frame: its own
.feedback is positioned against .copy, and the accent ::before that used to
sit on the frame no longer paints.
Verified: astro check 0 errors, axe clean in both themes, contrast clean
across 14 routes in both themes, Lighthouse 14/14 clearing every floor with
exit 0. Button measured visible at 0.9 opacity, not clipped.
* Shorten the docs title band on wide windows
At 1440px the band resolved to 130px around a 37.6px title -- roughly two
thirds of it empty, reading as a near-square block rather than a band, and
the largest single part of the 266px a reader crossed before the first
sentence. clamp(6.75rem, 9vw, 8.25rem) becomes clamp(5.5rem, 7vw, 6.75rem).
Measured at 1440: band 131px to 102px, first sentence 266px to 237px. The
longest title, "Core library and crates", still sets on one line.
Not reduced further: below about 5.5rem the corner wash in shell.css loses
the area it needs to read as a gradient, and the title crowds the header.
The contents rail reads the same token for its offset (toc.css), so it
follows automatically rather than needing a matching edit.
Worth knowing, and now written down in theme.css: this token only governs
windows at or above 72rem. Below that shell.css sets its own heights --
clamp(8rem, 18vw, 10.5rem) under 71.99rem, a flat 8.5rem under 49.99rem --
so a narrow window measures about 136px whatever this says. The band is
therefore now SHORTER on a desktop than on a phone. Nothing is clipped: a
long title wraps to two lines down there and the band grows to hold it,
because min-height is a floor rather than a cap. Left alone deliberately --
narrowing the band on the screens with the least vertical room is its own
decision, not a side effect of this one.
Verified: contrast clean across 14 routes in both themes, axe clean in both
themes, Lighthouse 14/14 clearing every floor with exit 0.
* Put the release version in the hero badge, and prioritise the docs LCP image (#420)
* Move the released version into the hero badge
The badge above the headline was a static platform list -- "Windows .
Linux . macOS" -- repeating what the install section says and pointing
nowhere. The released version sat below the headline instead, as the third
item on a line of metrics, where it was the one entry that was not a
metric and the one that had somewhere useful to point.
They swap. The badge becomes "v0.3.1 . What changed ->" linking to the
changelog, and the version leaves the social-proof line.
No new network call: the tag comes from the releases fetch that already
runs for the download counter, which resolves past drafts and prereleases.
That fetch can be rate-limited or fail (unauthenticated GitHub is 60/hour
per IP and each load spends two), so the badge is SERVER-RENDERED with the
platform list and only upgraded once a release confirms a tag -- it is
never empty and never names a version nothing confirmed.
Composed rather than hard-coded, since the site shares subtree history
with product-site-template: the behaviour hangs off a new optional
`hero.releaseLink`. Omit it and the badge stays the static string it was,
which is the right default for a product with no changelog page or no
published releases.
Not uppercase, unlike the badge it replaces: the badge's own
text-transform rendered the tag as "V0.3.1", and a version string is a
literal with a case of its own. Kept in the muted step rather than the
accent -- the accent belongs to the install button, and a second
accent-coloured thing directly above the headline competes with it.
Verified against the running page: badge reads "v0.3.1 . What changed ->"
and links to /changelog/ (200); the metrics line reads "15* . 2.6K+
downloads . View source" with no dangling separator; no #latest-version
element remains. astro check 0 errors, css-shadowing 0/1114, css-regions
OK, build clean.
* Prioritise the docs header wordmark, which is the LCP element
On a docs page this image IS the Largest Contentful Paint. There is no hero
down there, so the wordmark is the largest thing painted, and it is on the
critical path by default rather than by choice. It is also discovered late:
it sits behind the inlined stylesheet in <head> and competes with nothing
that announces itself as urgent.
Cloudflare Web Analytics, 24h to 2026-09-15, bots excluded, names this
element as the LCP element at 5,415ms against a 599ms P50 on the same day.
The asset is 8,845 bytes, so those five seconds are discovery and connection
rather than transfer, which is what this hint addresses and what shrinking
the image would not.
Read that field data with its sample size in view: five LCP samples in the
window, so P90 and P99 are both 5,415ms because they are the same single
observation. This is a hint worth acting on because the fix is one attribute
and cannot regress the warm-cache case, NOT a measured typical page load.
The Lighthouse gate added in #414 passes at the same time, and lab and field
disagreeing at N=5 is expected rather than evidence of a regression.
Deliberately not set on the landing page's copy of the same wordmark in
components/Nav.astro. There the hero screenshot is the LCP element and
already carries fetchpriority="high"; raising the logo too would put two
images at the front of the queue and demote the one that actually is the
largest paint.
* Align the install list's two controls, and stop the alternatives shouting (#421)
ALIGNMENT. A Copy button and a Download button on rows stacked directly on
top of each other sat on different vertical lines. Measured before the
change: copy 10px from the row's right edge, Download 16px.
They disagreed because they are positioned by different mechanisms. The
Download button lives in `.alt-action`, which is `justify-self: end` in the
row's grid, so it lands on the content-box edge -- `.alt`'s own 16px
padding. The copy button is absolutely positioned against `.alt` and so
answers only to its own `right`. Nothing tied the two numbers together, so
they drifted apart the moment one was set.
Moved the copy button to 16px rather than pulling the Download button in,
because the padding is the row's real edge and everything else in the row
already respects it. Scoped to `#install .alt` so the recommended command's
button, which sits inside a different container with its own inset, is not
dragged along with it.
HOVER GATING. The alternatives show their copy buttons on hover and
keyboard focus again, instead of permanently.
This reverses part of #416, which extended `always-show` to these rows on
the grounds that a hover-hidden control reads as a missing one. That
reasoning holds for the recommended command -- one row, the thing most
visitors act on -- and does not survive being applied to the list beneath
it, where six permanently lit buttons compete with the route the panel is
steering people towards. The recommended row keeps `always-show`.
No layout shift: `.alt-cmd` already reserves the button's width whether or
not it is painted, verified by measuring the command's right edge with the
button hidden and shown (581px both).
Touch is unaffected. With no hover to reveal them, the (hover: none) rule in
landing/base.css keeps every copy button at 0.7 opacity -- confirmed at
375px with the media query matching.
Verified: astro check 0 errors, css-shadowing 0 of 1114.
* Say what the other scanners actually are, and stop over-conceding to nmap (#422)
NMAP. Dropped "raw packet workflows" from the list of things nmap does
better. Two reasons, and the first is that it is not true as written:
pcap-enabled builds capture to a file and summarise an existing one, so the
answer was conceding ground the product holds.
The second is that the site already concedes this territory elsewhere, to a
different tool. /docs/packet-capture/ says "not a Wireshark replacement...
use Wireshark or tshark for deep protocol dissection". Two pages naming two
different winners for the same ground is worse than either claim alone. Deep
packet work is Wireshark's, and that page keeps it.
The three that remain -- advanced service detection, NSE scripts, OS
fingerprinting -- are real and not closable. An NSE engine with 600+
community scripts and a stack-signature database built since the 90s are not
gaps to close, they are a different product. Conceding them is what makes
the rest of the answer credible.
IP SCANNERS. The Angry IP Scanner / Advanced IP Scanner answer listed our
own adjectives ("cross-platform desktop app/TUI/CLI/MCP interfaces,
structured output, MIT-licensed Rust core") and never said anything
checkable about either tool it names.
It now names facts that are properties of THOSE tools, verified against
their own sites rather than recalled:
- advanced-ip-scanner.com states "Compatible with Windows 11, 10, 8, 7" and
is Famatech freeware, not open source. Windows-only and closed-source are
the two differences that actually hold.
- angryip.org advertises "Provides command-line interface" on its front
page, and is open source and cross-platform. So a CLI is NOT a difference
from that one, and the answer no longer implies it is. That claim was
about to be written before checking.
The answer also now concedes remote administration, which neither the old
text nor the plan for this change included. Advanced IP Scanner's front page
leads with RDP/Radmin control, shared folders and remote shutdown. NetsCLI
has no equivalent, and an answer that omitted the thing a reader can see on
that page in ten seconds would discredit everything around it.
Both `a` and `aHtml` carry the change: `a` is used verbatim in the FAQPage
JSON-LD, so a change made only to the visible copy leaves the structured
data asserting the old text to search engines and answer engines.
Verified on the running page: "raw packet workflows" appears nowhere in the
rendered text or the JSON-LD, and the new IP-scanner answer is present in
both. astro check 0 errors. No semicolons in either answer.
* Clear all three open advisories together (#428)
Three advisories were each blocking the others. cargo audit and npm audit are
separate required checks, and every open pull request failed at least one of
them, so none of the three single-fix branches could merge:
RUSTSEC-2026-0285 rustls 0.23.40 medium TLS 1.3 handshake messages
accepted across encryption
level boundaries
GHSA-vwc7-r8mq-g2x9 adm-zip <=0.6.0 HIGH extraction follows symlinks,
+ GHSA-7q85-xj36-vmfc arbitrary file overwrite;
uncontrolled allocation (DoS)
GHSA-9rgm-9g3h-6x36 devalue <5.9.1 moderate DoS via malformed input
The deadlock is why this is one branch rather than three. #427 carried the
rustls fix and failed npm audit on the other two; #424 and #425 carried the
npm fixes and failed cargo audit on rustls. Each was red for something it did
not cause, and branch protection correctly refused all of them. Fixing them
together is the only ordering that passes.
The npm halves are Dependabot's own lockfile changes from #424 and #425,
applied unmodified, so this supersedes both rather than competing with them.
The Rust half is `cargo update -p rustls`: a patch release inside the 0.23
line, no manifest change, carrying rustls-webpki 0.103.13 -> 0.103.15.
WORTH NOTING for whoever reads this later: the adm-zip advisory is HIGH and
had been sitting unmerged because its PR showed a failing cargo audit -- a
check an npm bump cannot break. A failure attributed to the wrong cause is
how a high-severity fix waits. When an audit fails on a PR that cannot have
caused it, check main before debugging the branch.
Verified locally: `cargo audit` exits 0 with only the three warnings the
config already allows (event-listener unsound, chacha20 and spin yanked), and
`npm audit` in site/ reports 0 vulnerabilities. Lockfiles only -- no manifest
and no source changed.
* build(deps-dev): bump selenium-webdriver from 4.48.0 to 4.49.0 in /site (#407)
Bumps [selenium-webdriver](https://github.com/SeleniumHQ/selenium) from 4.48.0 to 4.49.0.
- [Release notes](https://github.com/SeleniumHQ/selenium/releases)
- [Commits](https://github.com/SeleniumHQ/selenium/compare/selenium-4.48.0...selenium-4.49.0)
---
updated-dependencies:
- dependency-name: selenium-webdriver
dependency-version: 4.49.0
dependency-type: direct:development
update-type: version-update:semver-minor
...
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* build(deps-dev): bump chromedriver from 152.0.3 to 153.0.1 in /site (#408)
Bumps [chromedriver](https://github.com/giggio/node-chromedriver) from 152.0.3 to 153.0.1.
- [Commits](https://github.com/giggio/node-chromedriver/compare/152.0.3...153.0.1)
---
updated-dependencies:
- dependency-name: chromedriver
dependency-version: 153.0.0
dependency-type: direct:development
update-type: version-update:semver-major
...
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* Give release paragraphs their break back, and stop the fade reading navy (#426)
PARAGRAPH BREAK. `.release-body>*+*` sets 14px between every block in a
release card. `.release-paragraph` then set `margin:0`, whose implicit
`margin-top:0` beat it -- both selectors are one class deep, so source order
decided, and the reset came later.
On v0.3.1 that put the curated summary and the body's own first paragraph
flush against each other, so two paragraphs rendered as one unbroken run of
text with a line wrap between them. Measured before the change: the
paragraph's computed margin-top was 0px where the flow rule asks for 14px.
Only the bottom and inline margins are reset now, which hands the top back to
the flow rule. Nothing wanted a zero there: a paragraph is never a card's
first child, because `.release-summary` always is. Headings are untouched and
keep their own 26px.
FADE COLOUR. The collapsed-card overflow gradient ended at `--ui-surface` --
#191d25 in dark, rgb(25,29,37), with blue twelve points above red. The card
underneath is `.release-item`, `--ui-lift-rgb` at 2.8% over the page
background, which composites to a neutral grey. Fading a neutral card into a
blue-tinted stop is what made the shadow read as navy instead of as the card
continuing past the cut.
Every stop is on the page-background channel now. That lands ~2% off the
card's exact composite (17,17,17 against 23,23,23 in dark), which is below
perception at the end of a gradient; the old stop was wrong by twenty points
on one channel, which was not. Both tokens flip with the theme, so there is
no light-theme variant to keep in step -- verified in light, where the fade
ends on rgb(251,251,251) against the same neutral card.
Verified on the running page in both themes: paragraph margin-top 14px,
heading margin-top still 26px, fade ending neutral. astro check 0 errors,
css-shadowing 0 of 1114, css-regions OK.
* Take Starlight 0.42, and declare the virtual module it stopped declaring (#430)
Dependabot's astro-group bump (#406) failed `astro check` with one error:
src/components/starlight/Header.astro:2:20 - error ts(2307):
Cannot find module 'virtual:starlight/user-config'
Starlight builds its virtual modules at runtime through a Vite plugin, so
nothing on disk corresponds to them and TypeScript has to be told they exist.
Up to 0.41.9 the package shipped a root `virtual.d.ts` declaring
`user-config`. 0.42 moved every type under `dist/` and dropped that file. What
remains is `dist/integrations/vite-virtual-modules.d.ts`, which types the
plugin itself rather than declaring anything ambient for a consumer.
So the import that had been relying on the package to declare it no longer
has a declaration, and the upgrade fails to typecheck.
This repository already keeps `src/starlight-virtual.d.ts` for exactly this
problem: Starlight has never declared the per-component virtual modules, and
all seven the site imports are shimmed there. `user-config` was the only one
the package still covered. It now sits with its neighbours, which makes the
file complete rather than partial -- every `virtual:starlight/*` the site
imports is declared in one place.
`any`, matching the rest of the file. The alternative is importing Starlight's
own config type out of `dist/utils/`, a path the package does not export and
is free to move again -- which is the failure mode being fixed, not a
precedent to extend. Header.astro reads two properties off it.
Supersedes #406 rather than competing with it: the version bump is that PR's,
applied unchanged.
Verified against 0.42.1 locally: astro check 0 errors, the site builds all 14
pages, the docs shell still renders through our Header override, and both CSS
gates pass.
* Name hosts from mDNS when reverse DNS cannot (#418) (#432)
* Name hosts from mDNS when reverse DNS cannot (#418)
Consumer routers do not serve PTR records for their own DHCP clients, and
appliances ignore LLMNR and NetBIOS, so `reverse_lookup_best_effort_timeout`
returns nothing for exactly the devices someone opened the app to identify.
Meanwhile those devices announce their names over mDNS continuously, and
netscli has shipped an mDNS browser the whole time -- as a separate operation
discover never consulted.
WHAT RUNS. A browse starts at the top of `scan_subnet_with_progress`, before
the ping sweep, and is awaited after the reverse-DNS stage. It overlaps both.
Names fill only hostnames still empty, so nothing that resolves today changes
and the output is additive in effect as well as in shape.
Fusion happens after `hostname_map` rather than inside the resolve stage, so
ARP-only neighbours are named too -- the devices that never answered a probe
are the same ones least likely to have a PTR record.
THE WINDOW IS MEASURED, not picked. On an ordinary home LAN, unique hosts
reached their ceiling of 7 at 1000ms and did not move at 1500, 2000 or
3000ms; 500ms was unstable across repeats (3 hosts, then 5) and 250ms
returned nothing twice. 1500ms sits past the plateau with margin, and well
under the 3000ms the explicit `/mdns` browse uses -- that one is a deliberate
"go and look", this rides along.
COST, measured on the same network: a /24 discover takes 2317-3466ms, so the
browse is absorbed whole. A /30 takes 1631ms, where the window dominates and
adds roughly 1.4s. That is the deliberate trade -- issue #418 asked for no
extra wall-clock at all, which would have meant cutting the browse off when
the sweep finished and starving small subnets. The issue's acceptance
criterion is updated to say what this actually does.
ATTRIBUTION. New additive field `hostname_source: Option<NameSource>` with
`Reverse` and `Mdns`, following `found_by`'s precedent. Deliberately a
separate axis rather than more `FoundBy` variants: a host can be found by
probe and named by mDNS, or found in the neighbour table and named by reverse
DNS, in any combination, and one enum cannot carry two independent facts
without becoming a product of them.
SAFETY. Every mDNS name goes through `normalize_hostname`, the same function
the reverse lookups use -- now `pub(crate)`, gated to the mdns feature so the
default build does not carry an unused import. This is not tidiness. An mDNS
hostname is a string chosen by whoever runs the other machine, exactly the
untrusted remote text ARCHITECTURE.md requires be treated as data and never
as control, and that function is what rejects a device calling itself an
escape sequence that would repaint the term…
…re (#37) * Run CI on the self-hosted runner, because hosted will not schedule here This repository is private and hosted Actions minutes are metered for private repositories. Measured 2026-09-21 on ubuntu-latest, across the original run and two re-runs: the job completed in three seconds with ZERO steps executed, no log written at all -- the API returns BlobNotFound -- and no annotation. That is GitHub declining to schedule a job, and from inside the repository it is indistinguishable from a broken workflow. The comparison is what settles it: netscli and nvx run this same workflow shape on ubuntu-latest today and both are public, so they get free minutes. Hosted is not a worse choice here, it is not a working one. The security guard comes back with the runner. It was removed on the move to ubuntu-latest, correctly -- there is no hazard on a hosted runner. There is one here: a self-hosted runner executes whatever the workflow says, so a fork's pull request against a PUBLIC repository would run its own code on that machine. The guard is checked at run time rather than trusted from a comment, because the setting it depends on is one click away in the repository settings. The runner must be running for any of this to report. Between 2026-09-12 and 2026-09-21 it was offline and jobs simply queued, which is quieter than failing and took nine days to notice. The README now says how to start it and how to install it as a service. * Split the feedback types out, to get types.ts back under the guard The first CI run after the runner came back failed the file-size guard: types.ts at 302 lines against a limit of 300. It was 269 at the last run before the runner went offline, and the feedback feature added 33 -- so it has been over since #33 merged, with nothing able to say so. Feedback's 33 lines are the most self-contained block in the file, not a judgement that feedback is special. types.ts re-exports both types, so every existing import keeps working and nothing else had to change. Not a transition exception: that map is for a file mid-split with a named next step, and using it here would carry a file over the line rather than fix it. The comparison table's types are the next candidate if it goes over again. * Drop the npm cache on the self-hosted runner The netscli sync turned `cache: npm` back on for hosted runners. This workflow now runs on the self-hosted box, where ~/.npm persists on disk between jobs, so the cache action replaces a local read with an upload and a download. Run 35599905672 measured it: all fourteen checks green at 12:33:42Z, then the setup-node post step held the job for twenty minutes on the cache upload with 0.44s of CPU, and the run had to be cancelled.
…#38) This repo is the central one for product sites and it assumes agent-assisted development, but everything it knows lived in prose that gets read when an agent happens to look. AGENTS.md and the README are good and stay as the detail; these are the entry points, and each one carries the failures that prose did not stop. Written from doing the 2026-09-21 backlog rather than from the docs, so each rule has something measured behind it: product-site-sync -- the merge's two silent failures. A product's new files arrive as ADDITIONS, which merge without a conflict: .gitattributes merge=ours protects files that already exist and cannot protect against one that does not, and four product screenshots came through that way. And a sync can un-generalise: the template had parameterised the stats fetch, the product hardcoded its crate name, and taking the file whole reverted it with no conflict marker. Plus the one that costs most -- git stash during an uncommitted merge cleared MERGE_HEAD, and committing after that would have replaced the merge with an ordinary commit, ending the shared history every future sync depends on. product-site-upstream -- how to tell generic from product-specific (read the file, not the intent), only upstream what has landed on main, generalise so the feature does NOTHING when unconfigured rather than fetching /crates/undefined in every visitor's browser, default new sections off, and prove a section renders by building and asserting the markup, because a typecheck passes on one that renders nothing. product-site-spin-off -- subtree add, then the four things that break because the template's root is a site and a subtree is not. The fourth is new: .claude/skills/ arrives at site/.claude/skills/ where Claude Code does not look, the same silent failure as the CI workflow. Measured by subtree-adding this template into a scratch repo and listing where each file landed. README and AGENTS.md updated from three things to four. product-site-customise -- deliberately thin, because AGENTS.md already covers it. It exists so that guide gets read, and to hold the one rule the arrangement depends on: edit site-content, never components. A component edited downstream takes that site out of the sync in both directions. npm run check 0 errors across 79 files; check:content, check:css and check:regions pass.
#30) * Add the comparison section, and stop product facts hiding in the shell Eight changes from a site built on this template, generalised. What stayed behind is that product's own content, its brand assets and its one bespoke section. A competitor matrix is the section this shell was missing. Compare.astro is driven entirely by compare.ts, so the columns, rows and the highlighted column are content. 'compare' joins the LandingSection union and the component map, and the sample config renders it. The JSON-LD read applicationCategory, operatingSystem, programmingLanguage and the offer as literals in the layout. A site for a program written in Go therefore advertised Rust to search engines, and no gate looked, because check-content.mjs scanned a hand-written list of files and the layout was not on it. Both halves are fixed. The facts move into a required AppSchema in meta.ts, and the scan walks the whole source tree with comments stripped, so a sample value cannot hide in a file nobody listed. Branding gains an optional wordmarkLight. A wordmark containing type cannot be one asset, because the lettering has to be light on the dark bar and dark on the light one. Products shipping a single asset are unaffected and keep using the filter token. Both bars also bound the mark by their own height now. A wordmark taller than the sample used to set the bar height itself, which left the active link's underline 6.9px short of the bar's bottom border. The install command's copy button moved when the command was scrolled, by up to 674px, because the scroll container and the positioning context were the same element. They are separate now, and the inner one is focusable, since a command you can scroll with a mouse should be scrollable without one. The CLI group's title no longer promises a terminal UI. * Keep types.ts under the file size guard * Split the hero types out so the size guard passes The comparison table's types took types.ts to 328 lines against the 300-line guard. feedback-types.ts named the comparison types as the next candidate to move, but they are 14 lines and would not have cleared the guard, so the hero's 80 went instead and types.ts is back to 252. Platform stays in types.ts. hero-types.ts imports it back type-only, so the cycle is erased at compile time.
* Say when the visual baseline has gone stale
This tool degrades in a way that looks exactly like the thing it exists to
report. A baseline older than a few site changes does not flag those
changes, it flags nearly everything -- 223 of 240 on 2026-09-21, against a
baseline from the 14th, including pages the change under test had not
touched. The list reads as a catastrophic regression and cannot be acted on.
The real answer was that nobody had recorded a baseline in six days.
A guard whose broken state is indistinguishable from a loud failure stops
being read, which is worse than not having one. So `check` now compares the
baseline's timestamp against the newest commit touching what the pages are
built from -- src, astro.config.mjs, package.json, public -- and says so
before the run rather than leaving it to be inferred from the length of the
list afterwards.
Warns, never fails. Comparing against a deliberately old baseline is a
legitimate thing to want, and the note at the top of this file already
explains why this is a local tool and not a gate: it compares PNG bytes, so
it is exact on one machine and meaningless across two.
Verified both directions: silent against a freshly recorded baseline, and
against the same baseline backdated to the 14th it reports 17 commits since,
most recently 2026-09-20.
* Stop `record` crashing on a capture it could not take
A record run died after all 240 captures were on disk:
Captured 240 image(s) into .\.visual-baseline
search dialog did not open; no search capture
5 capture(s) did not reproduce; taking the second reading.
Error: ENOENT ... open '.visual-verify\docs-search__dark__1600.png'
The confirm pass re-takes every capture and promotes the second reading of
any that disagree. A capture that could not be TAKEN is missing from the
verify directory, so it lands in the same list -- and then gets read, which
throws.
The search capture is the one that does this. It depends on a dialog opening
and Pagefind returning results, and `capture` already skips it with a warning
when either does not happen; only the promotion step assumed the file was
there.
What made it worth fixing rather than retrying: the run left a baseline that
looked finished. All 240 files present, none of them confirmed, and the next
`check` would have compared against frames that were never verified to
reproduce. The crash is loud, but what it leaves behind is not.
Missing files are now skipped. They stay in the retry list, so the next round
takes them again, and round 5 still reports anything that never settles.
Verified by re-recording: "1 capture(s) did not reproduce; taking the second
reading. Baseline confirmed: every capture reproduced on pass 3." Exit 0.
…449) * Build the release body at publish time, from what is already written The body was hand-written into the draft, and release-drafter regenerates that draft on every push to main. So an overview survived only if nothing merged between writing it and publishing -- and "merge the changelog, then publish" is the ordinary release sequence. On 0.3.2 two PRs landed in that gap and the overview was gone, with nothing reporting it: the draft looked fine, it just held the generated PR list again. An ordering rule loses to the ordinary sequence, so this is a mechanism instead. publish-release.yml already holds a concurrency lock against release-drafter, for the neighbouring race where a drafter PATCH landing one second after a publish replaced v0.3.1's tag with an untagged- placeholder. Composing the body inside that lock, immediately before flipping the draft, means whatever the drafter last left is irrelevant. No new prose. The release already had two curated descriptions and this joins them: the site's one-line releaseSummaries entry, which nobody reading GitHub ever saw and which is exactly the framing a body wants at the top, then the ## [X.Y.Z] section of CHANGELOG.md, which is already required to be right before a release. Writing a third by hand is what produced four descriptions of one release saying the same things in different words -- site #410 fixed the same duplication on the changelog page. Adds the v0.3.2 summary, which the site needed regardless: without one the page derives a summary from the body and prints that prose twice. A missing CHANGELOG section fails the publish, which is the right way round. A missing summary only warns; it is not worth blocking a release at the last step, and the warning says the site wants the line too. Verified against v0.3.2, v0.3.1, v0.2.6 and v0.2.0, a malformed tag (exit 2) and a version with no section (exit 1). The compare link was wrong for older tags on the first pass -- it took the first version that was not the target, which is the newest, so v0.2.6 produced compare/v0.3.2...v0.2.6 running backwards. It now takes the entry below. * Compose the notes from the default branch, not the tag Checking out the tag would have failed on the first release it ran for, in two ways, and only one of them is a timing accident. The script landed after v0.3.2 was tagged, so it is not in that tree at all and the compose step would have died on a missing file. That one goes away on its own next release. The other does not. A version's CHANGELOG section is finished AFTER its tag: the heading is dated in a separate commit once the tag exists, because site/scripts/changelog-dates.mjs fails a dated version with no matching tag. So the tag never carries the final section, and v0.3.2's carries no date. Composing from the tag would mean always building notes from a changelog entry one commit short of done. The default branch is where the finished record lives, which is also where the site reads it from.
* Ship the MCP server as an npm package and as .mcpb bundles Connecting a client meant installing netscli, finding where it landed and writing the JSON by hand. Two routes are added so that is no longer the only one, and the docs say plainly which of the three to pick. npm: a `netscli` launcher plus one prebuilt-binary package per platform, with matching os/cpu fields so npm installs exactly one. This is what makes `npx -y netscli serve` work, which is the form every MCP client's documentation already uses. The launcher execs the binary rather than a postinstall copying it into place, because a postinstall does not run under --ignore-scripts. .mcpb: five per-platform bundles attached to each release, carrying the binary. One action to install, no PATH entry and no Node. The manifest declares the local-network default as a user_config switch, so the person installing a network scanner reached by a model sees that choice instead of finding out from an error message naming an environment variable. Neither ships packet capture: libpcap and Npcap have to be on the machine, and a package that installs cleanly then fails inside capture_pcap is worse than one that never offered it. Anyone who already has netscli keeps pointing their client at it, and the docs lead with that. Both publish jobs verify the release asset's checksum before packaging it, like the Homebrew and Scoop jobs. publish-npm.sh publishes the launcher last, so a failed platform upload cannot leave an installable `netscli` whose binary does not exist. Verified against the real v0.3.2 assets: all five npm packages stage and the launcher runs the Windows binary through a full MCP handshake; all five bundles pack and their entry_point resolves inside the archive; the unpacked bundle refuses 1.1.1.1 with the switch off and reaches the scan with it on. * Publish to npm over OIDC, with a token only to bootstrap npm trusted publishing exchanges the Actions OIDC token for a short-lived, workflow-scoped credential and attests provenance with it, so no long-lived secret has to exist. That is the steady state this should run in. It cannot cover the first publish. A trusted publisher is configured on a package's settings page on npmjs.com, and that page does not exist until the package does -- so all six of these need one token-authenticated release before OIDC can take over. NPM_TOKEN is therefore optional rather than removed, and the script prefers OIDC whenever the token is absent. Node 22 bundles npm 10.x, which has no trusted publishing at all and would fail looking for a token, so the job upgrades to npm >= 11.5.1 and the script checks the version it actually got rather than assuming the step ran.
…d matching nav spacing - InstallClient.oneBox: a card with several commands can show them in one box, one per line, copied together. Pasting runs them in order in bash, zsh, cmd and both PowerShells, where joining with && fails in Windows PowerShell 5.1. A wrapped line hangs under its command so the commands stay distinguishable. - Changelog page: it fetches one page of GitHub releases, and labelled every older changelog entry missing from that page "Not yet released" and appended older GitHub releases the changelog does not list. Now only entries above the newest confirmed release can be unreleased, and only remote releases newer than it are appended. - Docs header links use the landing bar's spacing: 20px between links and a 32px group gap at the divider (was 18px and 39px); below 72rem 12px and 20px, keeping the proportion (was 11px beside a 43px divider gap). From xtctx's site.
…pacing One copyable box for a client's commands, honest changelog labels, matching nav spacing
…to the landing page (#472) * Add a comparison with nmap, Angry IP Scanner and Advanced IP Scanner to the landing page Compare.astro comes from product-site-template, where nvx's comparison section was generalised, so the next template sync sees the same file on both sides. One change: the note under the table drops the template's max-width:62ch, which wrapped a two-sentence note into a narrow column under a full-width table -- the same fix nvx made. It can go back to the template on the next sync. The rows are ordered by the question a reader is asking (where it runs, how you use it, what it finds, what you get out) and every cell was checked on 2026-09-24 against each project's own docs and source; the sources and the reasoning behind each non-obvious cell are in compare.ts. Two rows are ones NetsCLI loses: UDP/OS/version detection, and the note says what Advanced IP Scanner is for. Remote actions went into the note rather than a row, since they are remote management, not scanning. This replaces the 25-row docs page proposed in #470. * Outline netscli's comparison column instead of tinting it The tint (nvx's version, from the template) read as a highlighted run of cells rather than as the product the table is about. The column now gets a 1px accent border on all four sides; border-collapse gives the left cell a tie, so its right edge survives next to the neighbouring hairline. * WIP: comparison table, 8 rows, no lead, body-coloured column text * Rebuild the comparison around the questions people choosing a scanner ask The earlier rows were mostly things only netscli has (MCP, JSON, DNS records, mDNS), which read as narrow and cherry-picked. Nine rows now follow the questions in the order a reader asks them -- does it run on my OS, how do I use it, does it find and name my devices, what's open, can I go deeper, what else is built in, can I get results out, what does it cost -- with a short phrase per tool, so nmap's depth and Advanced IP Scanner's remote actions are credited in their own rows and the note under the table goes. Also: no lead under the heading (it only listed what was compared and which versions; those stay in compare.ts's comment), netscli's cells in the body colour with only the header green, and answer cells wrap, since short phrases unwrapped pushed the table past a 1024px window. * Make the comparison a tick table The phrase-per-cell version was accurate but too much to take in. Ten rows of ticks, with a word or two only where a tick would mislead, and three rows that go to the other tools (UDP/OS/version detection for nmap, remote actions for Advanced IP Scanner, open source for three of four). Rows every tool ticks are left out. * Fade the comparison's dashes, and announce them as 'Not offered' The dashes competed with the ticks. They now sit at 40% opacity and are aria-hidden, with a visually hidden 'Not offered' in the same cell, so the information doesn't depend on a glyph that is now deliberately faint. * Split the comparison's detection row into UDP, versions and OS With UDP scanning (#475) and announced service versions (#474), netscli no longer loses the whole 'UDP, OS and version detection' row, only OS detection. Three rows now: UDP port scan (netscli and nmap), service versions (netscli from banners, nmap by probing, Angry IP Scanner for web servers via its Server-header fetcher), OS detection (nmap only). * Comparison: netscli's versions cover common services, OS gets hints With #474 asking Redis/Valkey and Memcached and reading MySQL's greeting, 'From banners' undersold it: 'Common services'. With #476's inspect OS hint, the OS detection cell becomes 'Hints' rather than a dash; nmap's packet fingerprinting still gets the tick. * Comparison: drop the JSON row, add plugins, date the table; say TCP and UDP * Comparison: call the extension row Add-ons, so it doesn't read as unscriptable * Add Compare to the site nav * Install: give the Linux .deb a download button, not a command chip * Install: put the .deb button on the right, level with the AppImage one * Install: the Windows installer is signed; label the .deb row, not its button * Install: drop the Windows installer's signing note; it is signed * Install: macOS 15 removed right-click Open; give the Open Anyway route * Install: warn Homebrew cask users too; only binaries and installers are signed * Give the landing and docs bars one set of nav spacing * Move the nav spacing tokens to their own file; tokens.css is at the size guard * Centre the nav separator between FAQ and Docs * Docs: anchors land 24px under the header, one shaded surface for header and TOC bar, a one-line footer * Docs TOC bar: drop the light theme's white box behind 'On this page' * Theme menu and search: focus without scrolling the page * Docs header: search button sits a link-gap from Features at mid widths * Docs header: search box sits a link-gap from Features on wide screens too * Docs header: search joins the theme control after the links * Docs: Copy page rides in the contents bar on narrow screens, sentence-case sidebar groups, the bar's scroll gradient * toc-bar.css back under the size guard * toc-bar.css under the size guard * Docs: restyle the menu button under Starlight 0.42's markup * Landing copy: FAQ names every desktop install and the real platforms, no pipeline words; CLI card lists CSV and Markdown * FAQ: no inline semicolons or colons in the new answers * Landing copy: no em dashes, inline colons or semicolons * Hero: on narrow screens, put More install options under the command bars * Copy page: show a tick while it says Copied, like the landing copy buttons * Copy page: show a cross while it says Failed
The group gap was the link gap plus a 32px margin, with the line 16px before the group's first link: 35px of space on its left, 16px on its right, in both the landing nav and the docs header. The margin is now one link gap and the line is centred in the double gap. From xtctx's site.
Centre the nav separator, one link gap either side
netscli's site/ at fstubner/netscli 0912724, merged with its subtree split rather than hand-applied, so the next sync starts from a shared base. Brought over: the comparison section's accent outline and screen-reader text for empty cells, nav spacing as tokens shared by both bars (nav-spacing.css), the docs header with search grouped beside the theme control, the Copy page button as its own component with copied/failed icons and a slot in the "On this page" bar, the one-row docs footer, sentence-case sidebar labels, preventScroll on the theme menu, the narrow-screen install link order, the .deb download row, and the header-controls check. Kept from this repo where both sides changed the same thing: the light wordmark, the optional source link and crates.io count, product-neutral release badge text, Feedback in the docs footer, the menu button's focus ring, the tighter sidebar rail inset, sitemap and sharp, the broader changelog heading formats and sidebar-ordered llms-full.txt. Hero types stay in hero-types.ts. netscli's docs pages and screenshots stay out; compare.ts joins the merge=ours content files.
#59 centred the separator with fixed 20px values; this branch centres it with the nav-spacing.css tokens both bars share, so the spacing is set in one place. Same visual intent, one source.
…#509) - llms-full.txt lists the docs in sidebar order, not alphabetically. - Header buttons share one hover: the search button takes the accent tint and border the menu button and the landing menu toggle already use, and keyboard focus gets a 2px accent ring (from xtctx via the template). - The landing menu toggle gets the same focus ring. - The changelog page also reads "## [x.y.z](link) (date)" headings. - The release badge shows the version, not a package-prefixed tag.
Sync netscli's site from 2026-10-04, as a merge
* Add a privacy page and the Microsoft Store listing checklist The Store listing (issue #466) needs a privacy policy URL; the app reads IP and MAC addresses and hostnames, and the desktop app checks GitHub for releases. docs/privacy.md says what stays local and what reaches the internet. docs/MICROSOFT-STORE.md has the account steps, the submission fields and the listing text. * Store screenshots and logos, from a fuller screenshot mode The desktop app's screenshot mode (?demo=screenshot) showed one scan tab. It now fills Discover, Port Scan, Inspect, DNS and mDNS with a fixed home network in 192.0.2.0/24, with versions and an OS hint, and ?tab= picks the tab that opens. packaging/msstore/ has the five 2732x1536 screenshots taken from it with headless Chrome, plus the 1080 box art and 300 tile rendered by the icon generator. * Privacy at /privacy/, its own page, linked from both footers Moved out of the docs: it is a policy rather than documentation, app stores link to it so the address must not move with the docs, and it has to exist with the docs switched off. Same text. Added to the a11y route list; the Store checklist points at the new URL. * Serve the Windows MSI from netscli.com for the Microsoft Store The Store refuses a package URL that redirects, and a GitHub release download answers 302 to a signed, expiring address. The Pages build now copies the last three releases' MSIs to /download/<tag>/, each checked against its release's published SHA-256 (a tampered byte fails the check, tested). The Store checklist uses the new URL.
…531) Search Console flagged every docs page: Missing field "item" (in "itemListElement"). The breadcrumb was Docs > <sidebar group> > <page>, and sidebar groups have no page, so the middle step had no item URL, which Google requires. Now Docs > <page>, and the docs index is a single step rather than the same URL twice. site/scripts/structured-data.mjs parses every built page's JSON-LD and checks each BreadcrumbList (positions, names, an item URL on every step, no repeated URLs). Run in the Site workflow after the build. Against the previous Head.astro it reports 12 problems, one per docs page.
Four netscli changes since the 2026-10-04 merge: the docs breadcrumb fix (no URL-less sidebar-group step, which Search Console flagged) with the new check:structured-data, http-cache-semantics 4.3.0 for GHSA-ch52-4w7c-c8xp, a Privacy link in both footers, and the /privacy/ page. The privacy page arrives as a shell: privacy.astro renders privacyCopy from src/data/site-content/privacy.ts, which here is REPLACE_ME sample text (check:content flags it) and joins the merge=ours list. netscli moves its own text the same way in #533, so the next sync sees the same page on both sides. Conflicts kept both sides: check:content and theme beside check:structured-data in package.json, the modules-gated footer links plus Privacy, and this repo's comment on the changelog heading formats.
Sync netscli's site from 2026-10-05
…64) * CI: put Git Bash first on PATH, so bash steps stop running under WSL The self-hosted runner resolved shell: bash to C:\Windows\System32\bash.exe (WSL) on 2026-10-05, after resolving Git Bash on 2026-10-04's runs. WSL's bash cannot open the runner's Windows script path, so every step after checkout failed on its first line. A PowerShell step now adds Git's bin to GITHUB_PATH for the rest of the job. * CI comment: say when the bash change was seen, not when it happened
…hen 4322 is taken (#61) The a11y step failed on the self-hosted runner with "Something is already listening on http://127.0.0.1:4322" whenever another job or a leftover server held the port. Add choosePort() to the shared preview helper (preferred port if free, otherwise an OS-assigned one) and use it in a11y, contrast-sweep, lighthouse, visual-snapshot, header-controls and css-equivalence. Reuse mode keeps its named port. a11y now also stops its server in a finally block.
…lease name (#62) From xtctx: release-please names releases "xtctx: v0.22.1", and for a grouped changelog entry the fetched release named only the one version that matched, so a "v0.20.0 – 0.21.8" card read "xtctx: v0.21.8". Prefer the changelog heading; fall back to the release name for a release the changelog does not list.
* Privacy page behind a module toggle, like docs and changelog modules.privacy = false leaves /privacy/ out of the build (the page is now a one-path dynamic route, so it is not built at all rather than built and unlinked), drops the Privacy link from both footers, and stops check:content asking for the privacy text. Default stays on. From xtctx, a CLI published to npm with no app-store listing, which picked the page up in a sync and has no use for it. * Shorten the privacy toggle's doc comment to stay under the file-size guard
SocialProof gains an optional npmPackage. Its all-time downloads join the release-asset and crates.io total, so a product installed with npm or npx no longer shows no download count at all (xtctx: 0 release assets, about 8,600 npm downloads). npm's API answers at most 18 months per request and silently trims a longer range, so the total is summed backwards in 540-day spans. SocialProof moves to social-types.ts, re-exported from types.ts, which was at the 300-line guard.
Count npm downloads in the hero, for products shipped through npm
…ads to npm - Zero stars showed nothing. The item now reads "Star on GitHub" and links to the repo page, where the star button is (GitHub has no URL that stars a repo). With stars it shows the count, as before. - The download count linked to GitHub releases even when it was counted from npm, whose product's releases carry no files. With npmPackage set it links to the npm package page.
Hero metrics: ask for a star when there are none, and send npm downloads to npm
…privacy module, free-port helper) netscli's history shares no base with the template's, so the merge base was faked with a temporary replace-graft onto template commit 287ef9d and removed afterwards. Kept netscli's own: privacy.ts text, README, View source link, the GitHub-releases download link; set cratesIoCrate to netscli. Left out (template-only, netscli has no counterpart): .github/workflows/ci.yml, AGENTS.md, scripts/check-content.mjs.
…li has no feedback component)
Contributor
|
Site preview: https://pr-550.netscli-site-preview.pages.dev Built from 6cb2555 with Production is unaffected: netscli.com is served from GitHub Pages via |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Brings the product-site-template's latest main into
site/as a real merge (two parents).What arrives
npmPackageis set; netscli leaves it unset, so its downloads still link to GitHub releases.SocialProofmoved tosocial-types.ts.cratesIoCrate: 'netscli'is now set infooter.tsinstead of the crate name being hardcoded in the script.modules.privacy(default on) via a newmodules.tsand[policy].astro; the privacy text is still netscli's ownprivacy.ts. Footers show the Privacy link when on.Left out on purpose: template
.github/workflows/ci.yml,AGENTS.md,scripts/check-content.mjs(netscli has no counterpart;npm run check:contentdoes not exist here), and the template's Feedback block.Heads-up: netscli's history shares no base with the template's (earlier syncs were squashed), so
git subtree pullrefuses. The merge base was faked with a temporary replace-graft onto template commit 287ef9d and removed afterwards. Template pieces netscli never took (feedback, install-clients, visual/hero types) will show as modify/delete conflicts in future pulls: keep them deleted.Checked:
npm run check0 errors,npm run build15 pages, dev server: star count shown, downloads link to GitHub releases, /privacy/ renders netscli's text, footer links Privacy, no console errors. Contrast/a11y sweeps not run.