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