docs(privshield): add veil TUI walkthrough GIF, ship the web console in-repo

- docs/veil-tui.gif: an animated walkthrough of the `veil` terminal harness
  (shield off/on, 32 passes → out-of-spec, 96 → pass, ward preset, optimize,
  witness check), embedded at the top of the harness section in the crate README.
- ui/veil-console.html: the self-contained VEIL Console web dashboard now lives
  in the repo (no build/network), linked from the README.
- veil: honor CLICOLOR_FORCE so piped captures keep ANSI color (standard flag).
- veil-console.html footer now points at the `veil` terminal harness/TUI.

Validated: 38 tests + doctest pass, clippy --all-targets -D warnings clean,
rustfmt clean. GIF is 800×520, ~113 KB, verified to decode/animate in a browser.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01WEXNqzs7UsfNFBcP5yW21p
This commit is contained in:
Claude
2026-08-09 15:39:01 +00:00
parent cb67be117a
commit aea8c8c66a
4 changed files with 950 additions and 4 deletions
@@ -73,6 +73,8 @@ counterpart to the npm metaharness). It drives the same model the tests use —
interactive ANSI dashboard plus scriptable subcommands, std-only (no
`crossterm`/`ratatui`), so it runs in any terminal, pipe, or CI.
![veil TUI walkthrough — toggling the shield off/on, dropping to 32 passes (out of spec), back to 96 (pass), a ward preset, optimize, and a witness check](docs/veil-tui.gif)
```bash
cargo run -p wifi-densepose-privshield --bin veil # interactive TUI (or a one-shot report when piped)
cargo run -p wifi-densepose-privshield --bin veil -- sweep # re-ID vs passes + throughput vs bits
@@ -97,8 +99,11 @@ cargo run -p wifi-densepose-privshield --bin veil -- doctor # self-check, exit
In the TUI, type commands to steer the shield live: `on`/`off`, `passes <n>`,
`bits <n>`, `n <k>`, `snr <db>`, `metric euclid|cosine`,
`preset scif|board|ward|hotel`, `optimize`, `proof`, `quit`. All readouts are
**SYNTHETIC / L0**. (A graphical VEIL Console web dashboard mirrors this same
instrument; `veil` is the terminal-native version.)
**SYNTHETIC / L0**.
A self-contained graphical **VEIL Console** web dashboard mirrors this same
instrument — it ships in [`ui/veil-console.html`](ui/veil-console.html) (open it
in any browser; no build, no network). `veil` is the terminal-native version.
## Modules
Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

@@ -38,9 +38,16 @@ const BOLD: &str = "\x1b[1m";
const RST: &str = "\x1b[0m";
const BLOCKS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
/// Emit color only for a real terminal, and never when `NO_COLOR` is set.
/// Emit color for a real terminal; never when `NO_COLOR` is set; always when
/// `CLICOLOR_FORCE` is set (so piped captures keep their color).
fn color_enabled() -> bool {
io::stdout().is_terminal() && std::env::var_os("NO_COLOR").is_none()
if std::env::var_os("NO_COLOR").is_some() {
return false;
}
if std::env::var_os("CLICOLOR_FORCE").is_some() {
return true;
}
io::stdout().is_terminal()
}
/// Wrap `s` in `code` when color is on.
@@ -0,0 +1,934 @@
<title>VEIL Console — WiFi-Sensing Privacy Shield</title>
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<style>
/* ---- Theme tokens: light is the bare :root; dark redefined twice ---- */
:root {
--ground: #e8eeee;
--surface: #ffffff;
--surface-2: #f1f6f6;
--ink: #0b1a1c;
--muted: #5b6d6f;
--faint: #8b9c9d;
--line: #d6e0e0;
--grid: #e3ebeb;
--accent: #0fb5a6;
--accent-2: #0a897e;
--accent-soft: rgba(15, 181, 166, 0.12);
--threat: #dd6f26;
--threat-soft: rgba(221, 111, 38, 0.12);
--good: #1f9d63;
--crit: #d5383d;
--shadow: 0 1px 2px rgba(11, 26, 28, 0.06), 0 8px 24px rgba(11, 26, 28, 0.07);
--shadow-sm: 0 1px 2px rgba(11, 26, 28, 0.06);
--mono: ui-monospace, "SF Mono", "JetBrains Mono", "Cascadia Code", Menlo, Consolas, monospace;
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
--r: 14px;
--r-sm: 10px;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--ground: #071211;
--surface: #0d1b19;
--surface-2: #122423;
--ink: #e3efee;
--muted: #8ba09f;
--faint: #5f7473;
--line: #1e3634;
--grid: #16302e;
--accent: #20d3c0;
--accent-2: #5eebdc;
--accent-soft: rgba(32, 211, 192, 0.14);
--threat: #f59e4b;
--threat-soft: rgba(245, 158, 75, 0.15);
--good: #3ecf8e;
--crit: #f26b6f;
--shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 12px 30px rgba(0, 0, 0, 0.4);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.35);
}
}
:root[data-theme="dark"] {
--ground: #071211;
--surface: #0d1b19;
--surface-2: #122423;
--ink: #e3efee;
--muted: #8ba09f;
--faint: #5f7473;
--line: #1e3634;
--grid: #16302e;
--accent: #20d3c0;
--accent-2: #5eebdc;
--accent-soft: rgba(32, 211, 192, 0.14);
--threat: #f59e4b;
--threat-soft: rgba(245, 158, 75, 0.15);
--good: #3ecf8e;
--crit: #f26b6f;
--shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 12px 30px rgba(0, 0, 0, 0.4);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.35);
}
* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0;
background: var(--ground);
color: var(--ink);
font-family: var(--sans);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
overflow-x: hidden;
}
h1, h2, h3 { margin: 0; text-wrap: balance; letter-spacing: -0.02em; }
a { color: var(--accent-2); }
.wrap { max-width: 1120px; margin: 0 auto; padding: 14px 14px 64px; }
.label {
font-family: var(--mono);
font-size: 11px;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--muted);
}
.mono { font-family: var(--mono); font-variant-numeric: tabular-nums; }
/* ---- top bar ---- */
.topbar {
display: flex; align-items: center; gap: 10px;
padding: 6px 2px 16px;
}
.brand { display: flex; align-items: center; gap: 10px; min-width: 0; }
.glyph {
width: 34px; height: 34px; flex: none; border-radius: 9px;
background: var(--accent-soft);
display: grid; place-items: center;
border: 1px solid var(--line);
}
.glyph svg { width: 20px; height: 20px; }
.brand-txt { display: flex; flex-direction: column; line-height: 1.05; min-width: 0; }
.brand-txt b { font-size: 15px; letter-spacing: 0.02em; }
.brand-txt span { font-family: var(--mono); font-size: 10px; letter-spacing: 0.16em; text-transform: uppercase; color: var(--faint); }
.spacer { flex: 1; }
.icon-btn {
width: 38px; height: 38px; flex: none; border-radius: 10px;
border: 1px solid var(--line); background: var(--surface); color: var(--ink);
display: grid; place-items: center; cursor: pointer; box-shadow: var(--shadow-sm);
transition: transform .15s ease, border-color .15s ease;
}
.icon-btn:hover { border-color: var(--accent); transform: translateY(-1px); }
.icon-btn svg { width: 18px; height: 18px; }
.icon-btn .sun { display: none; }
:root[data-theme="dark"] .icon-btn .sun { display: block; }
:root[data-theme="dark"] .icon-btn .moon { display: none; }
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) .icon-btn .sun { display: block; }
:root:not([data-theme="light"]) .icon-btn .moon { display: none; }
}
/* status pill */
.status {
display: inline-flex; align-items: center; gap: 7px;
font-family: var(--mono); font-size: 11px; font-weight: 600; letter-spacing: 0.08em;
padding: 7px 11px; border-radius: 999px; border: 1px solid var(--line);
background: var(--surface); text-transform: uppercase; white-space: nowrap;
}
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); }
.status.good { color: var(--good); border-color: color-mix(in srgb, var(--good) 40%, var(--line)); background: color-mix(in srgb, var(--good) 10%, var(--surface)); }
.status.good .dot { background: var(--good); box-shadow: 0 0 0 0 color-mix(in srgb, var(--good) 60%, transparent); animation: pulse 2s infinite; }
.status.watch { color: var(--accent-2); border-color: color-mix(in srgb, var(--accent) 40%, var(--line)); background: var(--accent-soft); }
.status.watch .dot { background: var(--accent); }
.status.bad { color: var(--crit); border-color: color-mix(in srgb, var(--crit) 40%, var(--line)); background: color-mix(in srgb, var(--crit) 10%, var(--surface)); }
.status.bad .dot { background: var(--crit); animation: pulse-bad 1.1s infinite; }
@keyframes pulse { 70% { box-shadow: 0 0 0 7px transparent; } 100% { box-shadow: 0 0 0 0 transparent; } }
@keyframes pulse-bad { 0%,100% { opacity: 1; } 50% { opacity: .35; } }
/* ---- grid ---- */
.grid { display: grid; gap: 14px; grid-template-columns: 1fr; }
@media (min-width: 900px) {
.grid { grid-template-columns: 1.15fr 1fr; align-items: start; }
.col-span { grid-column: 1 / -1; }
}
.card {
background: var(--surface); border: 1px solid var(--line); border-radius: var(--r);
box-shadow: var(--shadow); padding: 16px; opacity: 0; transform: translateY(10px);
animation: rise .55s cubic-bezier(.2,.7,.3,1) forwards;
}
@keyframes rise { to { opacity: 1; transform: none; } }
.card > .head { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; margin-bottom: 12px; }
.card h2 { font-size: 14px; font-weight: 650; }
.card .sub { font-family: var(--mono); font-size: 11px; color: var(--muted); }
/* ---- hero ---- */
.hero { padding: 0; overflow: hidden; position: relative; }
.hero-canvas-wrap { position: relative; aspect-ratio: 16 / 11; width: 100%; background:
radial-gradient(120% 90% at 50% 0%, color-mix(in srgb, var(--accent) 6%, var(--surface)), var(--surface)); }
@media (min-width: 900px) { .hero-canvas-wrap { aspect-ratio: 16 / 10; } }
canvas { display: block; width: 100%; height: 100%; }
.hero-overlay { position: absolute; inset: 0; padding: 16px; display: flex; flex-direction: column; justify-content: space-between; pointer-events: none; }
.hero-top { display: flex; justify-content: space-between; align-items: flex-start; gap: 10px; }
.hero h1 { font-size: clamp(20px, 5.4vw, 30px); font-weight: 720; line-height: 1.04; max-width: 15ch; }
.hero h1 em { font-style: normal; color: var(--accent-2); }
.hero .kbig { font-family: var(--mono); font-variant-numeric: tabular-nums; text-align: right; }
.hero .kbig b { display: block; font-size: clamp(26px, 8vw, 44px); font-weight: 680; letter-spacing: -0.03em; line-height: 1; }
.hero .kbig small { font-size: 10px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--muted); }
.hero-bottom { display: flex; align-items: flex-end; justify-content: space-between; gap: 12px; }
.legend { display: flex; gap: 14px; font-family: var(--mono); font-size: 11px; color: var(--muted); }
.legend i { width: 9px; height: 9px; border-radius: 2px; display: inline-block; margin-right: 5px; vertical-align: middle; }
.switch-row { pointer-events: auto; display: inline-flex; gap: 4px; padding: 4px; border-radius: 12px; background: color-mix(in srgb, var(--surface) 80%, transparent); border: 1px solid var(--line); backdrop-filter: blur(6px); }
.seg { font-family: var(--mono); font-size: 11px; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; border: none; background: transparent; color: var(--muted); padding: 7px 12px; border-radius: 9px; cursor: pointer; transition: color .15s ease, background .15s ease; }
.seg[aria-pressed="true"] { background: var(--accent); color: #04201d; }
:root[data-theme="dark"] .seg[aria-pressed="true"] { color: #04201d; }
.seg.off[aria-pressed="true"] { background: var(--crit); color: #fff; }
/* ---- KPI row ---- */
.kpis { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(150px, 1fr); gap: 12px; overflow-x: auto; scroll-snap-type: x mandatory; padding-bottom: 4px; margin: 0 -2px; }
.kpis::-webkit-scrollbar { height: 0; }
@media (min-width: 640px) { .kpis { grid-auto-flow: row; grid-template-columns: repeat(4, 1fr); grid-auto-columns: auto; overflow: visible; } }
.kpi { scroll-snap-align: start; background: var(--surface); border: 1px solid var(--line); border-radius: var(--r-sm); padding: 13px 14px; box-shadow: var(--shadow-sm); position: relative; overflow: hidden; }
.kpi .label { display: flex; align-items: center; gap: 6px; }
.kpi .v { font-family: var(--mono); font-variant-numeric: tabular-nums; font-size: 26px; font-weight: 650; letter-spacing: -0.02em; margin-top: 8px; line-height: 1; }
.kpi .v small { font-size: 13px; color: var(--muted); font-weight: 500; }
.kpi .foot { font-family: var(--mono); font-size: 10.5px; color: var(--muted); margin-top: 6px; }
.kpi .strip { position: absolute; left: 0; top: 0; bottom: 0; width: 3px; background: var(--accent); }
.kpi.warnstrip .strip { background: var(--threat); }
.kpi.goodstrip .strip { background: var(--good); }
.tag { font-family: var(--mono); font-size: 9.5px; letter-spacing: .08em; text-transform: uppercase; padding: 2px 6px; border-radius: 5px; background: var(--surface-2); color: var(--muted); }
.tag.good { color: var(--good); background: color-mix(in srgb, var(--good) 12%, var(--surface)); }
.tag.warn { color: var(--threat); background: var(--threat-soft); }
.chart-box { width: 100%; aspect-ratio: 16 / 9; position: relative; }
.chart-box.short { aspect-ratio: 20 / 6; }
figcaption { font-family: var(--mono); font-size: 10.5px; color: var(--muted); margin-top: 8px; display: flex; justify-content: space-between; gap: 8px; }
/* ---- tools ---- */
.tool { display: flex; flex-direction: column; gap: 4px; margin-bottom: 16px; }
.tool:last-child { margin-bottom: 0; }
.tool .trow { display: flex; justify-content: space-between; align-items: baseline; }
.tool .tval { font-family: var(--mono); font-variant-numeric: tabular-nums; font-size: 14px; font-weight: 650; color: var(--accent-2); }
.tool .tval .note { font-size: 10px; color: var(--muted); font-weight: 400; margin-left: 6px; }
input[type="range"] { -webkit-appearance: none; appearance: none; width: 100%; height: 6px; border-radius: 999px; background: linear-gradient(to right, var(--accent) var(--pct,50%), var(--surface-2) var(--pct,50%)); outline: none; margin: 8px 0 2px; }
input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 20px; height: 20px; border-radius: 50%; background: var(--surface); border: 2px solid var(--accent); box-shadow: var(--shadow-sm); cursor: pointer; transition: transform .12s ease; }
input[type="range"]::-webkit-slider-thumb:active { transform: scale(1.15); }
input[type="range"]::-moz-range-thumb { width: 20px; height: 20px; border-radius: 50%; background: var(--surface); border: 2px solid var(--accent); cursor: pointer; }
input[type="range"]:focus-visible { box-shadow: 0 0 0 3px var(--accent-soft); }
.presets { display: flex; flex-wrap: wrap; gap: 8px; }
.chip { font-family: var(--mono); font-size: 12px; font-weight: 600; padding: 9px 13px; border-radius: 10px; border: 1px solid var(--line); background: var(--surface-2); color: var(--ink); cursor: pointer; transition: all .15s ease; display: flex; flex-direction: column; gap: 2px; align-items: flex-start; }
.chip small { font-size: 9.5px; letter-spacing: .05em; text-transform: uppercase; color: var(--muted); font-weight: 500; }
.chip:hover { border-color: var(--accent); transform: translateY(-1px); }
.chip[aria-pressed="true"] { border-color: var(--accent); background: var(--accent-soft); }
.chip[aria-pressed="true"] small { color: var(--accent-2); }
.btn { font-family: var(--mono); font-size: 13px; font-weight: 650; letter-spacing: 0.04em; text-transform: uppercase; width: 100%; padding: 13px; border-radius: 11px; border: none; background: var(--accent); color: #04201d; cursor: pointer; box-shadow: var(--shadow-sm); transition: transform .12s ease, filter .15s ease; }
.btn:hover { filter: brightness(1.05); transform: translateY(-1px); }
.btn:active { transform: translateY(0); }
.btn:disabled { opacity: .6; cursor: default; transform: none; }
/* experiment bars */
.exp { display: flex; flex-direction: column; gap: 12px; }
.bar { display: grid; grid-template-columns: 1fr auto; gap: 4px 10px; align-items: center; }
.bar .bl { font-family: var(--mono); font-size: 11px; color: var(--muted); }
.bar .bv { font-family: var(--mono); font-variant-numeric: tabular-nums; font-size: 13px; font-weight: 650; }
.track { grid-column: 1 / -1; height: 9px; border-radius: 999px; background: var(--surface-2); overflow: hidden; }
.fill { height: 100%; width: 0; border-radius: 999px; transition: width 1.1s cubic-bezier(.2,.7,.3,1); }
.fill.threat { background: linear-gradient(90deg, var(--threat), color-mix(in srgb, var(--threat) 70%, #c0392b)); }
.fill.accent { background: linear-gradient(90deg, var(--accent), var(--accent-2)); }
.fill.good { background: linear-gradient(90deg, var(--good), color-mix(in srgb,var(--good) 70%, var(--accent))); }
.verdict { display: flex; align-items: center; gap: 8px; font-family: var(--mono); font-size: 12px; }
.verdict .pass { color: var(--good); font-weight: 700; }
.note { font-family: var(--mono); font-size: 11px; line-height: 1.5; color: var(--muted); }
.note b { color: var(--ink); font-weight: 650; }
.foot-links { display: flex; flex-wrap: wrap; gap: 6px 16px; margin-top: 10px; font-family: var(--mono); font-size: 11px; }
.compliance-meter { display: flex; align-items: center; gap: 10px; font-family: var(--mono); }
.cm-bar { flex: 1; height: 34px; border-radius: 8px; border: 1px dashed color-mix(in srgb, var(--good) 45%, var(--line)); background: color-mix(in srgb, var(--good) 8%, var(--surface)); display: flex; align-items: center; justify-content: center; font-size: 11px; color: var(--good); font-weight: 650; letter-spacing: .06em; }
/* ---- info buttons ---- */
.head-r { display: flex; align-items: center; gap: 9px; }
.info-btn {
width: 22px; height: 22px; flex: none; border-radius: 50%;
border: 1px solid var(--line); background: var(--surface-2); color: var(--muted);
font-family: var(--mono); font-size: 12px; font-weight: 700; line-height: 1;
cursor: pointer; display: grid; place-items: center; transition: all .15s ease;
}
.info-btn:hover { border-color: var(--accent); color: var(--accent-2); transform: translateY(-1px); }
.info-btn:focus-visible { outline: none; box-shadow: 0 0 0 3px var(--accent-soft); }
.mini-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin: 2px 2px -6px; }
/* ---- modal / bottom sheet ---- */
.backdrop {
position: fixed; inset: 0; z-index: 80; display: flex; align-items: flex-end; justify-content: center;
background: color-mix(in srgb, #041110 64%, transparent); backdrop-filter: blur(4px);
transition: opacity .2s ease;
}
.backdrop[hidden] { display: none; }
@media (min-width: 560px) { .backdrop { align-items: center; padding: 20px; } }
.modal {
width: 100%; max-width: 480px; max-height: 88vh; overflow-y: auto;
background: var(--surface); color: var(--ink);
border: 1px solid var(--line); border-top: 3px solid var(--accent);
border-radius: 20px 20px 0 0; box-shadow: var(--shadow);
padding: 18px 18px calc(20px + env(safe-area-inset-bottom));
animation: sheet-up .38s cubic-bezier(.2,.8,.25,1);
-webkit-overflow-scrolling: touch;
}
@media (min-width: 560px) { .modal { border-radius: 16px; border-top-width: 1px; padding: 22px; animation: modal-pop .3s cubic-bezier(.2,.8,.25,1); } }
@keyframes sheet-up { from { transform: translateY(100%); } }
@keyframes modal-pop { from { transform: translateY(12px) scale(.97); opacity: 0; } }
.grip { width: 42px; height: 4px; border-radius: 999px; background: var(--line); margin: 0 auto 15px; }
@media (min-width: 560px) { .grip { display: none; } }
.modal-h { display: flex; align-items: flex-start; gap: 12px; }
.mglyph { width: 42px; height: 42px; flex: none; border-radius: 12px; background: var(--accent-soft); display: grid; place-items: center; border: 1px solid var(--line); }
.mglyph svg { width: 23px; height: 23px; }
.modal h3 { font-size: 19px; font-weight: 720; line-height: 1.15; }
.eyebrow { font-family: var(--mono); font-size: 10.5px; letter-spacing: .15em; text-transform: uppercase; color: var(--accent-2); }
.modal p { margin: 12px 0; color: var(--muted); font-size: 14px; line-height: 1.6; }
.modal p b, .points b { color: var(--ink); }
.m-close { margin-left: auto; width: 32px; height: 32px; border-radius: 9px; border: 1px solid var(--line); background: var(--surface-2); color: var(--muted); cursor: pointer; font-size: 17px; line-height: 1; flex: none; }
.m-close:hover { border-color: var(--accent); color: var(--ink); }
.points { list-style: none; margin: 15px 0 4px; padding: 0; display: flex; flex-direction: column; gap: 11px; }
.points li { display: flex; gap: 11px; align-items: flex-start; }
.points .pi { width: 30px; height: 30px; flex: none; border-radius: 9px; display: grid; place-items: center; background: var(--surface-2); border: 1px solid var(--line); color: var(--accent-2); font-family: var(--mono); font-weight: 700; font-size: 13px; }
.points .pi svg { width: 16px; height: 16px; }
.points b { font-size: 13.5px; display: block; }
.points small { display: block; font-size: 12.5px; color: var(--muted); line-height: 1.5; margin-top: 2px; }
.modal-actions { display: flex; flex-direction: column; gap: 9px; margin-top: 18px; }
@media (min-width: 420px) { .modal-actions { flex-direction: row-reverse; } .modal-actions .btn { flex: 1; } }
.btn.ghost { background: transparent; color: var(--ink); border: 1px solid var(--line); box-shadow: none; }
.btn.ghost:hover { border-color: var(--accent); filter: none; }
/* ---- guided tour ---- */
.tour-dim { position: fixed; inset: 0; z-index: 70; background: color-mix(in srgb, #041110 66%, transparent); }
.tour-dim[hidden] { display: none; }
.tour-focus {
position: relative !important; z-index: 74; border-radius: 14px;
box-shadow: 0 0 0 3px var(--accent), 0 0 0 8px var(--accent-soft), 0 12px 34px rgba(0,0,0,.4);
transition: box-shadow .2s ease;
}
.tour-tip {
position: fixed; left: 12px; right: 12px; z-index: 78; margin: 0 auto; max-width: 440px;
background: var(--surface); border: 1px solid var(--line); border-radius: 15px;
box-shadow: var(--shadow); padding: 15px 16px calc(15px + env(safe-area-inset-bottom));
animation: modal-pop .22s ease;
}
.tour-tip[hidden] { display: none; }
.tour-tip h4 { margin: 5px 0 6px; font-size: 15.5px; font-weight: 700; }
.tour-tip p { margin: 0; color: var(--muted); font-size: 13.5px; line-height: 1.55; }
.tour-nav { display: flex; align-items: center; gap: 8px; margin-top: 15px; }
.tour-dots { display: flex; gap: 5px; flex: 1; align-items: center; }
.tour-dots i { width: 6px; height: 6px; border-radius: 50%; background: var(--line); transition: background .2s, width .2s; }
.tour-dots i.on { background: var(--accent); width: 16px; border-radius: 3px; }
.tour-nav button { font-family: var(--mono); font-size: 12px; font-weight: 650; padding: 9px 14px; border-radius: 9px; cursor: pointer; border: 1px solid var(--line); background: var(--surface-2); color: var(--ink); }
.tour-nav button:disabled { opacity: .4; cursor: default; }
.tour-nav .next { background: var(--accent); color: #04201d; border-color: transparent; }
.tour-nav .skip { border: none; background: transparent; color: var(--muted); padding: 9px 4px; }
@media (prefers-reduced-motion: reduce) {
.card { animation: none; opacity: 1; transform: none; }
.status.good .dot, .status.bad .dot { animation: none; }
.fill { transition: none; }
.modal, .tour-tip { animation: none; }
.tour-focus { transition: none; }
}
</style>
<div class="wrap">
<div class="topbar">
<div class="brand">
<span class="glyph" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="var(--accent-2)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l7 3v5c0 4.4-3 7.6-7 9-4-1.4-7-4.6-7-9V6l7-3z"/><path d="M8.5 12.2l2.2 2.2 4.8-4.8"/></svg>
</span>
<span class="brand-txt">
<b>VEIL&nbsp;Console</b>
<span>WiFi-sensing shield</span>
</span>
</div>
<span class="spacer"></span>
<span id="statusPill" class="status watch"><span class="dot"></span><span id="statusTxt">Monitoring</span></span>
<button id="helpBtn" class="icon-btn" aria-label="Introduction and help" title="Introduction &amp; help">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M9.6 9.2a2.4 2.4 0 114 1.9c-1 .7-1.6 1.2-1.6 2.4"/><path d="M12 17h.01"/></svg>
</button>
<button id="themeBtn" class="icon-btn" aria-label="Toggle light / dark theme" title="Toggle theme">
<svg class="moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.8A9 9 0 1111.2 3 7 7 0 0021 12.8z"/></svg>
<svg class="sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>
</button>
</div>
<div class="grid">
<!-- HERO -->
<section id="heroCard" class="card hero col-span" style="animation-delay:0s">
<div class="hero-canvas-wrap">
<canvas id="hero" aria-label="Live view of identity clusters collapsing toward the chance floor when the shield engages"></canvas>
<div class="hero-overlay">
<div class="hero-top">
<h1>Identity inference,<br/>collapsed to&nbsp;<em>chance</em>.</h1>
<div class="kbig"><b id="heroReid">4.7%</b><small>re-id &middot; shield on</small></div>
</div>
<div class="hero-bottom">
<div class="switch-row" role="group" aria-label="Shield mode">
<button class="seg" id="mAuto" aria-pressed="true">Auto</button>
<button class="seg" id="mOn" aria-pressed="false">On</button>
<button class="seg off" id="mOff" aria-pressed="false">Off</button>
</div>
<div class="legend">
<span><i style="background:var(--threat)"></i>exposed</span>
<span><i style="background:var(--accent)"></i>shielded</span>
</div>
</div>
</div>
</div>
</section>
<!-- KPIs -->
<div class="mini-head col-span"><span class="label">Live scorecard</span><button class="info-btn" data-explain="kpis" aria-label="What do these numbers mean?">?</button></div>
<section id="kpisSec" class="kpis col-span" aria-label="Live metrics">
<div class="kpi warnstrip"><span class="strip"></span><div class="label">Re-ID · off</div><div class="v" id="kOff">100<small>%</small></div><div class="foot">attacker unhindered</div></div>
<div class="kpi goodstrip"><span class="strip"></span><div class="label">Re-ID · on</div><div class="v" id="kOn">4.7<small>%</small></div><div class="foot" id="kChance">chance 6.25%</div></div>
<div class="kpi"><span class="strip"></span><div class="label">Throughput</div><div class="v" id="kTput">97.6<small>%</small></div><div class="foot">of baseline link</div></div>
<div class="kpi goodstrip"><span class="strip"></span><div class="label">Emission</div><div class="v mono" id="kEnergy" style="font-size:22px">1.000<small>×</small></div><div class="foot"><span class="tag good">not jamming</span></div></div>
</section>
<!-- COLLAPSE CURVE -->
<figure id="curveCard" class="card" style="animation-delay:.06s; margin:0">
<div class="head"><h2>Collapse curve</h2><div class="head-r"><span class="sub">re-ID vs mixing</span><button class="info-btn" data-explain="curve" aria-label="Explain this chart">?</button></div></div>
<div class="chart-box"><canvas id="curve"></canvas></div>
<figcaption><span>Givens passes →</span><span id="curveOp">op: 96 · re-ID 4.7%</span></figcaption>
</figure>
<!-- THROUGHPUT CURVE -->
<figure class="card" style="animation-delay:.12s; margin:0">
<div class="head"><h2>Throughput optimum</h2><div class="head-r"><span class="sub">vs feedback bits</span><button class="info-btn" data-explain="tput" aria-label="Explain this chart">?</button></div></div>
<div class="chart-box"><canvas id="tput"></canvas></div>
<figcaption><span>Feedback resolution (bits) →</span><span id="tputOp">5-bit · 97.6%</span></figcaption>
</figure>
<!-- SENSING DETECTOR -->
<figure class="card col-span" style="animation-delay:.16s; margin:0">
<div class="head"><h2>Sensing activity</h2><div class="head-r"><span class="sub" id="detTxt">solicitations / s</span><button class="info-btn" data-explain="detector" aria-label="Explain this chart">?</button></div></div>
<div class="chart-box short"><canvas id="detector"></canvas></div>
<figcaption><span>threshold 5.0 Hz — shield auto-engages above</span><span id="detNow">0.0 Hz</span></figcaption>
</figure>
<!-- TOOLS -->
<section id="controlsCard" class="card" style="animation-delay:.2s; margin:0">
<div class="head"><h2>Shield controls</h2><div class="head-r"><span class="sub">live model</span><button class="info-btn" data-explain="controls" aria-label="Explain these controls">?</button></div></div>
<div class="tool">
<div class="trow"><span class="label">Givens passes</span><span class="tval" id="vPass">96<span class="note">min robust 48</span></span></div>
<input type="range" id="sPass" min="8" max="128" step="8" value="96" aria-label="Givens rotation passes" />
</div>
<div class="tool">
<div class="trow"><span class="label">Feedback resolution</span><span class="tval" id="vBits">5<span class="note">bits · 802.11 {5,7,9}</span></span></div>
<input type="range" id="sBits" min="1" max="12" step="1" value="5" aria-label="Feedback resolution in bits" />
</div>
<div class="tool">
<div class="trow"><span class="label">Candidate identities</span><span class="tval" id="vN">16<span class="note">chance 6.25%</span></span></div>
<input type="range" id="sN" min="4" max="64" step="4" value="16" aria-label="Number of candidate identities" />
</div>
<div class="tool">
<div class="trow"><span class="label">Link SNR</span><span class="tval" id="vSnr">20<span class="note">dB</span></span></div>
<input type="range" id="sSnr" min="5" max="40" step="1" value="20" aria-label="Link signal-to-noise ratio in dB" />
</div>
<div style="height:8px"></div>
<div class="compliance-meter" aria-label="Compliance">
<span class="label" style="white-space:nowrap">Energy in</span>
<div class="cm-bar" id="cmBar">= energy out · 1.000×</div>
<span class="label" style="white-space:nowrap">out</span>
</div>
</section>
<!-- EXPERIMENT + PRESETS -->
<section id="expCard" class="card" style="animation-delay:.26s; margin:0">
<div class="head"><h2>Attacker vs. protector</h2><div class="head-r"><span class="sub">synthetic · L0</span><button class="info-btn" data-explain="experiment" aria-label="Explain this tool">?</button></div></div>
<div class="exp">
<div class="bar">
<span class="bl">Passive re-ID — shield off</span><span class="bv" id="eOff" style="color:var(--threat)"></span>
<div class="track"><div class="fill threat" id="fOff"></div></div>
</div>
<div class="bar">
<span class="bl">Passive re-ID — shield on</span><span class="bv" id="eOn" style="color:var(--accent-2)"></span>
<div class="track"><div class="fill accent" id="fOn"></div></div>
</div>
<div class="bar">
<span class="bl">Link throughput retained</span><span class="bv" id="eTput" style="color:var(--good)"></span>
<div class="track"><div class="fill good" id="fTput"></div></div>
</div>
<div class="verdict"><span id="verdict"></span></div>
</div>
<div style="height:14px"></div>
<button class="btn" id="runBtn">▶ Run experiment</button>
<div style="height:20px"></div>
<div class="label" style="margin-bottom:9px">Deployment presets · adaptive shield</div>
<div class="presets" id="presets">
<button class="chip" data-p="scif" aria-pressed="false">Defence / SCIF<small>N64 · 96p · 5b</small></button>
<button class="chip" data-p="board" aria-pressed="true">Boardroom<small>N16 · 96p · 5b</small></button>
<button class="chip" data-p="ward" aria-pressed="false">Hospital ward<small>N32 · 96p · 5b</small></button>
<button class="chip" data-p="hotel" aria-pressed="false">Hotel floor<small>N48 · 64p · 5b</small></button>
</div>
</section>
<section class="col-span note" style="padding:2px 4px">
<b>Prefer the terminal?</b> The same instrument ships as <b>veil</b> — a dependency-free
TUI &amp; scriptable harness inside the crate
(<span class="mono">cargo run -p wifi-densepose-privshield --bin veil</span>). Live-steer the
shield with <span class="mono">on/off · passes · bits · preset · optimize</span>, or run
<span class="mono">veil doctor</span> in CI.
</section>
<section class="col-span note" style="padding:2px 4px">
<b>Compliant waveform controls only — never jamming.</b> The shield rotates its own beamforming
feedback with keyed Givens rotations (energy-preserving), so a sniffer can't average out a stable
identity while the associated receiver, holding the key, decodes normally. All figures are
<b>SYNTHETIC / evidence-level&nbsp;L0</b> from the reference model — not measured on hardware.
<div class="foot-links">
<a href="#" onclick="return false">ADR-288 · VEIL</a>
<a href="#" onclick="return false">ADR-289 · harness</a>
<a href="#" onclick="return false">docs/research/privacy-shield</a>
</div>
</section>
</div>
</div>
<!-- onboarding modal / bottom sheet -->
<div id="backdrop" class="backdrop" role="dialog" aria-modal="true" aria-label="Introduction" hidden>
<div class="modal" id="modal"><div class="grip" aria-hidden="true"></div><div id="modalBody"></div></div>
</div>
<!-- guided tour -->
<div id="tourDim" class="tour-dim" hidden></div>
<div id="tourTip" class="tour-tip" role="dialog" aria-live="polite" aria-label="Guided tour" hidden>
<span class="eyebrow" id="tourStep">Step 1 of 5</span>
<h4 id="tourTitle"></h4>
<p id="tourBody"></p>
<div class="tour-nav">
<span class="tour-dots" id="tourDots" aria-hidden="true"></span>
<button class="skip" id="tourSkip">Skip</button>
<button id="tourBack">Back</button>
<button class="next" id="tourNext">Next</button>
</div>
</div>
<script>
(() => {
"use strict";
const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
const $ = (id) => document.getElementById(id);
const clamp = (v,a,b) => Math.max(a, Math.min(b, v));
const css = (n) => getComputedStyle(document.documentElement).getPropertyValue(n).trim();
/* ---------- VEIL model (mirrors the crate) ---------- */
// re-ID collapse vs mixing passes: piecewise-linear over the crate's measured
// N=16 shield-on points (optimize.rs). Collapse is ~N-independent (finding),
// so other N just shift by the chance-floor difference (1/N 1/16).
const REID16 = [[8,0.98],[16,0.75],[24,0.50],[32,0.20],[48,0.12],[64,0.078],[96,0.047],[112,0.078],[128,0.06]];
function interp(pts, xq) {
if (xq <= pts[0][0]) return pts[0][1];
if (xq >= pts[pts.length-1][0]) return pts[pts.length-1][1];
for (let i=0;i<pts.length-1;i++){ const [x0,y0]=pts[i],[x1,y1]=pts[i+1]; if (xq>=x0 && xq<=x1) return y0+(y1-y0)*(xq-x0)/(x1-x0); }
return pts[pts.length-1][1];
}
function reidOn(passes, N) {
const shifted = interp(REID16, passes) + (1/N - 1/16);
return clamp(shifted, Math.max(0, 1/N - 0.02), 1);
}
// throughput ratio: falling quantization residual vs rising feedback airtime.
function throughput(bits, snrDb) {
const snr = Math.pow(10, snrDb / 10);
const step = (Math.PI / 2) / Math.pow(2, bits);
const rho = Math.min(0.5, step * step / 12);
const capRatio = Math.log2(1 + snr * (1 - rho)) / Math.log2(1 + snr);
const airtime = 0.02 + 0.0008 * bits;
return clamp((1 - airtime) * capRatio, 0, 1);
}
function specOptimalBits(snrDb) { // best of {5,7,9}
return [5,7,9].reduce((b,x)=> throughput(x,snrDb) > throughput(b,snrDb) ? x : b, 5);
}
/* ---------- state ---------- */
const S = { mode:"auto", passes:96, bits:5, N:16, snr:20, sensing:0, engaged:true };
function engaged() { return S.mode==="on" || (S.mode==="auto" && S.sensing >= 5); }
/* ---------- shared count-up ---------- */
function animateNum(el, from, to, dur, fmt) {
if (reduce) { el.textContent = fmt(to); return; }
const t0 = performance.now();
(function step(t){
const k = clamp((t - t0)/dur, 0, 1);
const e = 1 - Math.pow(1-k, 3);
el.textContent = fmt(from + (to-from)*e);
if (k < 1) requestAnimationFrame(step);
})(t0);
}
/* ---------- readouts ---------- */
function refreshReadouts(animate) {
const chance = 1/S.N;
const on = reidOn(S.passes, S.N)*100;
const tp = throughput(S.bits, S.snr)*100;
$("vPass").firstChild.textContent = S.passes + " ";
$("vBits").firstChild.textContent = S.bits + " ";
$("vN").firstChild.textContent = S.N + " ";
$("vSnr").firstChild.textContent = S.snr + " ";
$("vN").querySelector(".note").textContent = "chance " + (chance*100).toFixed(2) + "%";
$("kChance").textContent = "chance " + (chance*100).toFixed(2) + "%";
$("curveOp").textContent = "op: " + S.passes + " · re-ID " + on.toFixed(1) + "%";
$("tputOp").textContent = S.bits + "-bit · " + tp.toFixed(1) + "%";
// slider fills
for (const [id,mn,mx] of [["sPass",8,128],["sBits",1,12],["sN",4,64],["sSnr",5,40]]) {
const el=$(id); el.style.setProperty("--pct", ((el.value-mn)/(mx-mn)*100)+"%");
}
$("kOn").innerHTML = on.toFixed(1)+'<small>%</small>';
$("kTput").innerHTML = tp.toFixed(1)+'<small>%</small>';
$("heroReid").textContent = (engaged()? on : 100).toFixed(1)+"%";
drawCurve(); drawTput();
}
/* ---------- status pill ---------- */
function refreshStatus() {
const pill=$("statusPill"), txt=$("statusTxt");
pill.className = "status";
if (S.mode==="off") { pill.classList.add("bad"); txt.textContent = S.sensing>=5 ? "Exposed" : "Shield off"; }
else if (engaged()) { pill.classList.add("good"); txt.textContent = "Protected"; }
else { pill.classList.add("watch"); txt.textContent = "Monitoring"; }
$("heroReid").textContent = (engaged()? reidOn(S.passes,S.N)*100 : 100).toFixed(1)+"%";
}
/* ---------- hi-dpi canvas ---------- */
function fit(c) {
const r = c.getBoundingClientRect(), dpr = Math.min(devicePixelRatio||1, 2);
c.width = Math.max(1, r.width*dpr); c.height = Math.max(1, r.height*dpr);
const x = c.getContext("2d"); x.setTransform(dpr,0,0,dpr,0,0);
return { x, w:r.width, h:r.height };
}
/* ---------- HERO: identity clusters collapsing ---------- */
const hero = $("hero");
let pts = [], collapse = engaged()?1:0, sessionAng = 0, lastSession = 0;
function seedPts() {
pts = [];
const groups = 16;
for (let g=0; g<groups; g++) {
const a = (g/groups)*Math.PI*2 + 0.2;
const rad = 0.30 + (g%3)*0.045;
for (let k=0;k<5;k++){
const jr = rad + (Math.random()-0.5)*0.05, ja = a + (Math.random()-0.5)*0.16;
pts.push({ a:ja, r:jr, ba:ja, br:jr, g });
}
}
}
seedPts();
function drawHero(t) {
const { x, w, h } = fit(hero);
const cx = w/2, cy = h*0.52, R = Math.min(w,h)*0.9;
x.clearRect(0,0,w,h);
const accent = css("--accent"), threat = css("--threat"), line = css("--grid");
// faint concentric grid (chance rings)
x.strokeStyle = line; x.lineWidth = 1;
for (let i=1;i<=3;i++){ x.beginPath(); x.arc(cx,cy,R*0.12*i,0,7); x.stroke(); }
// chance core
const coreR = R*0.06;
x.fillStyle = accent + "22";
x.beginPath(); x.arc(cx,cy,coreR*(1+0.15*Math.sin(t/600)),0,7); x.fill();
// session rotation cadence (shielded => fresh keyed rotation)
if (engaged() && !reduce && t-lastSession > 1300) { sessionAng += (Math.random()*2-1)*1.2; lastSession = t; }
const col = collapse;
for (const p of pts) {
const ang = p.ba + sessionAng*col;
const rr = p.br*(1-col) + (coreR/R + (Math.random()-0.5)*0.006)*col; // collapse toward core
const px = cx + Math.cos(ang)*rr*R, py = cy + Math.sin(ang)*rr*R;
const mix = col;
// colour lerps threat->accent as it shields
x.fillStyle = mix > 0.5 ? accent : threat;
x.globalAlpha = 0.55 + 0.35*(1-Math.abs(0.5-mix)*2*0.3);
x.beginPath(); x.arc(px,py, 2.4 + (1-col)*0.8, 0, 7); x.fill();
}
x.globalAlpha = 1;
// ease collapse toward target
const target = engaged()?1:0;
if (!reduce) collapse += (target-collapse)*0.06;
else collapse = target;
}
/* ---------- CURVE: re-ID vs passes ---------- */
function drawCurve() {
const { x, w, h } = fit($("curve"));
x.clearRect(0,0,w,h);
const padL=34, padR=10, padT=12, padB=22;
const gw = w-padL-padR, gh = h-padT-padB;
const grid=css("--grid"), muted=css("--muted"), accent=css("--accent"), threat=css("--threat"), good=css("--good");
const P0=8, P1=128, chance=1/S.N;
const X = p => padL + (p-P0)/(P1-P0)*gw;
const Y = v => padT + (1-v)*gh; // v in 0..1
// grid + y labels
x.strokeStyle=grid; x.fillStyle=muted; x.font="10px "+css("--mono").split(",")[0];
x.textAlign="right"; x.textBaseline="middle";
for (const v of [0,0.25,0.5,0.75,1]) { const yy=Y(v); x.strokeStyle=grid; x.beginPath(); x.moveTo(padL,yy); x.lineTo(w-padR,yy); x.stroke(); x.fillText((v*100)+"%", padL-6, yy); }
// chance band
x.fillStyle = good+"22"; x.fillRect(padL, Y(chance*2.48), gw, (h-padB)-Y(chance*2.48));
x.strokeStyle=good; x.setLineDash([4,4]); x.beginPath(); x.moveTo(padL,Y(chance)); x.lineTo(w-padR,Y(chance)); x.stroke(); x.setLineDash([]);
x.textAlign="left"; x.fillStyle=good; x.fillText("chance", padL+4, Y(chance)-7);
// curve
x.beginPath();
for (let p=P0;p<=P1;p+=2){ const yy=Y(reidOn(p,S.N)); const xx=X(p); p===P0?x.moveTo(xx,yy):x.lineTo(xx,yy); }
const grad=x.createLinearGradient(padL,0,w-padR,0); grad.addColorStop(0,threat); grad.addColorStop(1,accent);
x.strokeStyle=grad; x.lineWidth=2.4; x.lineJoin="round"; x.stroke();
// op point
const opx=X(S.passes), opy=Y(reidOn(S.passes,S.N));
x.fillStyle=css("--surface"); x.strokeStyle=accent; x.lineWidth=2.5;
x.beginPath(); x.arc(opx,opy,5,0,7); x.fill(); x.stroke();
// x ticks
x.fillStyle=muted; x.textAlign="center"; x.textBaseline="top";
for (const p of [8,48,96,128]){ x.fillText(p, X(p), h-padB+5); }
}
/* ---------- TPUT: throughput vs bits ---------- */
function drawTput() {
const { x, w, h } = fit($("tput"));
x.clearRect(0,0,w,h);
const padL=40, padR=10, padT=12, padB=22, gw=w-padL-padR, gh=h-padT-padB;
const grid=css("--grid"), muted=css("--muted"), accent=css("--accent"), good=css("--good");
const B0=1,B1=12, lo=0.95, hi=0.985;
const X=b=>padL+(b-B0)/(B1-B0)*gw;
const Y=v=>padT+(1-(v-lo)/(hi-lo))*gh;
x.font="10px "+css("--mono").split(",")[0]; x.textAlign="right"; x.textBaseline="middle";
for (const v of [0.95,0.96,0.97,0.98]){ const yy=Y(v); x.strokeStyle=grid; x.beginPath(); x.moveTo(padL,yy); x.lineTo(w-padR,yy); x.stroke(); x.fillStyle=muted; x.fillText((v*100).toFixed(0)+"%", padL-6, yy); }
// area + line
x.beginPath(); let started=false;
for (let b=B0;b<=B1;b++){ const yy=Y(clamp(throughput(b,S.snr),lo,hi)), xx=X(b); started?x.lineTo(xx,yy):(x.moveTo(xx,yy),started=true); }
x.lineTo(X(B1), Y(lo)); x.lineTo(X(B0), Y(lo)); x.closePath();
x.fillStyle=accent+"1e"; x.fill();
x.beginPath();
for (let b=B0;b<=B1;b++){ const yy=Y(clamp(throughput(b,S.snr),lo,hi)), xx=X(b); b===B0?x.moveTo(xx,yy):x.lineTo(xx,yy); }
x.strokeStyle=accent; x.lineWidth=2.4; x.lineJoin="round"; x.stroke();
// dots at 1..12
for (let b=B0;b<=B1;b++){ const xx=X(b), yy=Y(clamp(throughput(b,S.snr),lo,hi)); x.fillStyle=(b===S.bits)?accent:css("--faint"); x.beginPath(); x.arc(xx,yy,(b===S.bits)?4.5:2.2,0,7); x.fill(); }
// spec optimum marker
const sb=specOptimalBits(S.snr);
x.strokeStyle=good; x.setLineDash([3,3]); x.beginPath(); x.moveTo(X(sb),padT); x.lineTo(X(sb),h-padB); x.stroke(); x.setLineDash([]);
x.fillStyle=good; x.textAlign="center"; x.textBaseline="top"; x.font="9px "+css("--mono").split(",")[0];
x.fillText("spec opt", X(sb), padT-1);
x.fillStyle=muted; x.textBaseline="top"; x.textAlign="center"; x.font="10px "+css("--mono").split(",")[0];
for (const b of [1,5,9,12]) x.fillText(b, X(b), h-padB+5);
}
/* ---------- DETECTOR sparkline (live) ---------- */
const det = $("detector"); const buf = new Array(120).fill(0);
let detPhase = 0, spikeUntil = 0;
function stepDetector(t) {
// simulate sporadic sensing bursts
if (!reduce) {
if (t > spikeUntil && Math.random() < 0.006) spikeUntil = t + 2600 + Math.random()*3000;
const active = t < spikeUntil;
const base = active ? 6.5 + Math.sin(t/180)*2.2 : 1.0 + Math.sin(t/400)*0.6;
const val = clamp(base + (Math.random()-0.5)*1.2, 0, 12);
buf.push(val); buf.shift();
S.sensing = val;
}
const now = S.sensing;
$("detNow").textContent = now.toFixed(1)+" Hz";
const wasEng = S._eng;
S._eng = engaged();
if (S._eng !== wasEng) { refreshStatus(); }
drawDetector();
}
function drawDetector() {
const { x, w, h } = fit(det);
x.clearRect(0,0,w,h);
const padB=4, gh=h-padB-4, top=4, maxV=12;
const accent=css("--accent"), threat=css("--threat"), grid=css("--grid");
const Y=v=>top+(1-v/maxV)*gh, X=i=>i/(buf.length-1)*w;
// threshold
x.strokeStyle=threat; x.setLineDash([4,4]); x.lineWidth=1; x.beginPath(); x.moveTo(0,Y(5)); x.lineTo(w,Y(5)); x.stroke(); x.setLineDash([]);
// area
const active = S.sensing>=5, colr = active?threat:accent;
x.beginPath(); x.moveTo(0,h);
for (let i=0;i<buf.length;i++) x.lineTo(X(i), Y(buf[i]));
x.lineTo(w,h); x.closePath();
x.fillStyle = colr+"22"; x.fill();
x.beginPath();
for (let i=0;i<buf.length;i++){ const xx=X(i),yy=Y(buf[i]); i===0?x.moveTo(xx,yy):x.lineTo(xx,yy); }
x.strokeStyle=colr; x.lineWidth=2; x.lineJoin="round"; x.stroke();
// endpoint
const ex=X(buf.length-1), ey=Y(buf[buf.length-1]);
x.fillStyle=colr; x.beginPath(); x.arc(ex,ey,3.2,0,7); x.fill();
}
/* ---------- main loop ---------- */
let raf=0;
function loop(t){ drawHero(t); stepDetector(t); raf=requestAnimationFrame(loop); }
if (!reduce) raf=requestAnimationFrame(loop);
else { drawHero(0); drawDetector(); }
/* ---------- experiment ---------- */
function runExperiment() {
const on = reidOn(S.passes,S.N)*100, tp = throughput(S.bits,S.snr)*100, chance=1/S.N*100;
$("fOff").style.width="0%"; $("fOn").style.width="0%"; $("fTput").style.width="0%";
$("eOff").textContent="—"; $("eOn").textContent="—"; $("eTput").textContent="—"; $("verdict").textContent="";
const set=()=>{ $("fOff").style.width="100%"; $("fOn").style.width=on+"%"; $("fTput").style.width=tp+"%"; };
if (reduce) { set(); $("eOff").textContent="100.0%"; $("eOn").textContent=on.toFixed(1)+"%"; $("eTput").textContent=tp.toFixed(1)+"%"; showVerdict(on,chance,tp); return; }
setTimeout(set, 60);
animateNum($("eOff"),0,100,1100,v=>v.toFixed(1)+"%");
setTimeout(()=>animateNum($("eOn"),100,on,1100,v=>v.toFixed(1)+"%"),120);
animateNum($("eTput"),0,tp,1100,v=>v.toFixed(1)+"%");
setTimeout(()=>showVerdict(on,chance,tp),1180);
}
function showVerdict(on,chance,tp){
const pass = on <= chance*2+3 && tp >= 95;
$("verdict").innerHTML = pass
? '<span class="pass">✓ PASS</span> re-ID at chance · throughput ≥ 95% · energy 1.000× (compliant)'
: '<span style="color:var(--crit);font-weight:700">△ OUT OF SPEC</span> raise passes / bits to re-enter the chance band';
}
$("runBtn").addEventListener("click", runExperiment);
/* ---------- presets ---------- */
const PRE = {
scif:{N:64,passes:96,bits:5,snr:20}, board:{N:16,passes:96,bits:5,snr:25},
ward:{N:32,passes:96,bits:5,snr:15}, hotel:{N:48,passes:64,bits:5,snr:20},
};
$("presets").addEventListener("click", (e)=>{
const b=e.target.closest(".chip"); if(!b) return;
document.querySelectorAll("#presets .chip").forEach(c=>c.setAttribute("aria-pressed", c===b));
const p=PRE[b.dataset.p]; Object.assign(S,p);
$("sPass").value=p.passes; $("sBits").value=p.bits; $("sN").value=p.N; $("sSnr").value=p.snr;
refreshReadouts(true); refreshStatus();
});
/* ---------- sliders ---------- */
const bind=(id,key)=>{ $(id).addEventListener("input", e=>{ S[key]=+e.target.value; document.querySelectorAll("#presets .chip").forEach(c=>c.setAttribute("aria-pressed","false")); refreshReadouts(false); refreshStatus(); }); };
bind("sPass","passes"); bind("sBits","bits"); bind("sN","N"); bind("sSnr","snr");
/* ---------- mode ---------- */
function setMode(m){ S.mode=m; ["mAuto","mOn","mOff"].forEach(id=>$(id).setAttribute("aria-pressed", ($(id)===({auto:$("mAuto"),on:$("mOn"),off:$("mOff")})[m]))); refreshStatus(); refreshReadouts(false); }
$("mAuto").onclick=()=>setMode("auto"); $("mOn").onclick=()=>setMode("on"); $("mOff").onclick=()=>setMode("off");
/* ---------- theme ---------- */
$("themeBtn").addEventListener("click", ()=>{
const cur = document.documentElement.getAttribute("data-theme")
|| (matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
document.documentElement.setAttribute("data-theme", cur==="dark"?"light":"dark");
refreshReadouts(false); drawDetector();
});
matchMedia("(prefers-color-scheme: dark)").addEventListener?.("change", ()=>{ refreshReadouts(false); drawDetector(); });
/* ---------- onboarding: welcome, explainers, guided tour ---------- */
const backdrop=$("backdrop");
let lastFocus=null;
function openModal(html){
$("modalBody").innerHTML=html;
backdrop.hidden=false; document.body.style.overflow="hidden";
lastFocus=document.activeElement;
const f=backdrop.querySelector("button"); if(f) f.focus();
}
function closeModal(){ backdrop.hidden=true; document.body.style.overflow=""; if(lastFocus&&lastFocus.focus) lastFocus.focus(); }
backdrop.addEventListener("click", (e)=>{
if(e.target===backdrop){ closeModal(); return; }
const b=e.target.closest("[data-act]"); if(!b) return;
if(b.dataset.act==="close") closeModal();
else if(b.dataset.act==="tour"){ closeModal(); startTour(); }
});
const SHIELD='<svg viewBox="0 0 24 24" fill="none" stroke="var(--accent-2)" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l7 3v5c0 4.4-3 7.6-7 9-4-1.4-7-4.6-7-9V6l7-3z"/><path d="M8.5 12.2l2.2 2.2 4.8-4.8"/></svg>';
function welcomeHTML(){
return '<div class="modal-h"><span class="mglyph">'+SHIELD+'</span>'
+'<div><div class="eyebrow">Welcome</div><h3>Your Wi-Fi privacy shield</h3></div>'
+'<button class="m-close" data-act="close" aria-label="Close">×</button></div>'
+'<p>Ordinary Wi-Fi can quietly <b>recognise who is in a room</b> — no camera, no app, nothing you carry. VEIL scrambles that leak while your Wi-Fi keeps working normally. This console lets you <b>see it, tune it, and prove it</b>.</p>'
+'<ul class="points">'
+'<li><span class="pi">'+SHIELD+'</span><div><b>Watch it work</b><small>The panel up top shows people as dots. Shield on → they blur together and can\'t be told apart.</small></div></li>'
+'<li><span class="pi">%</span><div><b>Read the scorecard</b><small>Live numbers for how well you\'re hidden and how much Wi-Fi speed you keep.</small></div></li>'
+'<li><span class="pi">⚙</span><div><b>Tune with confidence</b><small>Sliders plus one-tap presets for real rooms — a ward, a boardroom, a secure facility.</small></div></li>'
+'</ul>'
+'<div class="modal-actions"><button class="btn" data-act="tour">Take the 30-second tour</button>'
+'<button class="btn ghost" data-act="close">Explore on my own</button></div>';
}
function openWelcome(){ openModal(welcomeHTML()); markSeen(); }
const EXPLAIN={
kpis:{t:"Your live scorecard",p:"Four numbers that update the moment you change a setting.",pts:[
["Re-ID · off","How often an eavesdropper picks the right person with no shield. 100% = they always win."],
["Re-ID · on","The same attacker with the shield running. You want this down at “chance” — pure guessing."],
["Throughput","How much of your normal Wi-Fi speed you keep. Above 95% is the goal."],
["Emission","Proof the shield only reshapes your own signal (energy in = energy out). It never jams."]]},
curve:{t:"Collapse curve",p:"How hard the shield scrambles the hidden identity fingerprint.",pts:[
["Left → right","More “mixing passes” means stronger scrambling."],
["Green band","The guessing floor — once the line drops in here, people are indistinguishable."],
["The dot","Your current setting. Drag Givens passes and watch it slide down."]]},
tput:{t:"Throughput optimum",p:"Privacy shouldn't cost much speed — this finds the sweet spot.",pts:[
["The curve","Wi-Fi speed kept for each feedback-detail setting (bits)."],
["Green dashed line","The best standards-allowed value. Higher isn't better — it just wastes airtime."]]},
detector:{t:"Sensing activity",p:"A live meter of how hard someone is probing the room.",pts:[
["The line","Detected sensing attempts per second."],
["Dashed threshold","Cross it and, in Auto mode, the shield switches itself on."]]},
controls:{t:"Shield controls",p:"Adjust the shield yourself, or let a preset do it.",pts:[
["Givens passes","Scrambling strength. More is safer — and essentially free."],
["Feedback resolution","Signal detail; the console marks the speed-optimal value."],
["Candidate identities","How many people could be present (sets the guessing floor)."],
["Link SNR","Signal quality of the room's Wi-Fi."],
["Presets","One tap configures everything for a room type — start here if unsure."]]},
experiment:{t:"Attacker vs. protector",p:"A one-tap proof of the whole thing.",pts:[
["Run experiment","Simulates a real eavesdropper twice — shield off, then on."],
["The bars","Their success crashes to chance while your speed stays high."],
["PASS","Means hidden, fast, and compliant — never jamming."]]},
};
function explainHTML(k){
const e=EXPLAIN[k]; if(!e) return "";
const rows=e.pts.map(p=>'<li><span class="pi"></span><div><b>'+p[0]+'</b><small>'+p[1]+'</small></div></li>').join("");
return '<div class="modal-h"><div><div class="eyebrow">What am I looking at?</div><h3>'+e.t+'</h3></div>'
+'<button class="m-close" data-act="close" aria-label="Close">×</button></div>'
+'<p>'+e.p+'</p><ul class="points">'+rows+'</ul>'
+'<div class="modal-actions"><button class="btn" data-act="close">Got it</button></div>';
}
document.addEventListener("click",(e)=>{ const ib=e.target.closest("[data-explain]"); if(ib) openModal(explainHTML(ib.dataset.explain)); });
$("helpBtn").addEventListener("click", openWelcome);
/* ----- guided tour ----- */
const STEPS=[
{el:"heroCard",t:"Watch the shield work",b:"Each dot is a person the room's Wi-Fi could secretly recognise. Tap On below and they blur into the centre — an eavesdropper can't tell them apart."},
{el:"kpisSec",t:"Your live scorecard",b:"“Re-ID · on” is the eavesdropper's success rate — you want it at chance (guessing). “Throughput” is the Wi-Fi speed you keep."},
{el:"curveCard",t:"Why it works",b:"More scrambling pushes the eavesdropper's success down into the green “guessing” band. The dot marks your setting."},
{el:"controlsCard",t:"Tune it — or don't",b:"These sliders fine-tune the shield. Not sure? Tap a room type under Deployment presets and it's all set for you."},
{el:"expCard",t:"Prove it",b:"Press Run experiment to simulate an attacker and watch the shield drop their success to chance while speed stays above 95%."},
];
const tour={active:false,i:0}; let focused=null;
function startTour(){ tour.active=true; tour.i=0; $("tourDim").hidden=false; $("tourDots").innerHTML=STEPS.map((_,i)=>'<i class="'+(i===0?'on':'')+'"></i>').join(""); showStep(); }
function clearFocus(){ if(focused){ focused.classList.remove("tour-focus"); focused=null; } }
function placeTip(){ const tip=$("tourTip"); if(tip.hidden||!focused) return; const r=focused.getBoundingClientRect(); tip.style.top=""; tip.style.bottom=""; if(r.top+r.height/2 < innerHeight*0.5) tip.style.bottom="14px"; else tip.style.top="14px"; }
function showStep(){
const s=STEPS[tour.i]; clearFocus();
focused=$(s.el); if(focused) focused.classList.add("tour-focus");
$("tourStep").textContent="Step "+(tour.i+1)+" of "+STEPS.length;
$("tourTitle").textContent=s.t; $("tourBody").textContent=s.b;
[...$("tourDots").children].forEach((d,i)=>d.classList.toggle("on",i===tour.i));
$("tourBack").disabled=tour.i===0;
$("tourNext").textContent=tour.i===STEPS.length-1?"Done":"Next";
$("tourTip").hidden=false;
if(focused) focused.scrollIntoView({behavior:reduce?"auto":"smooth",block:"center"});
if(reduce) placeTip(); else setTimeout(placeTip,320);
}
function endTour(){ tour.active=false; clearFocus(); $("tourDim").hidden=true; $("tourTip").hidden=true; markSeen(); }
$("tourNext").addEventListener("click",()=>{ tour.i>=STEPS.length-1 ? endTour() : (tour.i++, showStep()); });
$("tourBack").addEventListener("click",()=>{ if(tour.i>0){ tour.i--; showStep(); } });
$("tourSkip").addEventListener("click", endTour);
$("tourDim").addEventListener("click", endTour);
addEventListener("keydown",(e)=>{
if(e.key==="Escape"){ if(!backdrop.hidden) closeModal(); else if(tour.active) endTour(); }
else if(tour.active&&e.key==="ArrowRight"){ $("tourNext").click(); }
else if(tour.active&&e.key==="ArrowLeft"){ $("tourBack").click(); }
});
function markSeen(){ try{ localStorage.setItem("veil_seen","1"); }catch(_){} }
function seen(){ try{ return localStorage.getItem("veil_seen")==="1"; }catch(_){ return false; } }
/* ---------- init + resize ---------- */
let rt; addEventListener("resize", ()=>{ clearTimeout(rt); rt=setTimeout(()=>{ refreshReadouts(false); drawDetector(); if(reduce)drawHero(0); placeTip(); }, 120); });
refreshReadouts(false); refreshStatus();
if(!seen()) setTimeout(openWelcome, reduce?0:650);
// first-load flourish
if (!reduce) { animateNum($("kOff"),0,100,700,v=>v.toFixed(0)); setTimeout(()=>{$("kOff").innerHTML='100<small>%</small>';},720); setTimeout(runExperiment, 500); }
else { $("kOff").innerHTML='100<small>%</small>'; runExperiment(); }
})();
</script>