mirror of
https://github.com/ruvnet/RuView
synced 2026-08-11 20:41:44 +00:00
1c2b383075
Adopt "WiFi Veil" as the product name across all user-facing surfaces, keeping VEIL (Verifiable Emission-shaping for Identity-Leakage prevention) as the technical codename it's built on. Only prose, titles, descriptions, and the console UI change — no code identifiers, file names, crate/npm `name` fields, or the deterministic proof witness are touched, so `cargo test` and the C-core host test are unaffected. - Crate & research READMEs: title + defining line now "WiFi Veil (codename VEIL — …)". - Cargo.toml / package.json / plugin.json descriptions: "WiFi Veil …". - Console UI (veil-console.html): title, brand, and copy say "WiFi Veil". - Firmware tree (README, per-provider READMEs, BUILD/INTEGRATION/MEASUREMENT): "WiFi Veil protector/core/shield". - Harness manifest: recomputed SHA-256 digests for the four changed packaged files (README, package.json, CLAUDE.md, plugin.json) — all verified consistent. Co-Authored-By: claude-flow <ruv@ruv.net> Claude-Session: https://claude.ai/code/session_01WEXNqzs7UsfNFBcP5yW21p
981 lines
61 KiB
HTML
981 lines
61 KiB
HTML
<title>WiFi 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; }
|
||
|
||
/* ---- "how it protects you" explainer ---- */
|
||
.how-grid { display: grid; gap: 16px; grid-template-columns: 1fr; }
|
||
@media (min-width: 680px) { .how-grid { grid-template-columns: repeat(3, 1fr); } }
|
||
.how-step { display: flex; flex-direction: column; gap: 8px; }
|
||
.how-step .ic { width: 34px; height: 34px; border-radius: 10px; display: grid; place-items: center; border: 1px solid var(--line); background: var(--surface-2); }
|
||
.how-step .ic svg { width: 18px; height: 18px; }
|
||
.how-step h3 { font-size: 14px; font-weight: 650; letter-spacing: -0.01em; }
|
||
.how-step .eyebrow { font-family: var(--mono); font-size: 10px; letter-spacing: 0.14em; text-transform: uppercase; }
|
||
.how-step p { margin: 0; font-size: 13px; color: var(--muted); line-height: 1.55; }
|
||
.how-step p b { color: var(--ink); font-weight: 600; }
|
||
.how-threat .ic { border-color: color-mix(in srgb, var(--threat) 45%, var(--line)); background: var(--threat-soft); }
|
||
.how-threat .eyebrow { color: var(--threat); }
|
||
.how-shield .ic { border-color: color-mix(in srgb, var(--accent) 45%, var(--line)); background: var(--accent-soft); }
|
||
.how-shield .eyebrow { color: var(--accent-2); }
|
||
.how-honest .ic { border-color: color-mix(in srgb, var(--good) 45%, var(--line)); }
|
||
.how-honest .eyebrow { color: var(--good); }
|
||
|
||
/* ---- 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>WiFi Veil 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 & 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 <em>chance</em>.</h1>
|
||
<div class="kbig"><b id="heroReid">4.7%</b><small>re-id · 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>
|
||
|
||
<!-- HOW IT PROTECTS YOU -->
|
||
<section class="card col-span" style="animation-delay:.04s; margin:0">
|
||
<div class="head"><h2>How this protects you</h2><div class="head-r"><span class="sub">plain language</span><button class="info-btn" data-explain="protect" aria-label="More detail on how it protects you">?</button></div></div>
|
||
<div class="how-grid">
|
||
<div class="how-step how-threat">
|
||
<span class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="var(--threat)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/></svg></span>
|
||
<span class="eyebrow">The threat</span>
|
||
<p>Your Wi-Fi constantly sends the router fine signal details — <b>in the clear</b>. A stranger nearby can capture them and recognise <b>individual people by their radio "fingerprint"</b>: through walls, with no camera, and nothing on you.</p>
|
||
</div>
|
||
<div class="how-step how-shield">
|
||
<span class="ic"><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="eyebrow">The shield</span>
|
||
<p>WiFi Veil <b>scrambles that fingerprint</b> on every report with a secret twist only your own router can undo. An outside listener sees a <b>different scramble each time</b> and can't tie it to a person — their guess of "who's here" drops to <b>pure chance</b>.</p>
|
||
</div>
|
||
<div class="how-step how-honest">
|
||
<span class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="var(--good)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M9 12l2 2 4-4"/><path d="M12 3l7 3v5c0 4.4-3 7.6-7 9-4-1.4-7-4.6-7-9V6l7-3z"/></svg></span>
|
||
<span class="eyebrow">Kept honest</span>
|
||
<p>It shapes <b>only your own signal — it never jams</b>, and your Wi-Fi speed stays ~98%. It stops <b>outside snoops</b>, not the router you connect to. Figures here are <b>simulated (L0)</b>, pending real-hardware tests.</p>
|
||
</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 & 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 L0</b> from the reference model — not measured on hardware.
|
||
<div class="foot-links">
|
||
<a href="#" onclick="return false">ADR-288 · WiFi 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();
|
||
|
||
/* ---------- WiFi 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. WiFi 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={
|
||
protect:{t:"How this protects you",p:"Unauthorized Wi-Fi surveillance, and what WiFi Veil does about it — in plain terms.",pts:[
|
||
["What can spy on you","Since Wi-Fi 5, your device tells the router how to aim its signal by sending back “beamforming feedback” — sent unencrypted. Anyone in range can capture it passively and, from the tiny stable details, tell people apart (published work re-identifies individuals, detects occupancy through walls, even reads activity)."],
|
||
["Why you can't tell","There's no light, no app, no device on you, and the snoop only listens — so this happens silently. The Wi-Fi standard (802.11bf, 2025) added the sensing feature but no privacy protection."],
|
||
["What WiFi Veil changes","Your router already reshapes its signal legitimately. WiFi Veil adds a secret, per-session “twist” to that feedback (built from the same math the report already uses). Your own router shares the key and undoes it instantly; an outside listener sees a fresh random twist every session and can't average it into a stable fingerprint."],
|
||
["The result","Across sessions the snoop's identity guess collapses to chance (~1-in-N, no better than guessing), while your link keeps ~98% of its speed — because the twist preserves the signal's energy and the data beam is untouched."],
|
||
["Not jamming","WiFi Veil only shapes your own, standards-legal transmissions. It never floods the air or blocks anyone (that would be illegal jamming). The energy in = energy out meter proves it."],
|
||
["Honest limits","It defends against outside snoops — not the router you're connected to (that party holds the key). It doesn't yet hide coarse motion, and every number here is simulated (evidence level L0) until validated on real hardware."]]},
|
||
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>
|