Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
# WatcherBridge make targets. Go targets use the module cache; UI
# targets run inside ui/ with the pinned Node toolchain (.nvmrc).
# `make ui-check` is the Step 22 gate: lint (a11y warnings fatal),
# committed-dist build, and the 200 KiB gzip budget.
# no-inline-style guard, committed-dist build, and the 200 KiB gzip
# budget.

.PHONY: ui ui-check ui-drift

ui:
cd ui && npm ci --no-audit --no-fund && npm run build

ui-check:
cd ui && npm ci --no-audit --no-fund && npm run lint && npm run build && npm run smoke && npm run size
cd ui && npm ci --no-audit --no-fund && npm run lint && npm run styleguard && npm run build && npm run smoke && npm run size

# ui-drift rebuilds the bundle and fails when the committed output
# differs: internal/viewer/web/dist must always match ui/ sources.
Expand Down
29 changes: 23 additions & 6 deletions docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,12 @@ <h2 id="architecture-title">Follow a report.</h2>
<dl class="readout"><div><dt>Storage</dt><dd>Local SQLite</dd></div><div><dt>Evidence</dt><dd>Original signed report</dd></div><div><dt>Identity</dt><dd>Scanner-reported</dd></div></dl>
</article>
<article id="explain" class="stage">
<div><p class="eyebrow">03 / Optional AI</p><h3>Explain the change.</h3><p>Local rules set the priority floor. If configured, AI can explain the change using redacted context and may suggest a one-level increase up to high. A final leak check guards provider requests. If the model fails, the bridge stores a marked fallback.</p></div>
<div>
<p class="eyebrow">03 / Optional AI</p>
<h3>Explain the change.</h3>
<p>Local rules set the priority floor. If configured, AI can explain the change using redacted context and may suggest a one-level increase up to high. A final leak check guards provider requests. If the model fails, the bridge stores a marked fallback.</p>
<p>When enabled, the AI investigator can use read-only tools to look up retained scanner reports about host history, services, and similar changes. The briefing can cite supporting evidence and show where lookups were incomplete.</p>
</div>
<dl class="readout"><div><dt>Example finding</dt><dd>Scanner reported a host appearance</dd></div><div><dt>Suggested check</dt><dd>Confirm against your asset inventory</dd></div><div><dt>Model context</dt><dd>Redacted by default</dd></div></dl>
</article>
<article id="decide" class="stage">
Expand All @@ -112,18 +117,30 @@ <h2 id="architecture-title">Follow a report.</h2>
<section id="review" class="review wrap" aria-labelledby="review-title">
<p class="eyebrow">02 / Review workflow</p>
<div class="section-heading">
<h2 id="review-title">From one report<br>to the bigger picture.</h2>
<p>The local dashboard brings together scanner evidence, priority reasoning, and operator decisions.</p>
<h2 id="review-title">From report to decision.</h2>
<p>Review what the scanner reported, see why it was prioritized, and record your decision in the local dashboard.</p>
</div>
<div class="review-grid">
<figure class="product-shot">
<a href="images/report.png"><img src="images/report.png" loading="lazy" alt="WatcherBridge briefing detail with reported identity, recommended check, saved scanner reports, and operator decisions"></a>
<figcaption>Briefing detail / Synthetic demonstration data</figcaption>
</figure>
<div class="features">
<article><span>01</span><div><h3>Review related changes together</h3><p>Qualifying appearance or disappearance briefings from the same source and target can form one incident with links to its members.</p></div></article>
<article><span>02</span><div><h3>Turn repeat decisions into policy</h3><p>By default, three matching, eligible “expected” decisions can produce a rule proposal for operator approval.</p></div></article>
<article><span>03</span><div><h3>See where coverage is uncertain</h3><p>The overview pairs an Act, Review, or Quiet verdict with per-source coverage. Liveness stays unknown until heartbeat capability is established.</p></div></article>
<article>
<span aria-hidden="true">01</span>
<h3>Review related changes</h3>
<p>WatcherBridge groups qualifying appearance or disappearance reports into incidents. Optional AI summaries for closed incidents show how many briefings were sampled or omitted, label hypotheses, and can cite member briefings.</p>
</article>
<article>
<span aria-hidden="true">02</span>
<h3>Approve review rules</h3>
<p>By default, three matching, eligible “expected” decisions can trigger a rule proposal. An operator decides whether to approve it.</p>
</article>
<article>
<span aria-hidden="true">03</span>
<h3>Check scanner coverage</h3>
<p>See the Act, Review, or Quiet verdict beside each source’s coverage. A source’s liveness stays unknown until it sends a heartbeat.</p>
</article>
</div>
</div>
</section>
Expand Down
16 changes: 11 additions & 5 deletions docs/landing.css
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,8 @@ nav a:hover, .text-link:hover { text-decoration: underline; text-underline-offse

.architecture, .review { padding-block: clamp(54px, 8vw, 102px); }
.architecture { background: #e6ede1; border-block: 1px solid var(--line); }
.section-heading { display: grid; grid-template-columns: 1.18fr 1fr; align-items: end; gap: 18px 40px; margin-block: 13px 32px; }
.section-heading > p { max-width: 41ch; color: var(--muted); font-size: 1.06rem; }
.section-heading { margin-block: 13px 32px; }
.section-heading > p { max-width: 60ch; margin-top: 12px; color: var(--muted); font-size: 1.06rem; }
.lab { background: var(--dark); color: #edf7ee; padding: clamp(16px, 3vw, 36px); border-radius: 18px; }
.lab-header { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-bottom: 20px; }
.lab .indicator { background: var(--lime); }
Expand All @@ -96,11 +96,13 @@ nav a:hover, .text-link:hover { text-decoration: underline; text-underline-offse
.readout dd { color: var(--lime); overflow-wrap: anywhere; }
@keyframes reveal { from { opacity: .35; transform: translateY(5px); } to { opacity: 1; transform: none; } }

.review-grid { display: grid; grid-template-columns: 1.65fr 1fr; align-items: start; gap: clamp(24px, 4vw, 56px); }
.review-grid { display: grid; gap: clamp(28px, 4vw, 48px); }
.product-shot img { display: block; width: 100%; height: auto; border: 1px solid var(--line); border-radius: 12px; box-shadow: 0 14px 32px #14282214; }
.product-shot figcaption { margin-top: 12px; color: var(--muted); font: .72rem/1.5 var(--mono); }
.features article { display: grid; grid-template-columns: 30px 1fr; gap: 10px; padding-block: 19px; border-top: 1px solid var(--line); }
.features { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: clamp(16px, 2.5vw, 32px); }
.features article { padding-top: 16px; border-top: 1px solid var(--line); }
.features article > span { color: #32654f; font: 700 .8rem/1.5 var(--mono); }
.features h3 { margin-top: 8px; line-height: 1.15; }
.features p { margin-top: 8px; color: var(--muted); }
.closing { padding-block: 54px; background: var(--dark); color: #edf7ee; }
.closing-inner { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 24px; }
Expand All @@ -110,12 +112,16 @@ nav a:hover, .text-link:hover { text-decoration: underline; text-underline-offse
.site-footer { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 12px; padding-block: 22px; color: var(--muted); font-size: .82rem; }

@media (max-width: 820px) {
.hero, .section-heading, .review-grid { grid-template-columns: 1fr; }
.hero { grid-template-columns: 1fr; }
.hero h1 { font-size: clamp(3.4rem, 10vw, 6rem); }
.signal { width: min(100%, 590px); }
.stage { grid-template-columns: 1fr; }
}

@media (max-width: 720px) {
.features { grid-template-columns: 1fr; }
}

@media (max-width: 520px) {
.wrap { width: calc(100% - 32px); }
.site-header { align-items: flex-start; flex-direction: column; padding-block: 14px; gap: 12px; }
Expand Down
2 changes: 1 addition & 1 deletion internal/viewer/web/dist/assets/index.css

Large diffs are not rendered by default.

12 changes: 6 additions & 6 deletions internal/viewer/web/dist/assets/index.js

Large diffs are not rendered by default.

105 changes: 105 additions & 0 deletions ui/CONVENTIONS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# UI conventions — folder split

How the Svelte viewer is organized, and the rules every change must hold.
Governing contracts: [Designv9 §8](../.innerdoc/Designv9.md#8-svelte-viewer-and-product-language)
(dependency allowlist, JS only, 200 KiB gzip, hash routing),
[CONTRIBUTING](../CONTRIBUTING.md) (committed `dist`, `make ui-check`).

## Roles

| | `containers/` | `components/` |
|---|---|---|
| Role | Smart: fetch, hold state, subscribe, post, wire everything together | Dumb: render props, emit callbacks |
| May import | `@/api`, `@/lib`, components, context | Other components, `@/lib` pure helpers (e.g. `format.js`) |
| May not import | Other containers (`App` alone composes pages) | `@/api`, `@/containers`, context, `window` listeners |
| Local state | Only what orchestration needs | UI-only state (open/closed, a form draft, a copied flag) |

Enforced by `eslint.config.js` (`no-restricted-imports`); `App` has its own
override as the single composer. `src/lib` holds no markup or CSS and never
imports UI layers.

Shared-widget exception (plan step 33): screens may embed
`DecisionsContainer` — a subject-parameterized review widget with no page
coupling — through a targeted `eslint-disable-next-line` at the import.
Page containers (Home, Devices, Device, Incident, Briefing) stay
App-composed; embedded full pages still arrive through App snippets.

## Layout

Every unit lives in its own folder — components included — so styles,
tests and the entry point travel together:

```
components/<Name>/ Name.svelte Name.module.css index.js
containers/<Name>/ Name.svelte (Name.css only when the container has its own markup)
```

`src/lib` is flat: every helper is one `lib/*.js` module, no
subfolders, no barrels. `Pill`, `TimeStamp` and `Notice` own badges,
timestamps and status/alert banners, so no global class remains for
them — transitional globals are deleted, not parked.

Component props: data in, `on<something>` callbacks out (`onselect`,
`ondecided`). No `reload` callback props — containers own refetching.
Appearance variants ride on props (`variant`, `layout`), never on
parent selectors reaching into children. Shared derivation lives in
`@/lib/*` helpers with plain-node unit coverage, not inside components.

## CSS

- CSS Modules (`*.module.css`), Vite-native. No `svelte-preprocess`:
it is outside the Design §8.1 allowlist, and without it `<style src>`
compiles to empty CSS (verified spike, Svelte 5.57.3 / Vite 8.3.5).
- The `.svelte` file has no `<style>` block; the CSS file next to it is
the single owner of its classes. Scoping is enforced by hashed class
names; hyphenated names use bracket access.
- No cross-component styling. The old `.decision-card .trail` parent
selectors are gone with the component split; the dossier keeps its
page-unique selectors in `styles/dossier.css` (verbatim move, zero
visual change) until a visual pass splits them per component.
- Global CSS is `styles/tokens.css` (custom properties, theming),
`styles/base.css` (element defaults), `styles/utilities.css`
(`.sr-only`, `.mono`), `styles/dossier.css` (briefing page shell,
page-unique hooks) and `styles/shell.css` (app content pane). Only
page-unique selectors may live here; everything else gets an owner
component module. Enforced by `npm run styleguard` in `make ui-check`.
- Server output must stay CSP-clean (separate CSS assets, no inline
styles); `smoke.mjs` stubs CSS imports for SSR.

## JavaScript only

No TypeScript, no `svelte-check`, no test runner (all outside the
allowlist / forbidden automation stack). JSDoc wire typedefs with
`// wire: <GoType>` tags mirror Go views; `// @ts-check` is allowed.

## Testing

- `npm run smoke` — SSR render assertions (wire shapes, hostile text
inertness) plus pure-unit asserts (guard, paging, undo, purge).
- `npm run characterize` — pins current behavior plus unit coverage
for fencing, pager seeding/replace/reset/commit ordering, reconnect
paths, decision payloads, briefing derivations
and the evidence-key contract. The BUG-1/2/3 pins flipped green with
their owning migrations (Devices pager, Briefing resource reset,
Health snapshot reconnect); only lifecycle behavior stays on the
manual checklist, which smoke cannot mount.
- `npm run styleguard` — fails on any inline `<style>` block; runs in
`make ui-check`.
- Release sign-off remains the human walkthrough in
`.innerdoc/ui-manual-checklist.md`.

## Decisions log

- CSS Modules over `<style src>` (needs a new dep + Design exception)
or global-prefix naming (leak-prone).
- One `@` alias for the src root (vite + jsconfig) so moves never
rewrite specifiers; imports use `@/lib/x`, never `..` traversal.
Node-run scripts keep relative paths; the harness resolves `@`
through an inline `module.registerHooks()` resolver (own code, zero
dependencies) where dynamic imports need it.
- Health migrates first, Briefing and Decisions last; one screen per
step so the app stays shippable.
- `lib/` helpers are plain JS over `svelte/store`, not `.svelte.js` runes:
runes need the compiler, which plain node lacks, and no test runner
may be added — so unit coverage lives in `scripts/characterize.mjs`.
Components consume the stores with `$data` as usual.
50 changes: 47 additions & 3 deletions ui/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,58 @@ export default [
}
},
{
// The real outDir plus a local fallback: a stale or misconfigured
// build must never be linted as a source.
ignores: ['../internal/viewer/web/dist/**', 'dist/**']
// Generated output is never a source: the real outDir plus local
// fallbacks (a stale or misconfigured build, the SSR smoke tree).
ignores: ['../internal/viewer/web/dist/**', 'dist/**', '.smoke-tmp/**']
},
{
files: ['scripts/**/*.mjs'],
languageOptions: {
globals: { ...globals.node }
}
},
{
// Folder-split boundaries (CONVENTIONS): components render props and
// never fetch; containers orchestrate and never import each other
// (App alone composes pages); lib stays free of UI layers.
// These patterns match nothing until Phases 2-5 create the folders,
// so they lock the contract before the first move.
files: ['src/components/**/*.{js,svelte}'],
rules: {
'no-restricted-imports': ['error', {
patterns: [
{ group: ['@/api*'], message: 'components render props; fetching lives in containers.' },
{ group: ['@/containers', '@/containers/*'], message: 'components never import containers.' },
{ group: ['@/lib/context*', '**/lib/context*'], message: 'components read connection state from props, not context.' }
]
}]
}
},
{
files: ['src/containers/**/*.{js,svelte}'],
rules: {
'no-restricted-imports': ['error', {
patterns: [
{ group: ['@/containers', '@/containers/*'], message: 'containers never import each other; App alone composes pages.' }
]
}]
}
},
{
// App is the single composer: it may import page containers.
files: ['src/containers/App/**/*.{js,svelte}'],
rules: {
'no-restricted-imports': 'off'
}
},
{
files: ['src/lib/**/*.{js,svelte.js}'],
rules: {
'no-restricted-imports': ['error', {
patterns: [
{ group: ['@/components', '@/components/*', '@/containers', '@/containers/*'], message: 'lib stays UI-free; containers compose lib, never the reverse.' }
]
}]
}
}
];
13 changes: 13 additions & 0 deletions ui/jsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"baseUrl": ".",
"checkJs": false,
"module": "ESNext",
"moduleResolution": "Bundler",
"target": "ESNext",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src", "scripts", "vite.config.js", "eslint.config.js"]
}
6 changes: 4 additions & 2 deletions ui/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "watcherbridge-ui",
"private": true,
"version": "0.1.0",
"version": "0.1.2",
"description": "WatcherBridge local viewer (Svelte 5 + Vite, JavaScript only)",
"type": "module",
"engines": {
Expand All @@ -12,7 +12,9 @@
"build": "vite build",
"lint": "eslint . --max-warnings 0",
"size": "node ./scripts/size.mjs",
"smoke": "node ./scripts/smoke.mjs"
"smoke": "node ./scripts/smoke.mjs",
"characterize": "node ./scripts/characterize.mjs",
"styleguard": "node ./scripts/styleguard.mjs"
},
"dependencies": {
"svelte": "5.57.3"
Expand Down
Loading
Loading