Skip to content

Site: sync from product-site-template (star CTA, npm downloads, privacy module) - #550

Merged
fstubner merged 1031 commits into
mainfrom
site/template-sync-star
Oct 6, 2026
Merged

fstubner merged 1031 commits into
mainfrom
site/template-sync-star

Conversation

@fstubner

@fstubner fstubner commented Oct 6, 2026

Copy link
Copy Markdown
Owner

Brings the product-site-template's latest main into site/ as a real merge (two parents).

What arrives

  • Hero metrics: with zero stars the item reads "Star on GitHub" and links to the repo. Download count links to the npm page only when npmPackage is set; netscli leaves it unset, so its downloads still link to GitHub releases.
  • npm download counting in the landing script, plus SocialProof moved to social-types.ts. cratesIoCrate: 'netscli' is now set in footer.ts instead of the crate name being hardcoded in the script.
  • modules.privacy (default on) via a new modules.ts and [policy].astro; the privacy text is still netscli's own privacy.ts. Footers show the Privacy link when on.
  • Changelog card titles from CHANGELOG.md; free-port helper for the browser check scripts.

Left out on purpose: template .github/workflows/ci.yml, AGENTS.md, scripts/check-content.mjs (netscli has no counterpart; npm run check:content does 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 pull refuses. 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 check 0 errors, npm run build 15 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.

fstubner and others added 30 commits September 14, 2026 15:43
* 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.
@github-actions

github-actions Bot commented Oct 6, 2026

Copy link
Copy Markdown
Contributor

Site preview: https://pr-550.netscli-site-preview.pages.dev

Built from 6cb2555 with NETSCLI_PREVIEW=1 — noindex, and analytics disabled so it does not report into netscli.com's numbers.

Production is unaffected: netscli.com is served from GitHub Pages via pages.yml, which is manual-only.

@fstubner
fstubner merged commit 10eb0cf into main Oct 6, 2026
14 checks passed
@fstubner
fstubner deleted the site/template-sync-star branch October 6, 2026 21:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant