openvibers/openvibe.vision Commits
Vision design pass: one input per task page, result under it, calmer home and face choices (#4)
Changes
@@@ -11,33 +11,33 @@ html, body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--tex.vi-wrap { max-width: 1180px; margin: 0 auto; padding: 0 16px 64px; }.vi-narrow { max-width: 620px; }.vi-page-pad { padding-top: clamp(28px, 6vw, 64px); }--.vi h2 { margin: 0; font-size: 1.2rem; font-weight: 800; letter-spacing: -.01em; }--.vi-kicker { margin: 0 0 4px; font-size: .74rem; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; color: var(--room-strong); }++.vi h2 { margin: 0; font-size: 1.375rem; font-weight: 800; letter-spacing: -.01em; line-height: 1.2; }++.vi-kicker { margin: 0 0 4px; font-size: .8125rem; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; color: var(--room-strong); }.vi-page-h { padding-top: clamp(20px, 4vw, 40px); margin-bottom: 22px; }--.vi-page-h h1, .vi-entry-h h1 { margin: 0; font-size: clamp(1.9rem, 5vw, 3rem); font-weight: 800; letter-spacing: -.03em; line-height: 1.06; overflow-wrap: anywhere; }--.vi-lede, .vi-summary { margin: 10px 0 0; max-width: 700px; color: var(--text-secondary, #96a7c2); line-height: 1.55; }--.vi-fine, .vi-muted { color: var(--text-muted, #7386a3); font-size: .88rem; line-height: 1.55; }++.vi-page-h h1, .vi-entry-h h1 { margin: 0; font-size: clamp(1.45rem, 4.4vw, 1.75rem); font-weight: 800; letter-spacing: -.02em; line-height: 1.12; overflow-wrap: anywhere; }++.vi-lede, .vi-summary { margin: 10px 0 0; max-width: 64ch; color: var(--text-secondary, #96a7c2); font-size: 1.0625rem; line-height: 1.55; }++.vi-fine, .vi-muted { color: var(--text-muted, #7386a3); font-size: .8125rem; line-height: 1.55; }.vi-section { margin-top: clamp(28px, 4vw, 44px); }.vi-head-row { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 12px; }.vi-count { color: var(--text-muted, #7386a3); font-size: .86rem; }.vi-more { display: inline-flex; align-items: center; gap: 2px; font-weight: 700; font-size: .9rem; text-decoration: none; }.vi-actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 18px; }--.vi-btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 42px; padding: 0 18px; border: 0; border-radius: 999px; background: var(--room-fill); color: var(--room-on) !important; font: inherit; font-weight: 700; font-size: .95rem; text-decoration: none; cursor: pointer; }++.vi-btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 44px; padding: 0 18px; border: 0; border-radius: 999px; background: var(--room-fill); color: var(--room-on) !important; font: inherit; font-weight: 700; font-size: .9375rem; text-decoration: none; cursor: pointer; }.vi-btn-quiet { background: transparent; color: var(--text-primary, #e6edf7) !important; border: 1px solid var(--border, #1f2d47); }.vi-btn-small { min-height: 34px; padding: 0 14px; font-size: .86rem; }.vi-btn.is-mine { background: color-mix(in srgb, var(--room-accent) 22%, transparent); color: var(--room-strong) !important; }.vi-btn:focus-visible { outline: 2px solid var(--room-accent); outline-offset: 2px; }--.vi-note { margin: 14px 0; padding: 11px 14px; border-radius: 14px; font-weight: 600; background: color-mix(in srgb, var(--room-accent) 12%, transparent); border: 1px solid color-mix(in srgb, var(--room-accent) 30%, transparent); }++.vi-note { margin: 14px 0; padding: 11px 14px; border-radius: 14px; font-weight: 600; font-size: .9375rem; background: color-mix(in srgb, var(--room-accent) 12%, transparent); border: 1px solid color-mix(in srgb, var(--room-accent) 30%, transparent); }.vi-note.is-bad { background: color-mix(in srgb, var(--error, #ef4444) 12%, transparent); border-color: color-mix(in srgb, var(--error, #ef4444) 35%, transparent); }.vi-empty { display: grid; justify-items: start; gap: 10px; padding: 22px; border-radius: 18px; border: 1px dashed var(--border, #1f2d47); color: var(--text-secondary, #96a7c2); }.vi-empty p { margin: 0; line-height: 1.5; }--.vi-empty h1 { margin: 0; font-size: 1.6rem; color: var(--text-primary, #e6edf7); }++.vi-empty h1 { margin: 0; font-size: 1.375rem; color: var(--text-primary, #e6edf7); }.vi-card { padding: 18px; border-radius: 18px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); }.vi-form { display: grid; gap: 14px; }--.vi-field { display: grid; gap: 6px; font-size: .82rem; font-weight: 700; color: var(--text-secondary, #96a7c2); min-width: 0; }--.vi-field input, .vi-field select, .vi-field textarea { width: 100%; min-height: 42px; padding: 9px 12px; border-radius: 12px; border: 1px solid var(--border, #1f2d47); background: var(--bg-input, var(--bg-primary, #0a0f1c)); color: var(--text-primary, #e6edf7); font: inherit; font-size: .95rem; font-weight: 500; }++.vi-field { display: grid; gap: 6px; font-size: .8125rem; font-weight: 700; color: var(--text-secondary, #96a7c2); min-width: 0; }++.vi-field input, .vi-field select, .vi-field textarea { width: 100%; min-height: 44px; padding: 9px 12px; border-radius: 12px; border: 1px solid var(--border, #1f2d47); background: var(--bg-input, var(--bg-primary, #0a0f1c)); color: var(--text-primary, #e6edf7); font: inherit; font-size: .9375rem; font-weight: 500; }.vi-field input:focus, .vi-field select:focus, .vi-field textarea:focus { outline: 2px solid var(--room-accent); outline-offset: 1px; border-color: transparent; }.vi-facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 10px; margin: 20px 0 0; }.vi-facts > div { padding: 12px 14px; border-radius: 14px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); min-width: 0; }@@@ -83,14 +83,14 @@ html.ov-motion .vi-race-box { animation: vi-pop .6s cubic-bezier(.2,.9,.3,1.2) b.vi-demo-check { display: grid; gap: 10px; }.vi-demo-page { position: relative; height: 120px; overflow: hidden; border-radius: 12px; border: 2px dashed color-mix(in srgb, var(--error, #ef4444) 55%, transparent); background: var(--bg-card, #0f1729); }.vi-demo-overflow { position: absolute; left: 12px; top: 14px; width: 140%; padding: 10px; border-radius: 8px; background: color-mix(in srgb, var(--room-accent) 22%, transparent); font-size: .8rem; }--.vi-demo-clip { position: absolute; left: 12px; bottom: 16px; width: 55%; white-space: nowrap; overflow: hidden; font-weight: 800; color: #8a8f98; }++.vi-demo-clip { position: absolute; left: 12px; bottom: 16px; width: 55%; white-space: nowrap; overflow: hidden; font-weight: 800; color: var(--text-primary, #e6edf7); }.vi-demo-check ul, .vi-warnings { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }.vi-demo-check li, .vi-finding { display: flex; flex-wrap: wrap; gap: 4px 10px; align-items: baseline; padding: 8px 12px; border-radius: 10px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); font-size: .86rem; }--.vi-demo-check li.is-bad b, .vi-finding b { color: var(--error-text, #f87171); }++.vi-demo-check li.is-bad b, .vi-finding b { color: color-mix(in srgb, var(--error, #ef4444) 68%, var(--text-primary, #e6edf7)); }.vi-demo-check li span, .vi-finding span { color: var(--text-secondary, #96a7c2); }/* Results */--.vi-result, .vi-answer-card, .vi-check { display: grid; gap: 12px; margin-top: 18px; padding: 18px; border-radius: 18px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); border-top: 4px solid var(--room-accent); }++.vi-result, .vi-answer-card { display: grid; gap: 12px; margin-top: 18px; padding: 18px; border-radius: 18px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); border-top: 4px solid var(--room-accent); }.vi-question { margin: 0; font-weight: 800; font-size: 1.05rem; }.vi-answer-text { margin: 0; font-size: 1.05rem; line-height: 1.6; white-space: pre-wrap; overflow-wrap: anywhere; }.vi-ocr-text, .vi-sample-text, .vi-code { margin: 0; padding: 14px; border-radius: 12px; background: var(--bg-primary, #0a0f1c); border: 1px solid var(--border, #1f2d47); font: 500 .86rem/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre-wrap; overflow-wrap: anywhere; max-height: 420px; overflow: auto; }@@@ -133,3 +133,137 @@ html[data-ov-motion=reduced] .vi-race-img path, html[data-ov-motion=reduced] .vi.vi-check > input[role=switch]:focus-visible { outline: 2px solid var(--room-accent); outline-offset: 2px; }.vi-check > input[role=switch]:disabled { opacity: .5; cursor: not-allowed; }@media (prefers-reduced-motion: reduce) { .vi-check > input[role=switch], .vi-check > input[role=switch]::after { transition: none; } }++++/* ── Clean by default (§9b): the task pages ─────────────────────────────────────────────────────────────────────++ One input, one result under it, examples a tap away. The tool column is deliberately narrow so the page reads++ down the middle on a desk and fills a phone without a horizontal scroll. */++.vi-tool { max-width: 760px; }++.vi-tool-h { padding-top: clamp(18px, 4vw, 32px); margin-bottom: 20px; }++.vi-tool-h .vi-lede { max-width: 58ch; }++.vi-tool-form { display: grid; gap: 16px; margin: 0; min-width: 0; }++.vi-drop { position: relative; display: grid; justify-items: center; align-content: center; gap: 6px; min-height: 170px; padding: 24px 20px; border: 2px dashed var(--border, #1f2d47); border-radius: 18px; background: color-mix(in srgb, var(--bg-card, #0f1729) 72%, transparent); color: var(--text-secondary, #96a7c2); text-align: center; cursor: pointer; transition: border-color .15s ease, background .15s ease; }++.vi-drop:hover { border-color: var(--room-accent); background: color-mix(in srgb, var(--room-accent) 6%, var(--bg-card, #0f1729)); }++.vi-drop:has(> input:focus-visible) { outline: 3px solid var(--room-accent); outline-offset: 3px; }++.vi-drop.is-drag { border-color: var(--room-accent); background: color-mix(in srgb, var(--room-accent) 12%, var(--bg-card, #0f1729)); }++.vi-drop-art { display: grid; place-items: center; width: 44px; height: 44px; margin-bottom: 4px; border-radius: 50%; color: var(--room-strong); background: color-mix(in srgb, var(--room-accent) 14%, transparent); }++.vi-drop-mark { width: 24px; height: 24px; }++.vi-drop b { font-size: 1.0625rem; font-weight: 700; color: var(--text-primary, #e6edf7); overflow-wrap: anywhere; }++.vi-drop-hint { font-size: .8125rem; color: var(--text-muted, #7386a3); }++.vi-drop-file { display: none; max-width: 100%; padding: 3px 10px; border-radius: 999px; font-size: .8125rem; font-weight: 700; color: var(--room-strong); background: color-mix(in srgb, var(--room-accent) 12%, transparent); overflow-wrap: anywhere; }++.vi-drop.is-chosen .vi-drop-file { display: inline-block; }++.vi-drop.is-chosen .vi-drop-hint { display: none; }++.vi-sr { position: absolute !important; width: 1px !important; height: 1px !important; padding: 0 !important; margin: -1px !important; overflow: hidden !important; clip: rect(0 0 0 0) !important; white-space: nowrap !important; border: 0 !important; }++.vi-examples { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; min-width: 0; }++.vi-examples-label { flex-basis: 100%; font-size: .8125rem; font-weight: 700; color: var(--text-muted, #7386a3); }++.vi-example { display: inline-flex; align-items: center; gap: 7px; min-height: 44px; max-width: 100%; padding: 0 14px; border: 1px solid var(--border, #1f2d47); border-radius: 999px; background: transparent; color: var(--text-primary, #e6edf7); font: inherit; font-size: .8125rem; font-weight: 600; text-decoration: none; cursor: pointer; overflow-wrap: anywhere; }++.vi-example:hover { border-color: var(--room-accent); }++.vi-example:focus-visible { outline: 2px solid var(--room-accent); outline-offset: 2px; }++.vi-example img { flex: none; width: auto; height: 26px; border-radius: 3px; object-fit: cover; }++.vi-searchrow { display: flex; gap: 10px; align-items: stretch; }++.vi-searchrow input { flex: 1; min-width: 0; min-height: 52px; padding: 10px 14px; border: 1px solid var(--border, #1f2d47); border-radius: 14px; background: var(--bg-input, var(--bg-primary, #0a0f1c)); color: var(--text-primary, #e6edf7); font: inherit; font-size: 1.0625rem; }++.vi-searchrow input:focus { outline: 2px solid var(--room-accent); outline-offset: 1px; }++.vi-searchrow .vi-btn { flex: none; min-width: 96px; }++.vi-tool-facts { margin-top: 16px; }++.vi-result-tool { margin-top: 24px; padding: 20px; border-radius: 18px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #0f1729); }++.vi-result-empty { border-style: dashed; background: color-mix(in srgb, var(--bg-card, #0f1729) 55%, transparent); }++.vi-result-tool h2 { margin: 0 0 8px; }++.vi-result-tool p { margin: 8px 0 0; color: var(--text-secondary, #96a7c2); line-height: 1.55; max-width: 62ch; }++.vi-result-tool .vi-ocr-text { margin-top: 12px; }++.vi-result-head { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px 12px; }++.vi-result-head h2 { margin: 0; }++.vi-status { display: inline-flex; align-items: center; min-height: 28px; padding: 0 10px; border-radius: 999px; font-size: .8125rem; font-weight: 700; color: var(--text-muted, #7386a3); background: color-mix(in srgb, var(--text-muted, #7386a3) 12%, transparent); }++.vi-status.is-on { color: var(--room-strong); background: color-mix(in srgb, var(--room-accent) 15%, transparent); }++.vi-matches, .vi-findings-list, .vi-steps { list-style: none; margin: 16px 0 0; padding: 0; display: grid; gap: 0; }++.vi-match, .vi-findings-list li { display: flex; align-items: baseline; gap: 12px; padding: 12px 0; border-top: 1px solid var(--border, #1f2d47); min-width: 0; }++.vi-match:first-child, .vi-findings-list li:first-child { border-top: 0; }++.vi-match-rank { flex: none; width: 22px; font-size: .8125rem; font-weight: 800; color: var(--text-muted, #7386a3); }++.vi-match-main { display: grid; gap: 2px; flex: 1; min-width: 0; }++.vi-match-main a, .vi-match-main b { font-weight: 700; color: var(--text-primary, #e6edf7); text-decoration: none; overflow-wrap: anywhere; }++.vi-match-main a:hover { text-decoration: underline; }++.vi-match-main small, .vi-findings-list small { color: var(--text-muted, #7386a3); font-size: .75rem; overflow-wrap: anywhere; }++.vi-match-score { flex: none; font-size: .8125rem; font-weight: 800; color: var(--room-strong); font-variant-numeric: tabular-nums; }++.vi-findings-list b { min-width: 7ch; font-size: .9375rem; }++.vi-findings-list span { flex: 1; color: var(--text-secondary, #96a7c2); font-size: .8125rem; }++.vi-steps { gap: 6px; }++.vi-steps li { position: relative; padding-left: 18px; color: var(--text-secondary, #96a7c2); font-size: .9375rem; line-height: 1.5; }++.vi-steps li::before { content: ''; position: absolute; left: 2px; top: .55em; width: 6px; height: 6px; border-radius: 50%; background: var(--room-accent); }++.vi-choices { display: flex; flex-wrap: wrap; gap: 8px; margin: 0; padding: 0; border: 0; min-width: 0; }++.vi-choices legend { width: 100%; margin-bottom: 2px; padding: 0; font-size: .9375rem; font-weight: 700; color: var(--text-primary, #e6edf7); }++.vi-choice { position: relative; display: inline-flex; min-width: 0; }++.vi-choice input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: pointer; }++.vi-choice span { display: inline-flex; align-items: center; min-height: 44px; padding: 0 14px; border: 1px solid var(--border, #1f2d47); border-radius: 999px; background: transparent; color: var(--text-primary, #e6edf7); font-size: .9375rem; font-weight: 600; }++.vi-choice input:checked + span { border-color: transparent; background: var(--room-fill); color: var(--room-on); }++.vi-choice input:focus-visible + span { outline: 2px solid var(--room-accent); outline-offset: 2px; }++.vi-options, .vi-disclose, .vi-api { margin-top: 4px; border-top: 1px solid var(--border, #1f2d47); padding-top: 10px; min-width: 0; }++.vi-options summary, .vi-disclose summary, .vi-api summary { display: flex; align-items: center; min-height: 44px; font-size: .9375rem; font-weight: 700; color: var(--text-primary, #e6edf7); cursor: pointer; }++.vi-options summary:focus-visible, .vi-disclose summary:focus-visible, .vi-api summary:focus-visible { outline: 2px solid var(--room-accent); outline-offset: 2px; }++.vi-options[open] summary, .vi-disclose[open] summary, .vi-api[open] summary { margin-bottom: 8px; }++.vi-options .vi-field-grid, .vi-options .vi-check { margin-top: 10px; }++.vi-api { margin-top: 32px; }++.vi-api-body { padding: 2px 0 6px; }++.vi-api-body p { margin: 0; }++.vi-more-row { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; margin-top: 16px; font-size: .9375rem; color: var(--text-muted, #7386a3); }++.vi-more-row a { font-weight: 700; }++.vi-tasklist { list-style: none; margin: 12px 0 0; padding: 0; border-top: 1px solid var(--border, #1f2d47); }++.vi-tasklist li { border-bottom: 1px solid var(--border, #1f2d47); }++.vi-tasklist a { display: flex; align-items: center; gap: 12px; min-height: 68px; padding: 12px 2px; color: inherit; text-decoration: none; }++.vi-tasklist a:focus-visible { outline: 2px solid var(--room-accent); outline-offset: 2px; }++.vi-task-txt { display: grid; gap: 2px; flex: 1; min-width: 0; }++.vi-task-txt b { font-size: 1.0625rem; font-weight: 700; color: var(--room-strong); overflow-wrap: anywhere; }++.vi-task-txt span { font-size: .9375rem; line-height: 1.45; color: var(--text-secondary, #96a7c2); overflow-wrap: anywhere; }++.vi-go { flex: none; color: var(--text-muted, #7386a3); font-size: 1.125rem; }++.vi-tasklist a:hover .vi-go { color: var(--room-strong); }++.vi-home .sc-room-hero h1 { font-size: clamp(1.6rem, 5vw, 1.75rem); line-height: 1.14; letter-spacing: -.02em; }++.vi-home .sc-room-lede { font-size: 1.0625rem; max-width: 58ch; }++.vi-home .sc-room-chips a { min-height: 44px; }++.vi-net li { background: transparent; border: 0; padding: 4px 0; }++.vi-net { gap: 10px 18px; }++.vi-net b { color: var(--text-primary, #e6edf7); }++.vi-signin { display: grid; gap: 12px; justify-items: start; margin: 0 0 20px; padding: 18px; border-radius: 18px; background: color-mix(in srgb, var(--room-accent) 8%, var(--bg-card, #0f1729)); border: 1px solid color-mix(in srgb, var(--room-accent) 24%, transparent); }++.vi-signin p { margin: 0; font-size: .9375rem; line-height: 1.5; }++.vi-purposes { list-style: none; margin: 0; padding: 0; border-top: 1px solid var(--border, #1f2d47); }++.vi-purpose { padding: 18px 0; border-bottom: 1px solid var(--border, #1f2d47); }++.vi-purpose-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }++.vi-purpose-head > div { min-width: 0; }++.vi-purpose h2 { margin: 0 0 4px; font-size: 1.0625rem; }++.vi-purpose p { margin: 0; color: var(--text-secondary, #96a7c2); font-size: .9375rem; line-height: 1.5; }++.vi-purpose .vi-fine { margin-top: 6px; }++.vi-purpose .vi-disclose { margin-top: 8px; }++.vi-people-note { margin-top: 28px; padding-top: 20px; border-top: 1px solid var(--border, #1f2d47); }++.vi-people-note h2 { margin-bottom: 6px; }++.vi-people-note p { color: var(--text-secondary, #96a7c2); line-height: 1.55; }++.vi-check { display: flex; align-items: center; gap: 9px; min-height: 44px; font-size: .9375rem; font-weight: 600; color: var(--text-secondary, #96a7c2); }++.vi-check input[type=checkbox]:not([role=switch]) { flex: none; width: 20px; height: 20px; margin: 0; accent-color: var(--room-accent); }++.vi-enrol { margin-top: 14px; padding-top: 14px; border-top: 1px dashed var(--border, #1f2d47); }++++@media (max-width: 520px) {++ .vi-searchrow { flex-direction: column; }++ .vi-searchrow .vi-btn { width: 100%; }++ .vi-drop { min-height: 154px; padding: 20px 14px; }++ .vi-result-tool { padding: 16px; }++ .vi-match, .vi-findings-list li { gap: 8px; }++ .vi-findings-list b { min-width: 0; }++}++@media (prefers-reduced-motion: reduce) { .vi-drop { transition: none; } }++++/* The task list stays a readable column on a desk; the scene table scrolls inside its stage on a phone. */++.vi-tasklist, .vi-more-row { max-width: 820px; }++.vi-demo-scroll { overflow-x: auto; -webkit-overflow-scrolling: touch; }++.vi-demo-table td, .vi-demo-table th { overflow-wrap: normal; }++.vi-demo-table th:nth-child(3), .vi-demo-table td:nth-child(3), .vi-demo-table th:nth-child(4), .vi-demo-table td:nth-child(4) { white-space: nowrap; }++@media (max-width: 520px) { .vi-tasks-wrap .vi-count { display: none; } }++++/* Disclosure rows: a clear chevron, and no second rule inside a purpose row. */++.vi-options summary, .vi-disclose summary, .vi-api summary { gap: 8px; }++.vi-options summary::after, .vi-disclose summary::after, .vi-api summary::after { content: ''; flex: none; width: 8px; height: 8px; margin-left: auto; border-right: 1.6px solid currentColor; border-bottom: 1.6px solid currentColor; transform: rotate(45deg) translateY(-2px); transition: transform .15s ease; }++.vi-options[open] summary::after, .vi-disclose[open] summary::after, .vi-api[open] summary::after { transform: rotate(-135deg) translateY(-2px); }++.vi-purpose .vi-disclose { border-top: 0; padding-top: 0; margin-top: 2px; }++.vi-purpose .vi-disclose summary { color: var(--room-strong); font-size: .8125rem; min-height: 44px; }++@media (prefers-reduced-motion: reduce) { .vi-options summary::after, .vi-disclose summary::after, .vi-api summary::after { transition: none; } }++.vi a.vi-example { color: var(--text-primary, #e6edf7); }++.vi a.vi-example:hover { color: var(--room-strong); }++.vi input::placeholder, .vi textarea::placeholder { color: var(--text-muted, #8c99b0); opacity: 1; }++.vi-status { color: var(--text-secondary, #a9b4c8); background: color-mix(in srgb, var(--text-secondary, #a9b4c8) 12%, transparent); }++.vi-home .sc-room-chips a { color: var(--text-primary, #eef1f6); }++.vi-home .sc-room-chips a:hover, .vi-home .sc-room-chips a:focus-visible { color: var(--text-primary, #eef1f6); border-color: var(--room-accent); }++.vi-status { color: var(--text-primary, #eef1f6); background: color-mix(in srgb, var(--text-primary, #eef1f6) 8%, transparent); }nnew file mode 100644BBinary files /dev/null and b/public/img/sample-receipt.png differnnew file mode 100644BBinary files /dev/null and b/public/img/sample-sign.png differ@@@ -1 +1,49 @@--/* OpenVibe.Vision enhancement layer (main session). The v1 forms and results are complete without JavaScript. */++/* OpenVibe.Vision enhancement layer. Every form is complete without JavaScript; this only makes the drop zone++ accept a dragged file and shows the chosen filename. No result is fetched or rendered here. */++(function () {++ 'use strict';++ if (!document.querySelectorAll) return;++ var zones = document.querySelectorAll('[data-vi-drop]');++ for (var i = 0; i < zones.length; i++) {++ (function (zone) {++ var input = zone.querySelector('input[type=file]');++ var name = zone.querySelector('[data-vi-file]');++ if (!input) return;++ function show() {++ var has = input.files && input.files.length;++ if (has) {++ if (name) name.textContent = input.files[0].name;++ zone.classList.add('is-chosen');++ } else {++ if (name) name.textContent = '';++ zone.classList.remove('is-chosen');++ }++ }++ input.addEventListener('change', show);++ function over(event) {++ event.preventDefault();++ event.stopPropagation();++ zone.classList.add('is-drag');++ }++ function out(event) {++ event.preventDefault();++ event.stopPropagation();++ zone.classList.remove('is-drag');++ }++ zone.addEventListener('dragenter', over);++ zone.addEventListener('dragover', over);++ zone.addEventListener('dragleave', out);++ zone.addEventListener('dragend', out);++ zone.addEventListener('drop', function (event) {++ over(event);++ zone.classList.remove('is-drag');++ var transfer = event.dataTransfer;++ if (!transfer || !transfer.files || !transfer.files.length) return;++ try {++ input.files = transfer.files;++ show();++ } catch (e) { /* older browsers keep the picker as the only way in */ }++ });++ })(zones[i]);++ }++})();@@@ -37,6 +37,9 @@ function publicPages() {return [{ path: '/', changefreq: 'daily', priority: 1.0, title: 'OpenVibe.Vision home', text: 'Ask a picture, compare models, read text with the local OCR engine, and check a page for UI faults.' },{ path: '/ask', changefreq: 'weekly', priority: 0.9, title: 'Ask a picture', text: 'Upload an image or paste a public URL and ask what it shows.' },++ { path: '/read', changefreq: 'weekly', priority: 0.9, title: 'Read text from a picture', text: 'Read a receipt, sign, label or screenshot with the local OCR engine; free, and the picture is not stored.' },++ { path: '/find', changefreq: 'weekly', priority: 0.8, title: 'Find a picture by words', text: 'Search only your own pictures by describing them; owner-checked on every read and never a public index.' },++ { path: '/watch', changefreq: 'weekly', priority: 0.8, title: 'Watch a camera still', text: 'Find a package, a delivery, a person or a car in a camera still with local object detection.' },{ path: '/ocr', changefreq: 'weekly', priority: 0.8, title: 'Read text from a picture', text: 'Local Tesseract OCR first, with an optional AI fallback for a signed-in person.' },{ path: '/compare', changefreq: 'weekly', priority: 0.8, title: 'Compare models side by side', text: 'The same image through local OCR and through OpenVibe.AI, with the answer, cost and latency side by side.' },{ path: '/ui-check', changefreq: 'weekly', priority: 0.9, title: 'Check a page for UI faults', text: 'Overflow, cut-off text, low contrast, overlap and layering faults from HTML, a URL or a screenshot.' },@@@ -70,7 +73,9 @@ function createDiscoveryRoutes(ctx) {{ title: 'Start here', links: [{ title: 'OpenVibe.Vision', url: abs('/') },{ title: 'Ask a picture', url: abs('/ask') },-- { title: 'Read text from a picture', url: abs('/ocr') },++ { title: 'Read text from a picture', url: abs('/read') },++ { title: 'Find your own picture', url: abs('/find') },++ { title: 'Watch a camera still', url: abs('/watch') },{ title: 'Compare models', url: abs('/compare') },{ title: 'The UI check', url: abs('/ui-check') },{ title: 'People and consent', url: abs('/people') },@@@ -18,6 +18,8 @@* /developers the API* 404*/++const fs = require('fs');++const path = require('path');const express = require('express');const multer = require('multer');const seo = require('openvibe-shared/seo');@@@ -32,13 +34,16 @@ const { send } = require('../render/layout');const { isStaff } = require('../identity/staff');const views = require('../views');const peopleViews = require('../views/people');++const toolViews = require('../views/tools');const PUBLIC_CACHE = 'public, max-age=60';++const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');++const SAMPLE_IMAGES = { receipt: 'sample-receipt.png', sign: 'sample-sign.png' };function createPageRoutes(ctx) {-- const { config, s, store, vision, ai, ocr, faces, fetchImpl } = ctx;++ const { config, s, store, vision, ai, ocr, faces, networkTools, fetchImpl } = ctx;const r = asyncRouter();-- const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: config.images.maxMb * 1024 * 1024, files: 1 } });++ const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: config.images.maxMb * 1024 * 1024, files: 2 } });r.use(express.urlencoded({ extended: false, limit: '2mb' }));const base = () => String(config.baseUrl).replace(/\/+$/, '');const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });@@@ -50,10 +55,39 @@ function createPageRoutes(ctx) {if (err.code === 'LIMIT_FILE_SIZE') return page(req, res, { title: 'That image is too large', body: raw(views.formError(`The image is larger than ${config.images.maxMb} MB.`)) }, 413);return next(err);});++ const uploadWatchMiddleware = (req, res, next) => upload.fields([{ name: 'image', maxCount: 1 }, { name: 'previous_image', maxCount: 1 }])(req, res, (err) => {++ if (!err) return next();++ if (err.code === 'LIMIT_FILE_SIZE') return page(req, res, { title: 'That image is too large', body: raw(toolViews.watchView({ problem: `The image is larger than ${config.images.maxMb} MB.` })) }, 413);++ return next(err);++ });const bodyOf = (req) => (req.body && typeof req.body === 'object' ? req.body : {});async function imageOf(req) {return await loadImage({ file: req.file && req.file.buffer, imageUrl: bodyOf(req).image_url, image: bodyOf(req).image, config, fetchImpl });}++ // The two sample pictures are served from public/img and read here on the server, so a person can tap a real++ // example without an upload and without the image URL fetch (which refuses private hosts).++ async function sampleImage(name) {++ if (!Object.prototype.hasOwnProperty.call(SAMPLE_IMAGES, name)) return null;++ const file = SAMPLE_IMAGES[name];++ return await loadImage({ file: await fs.promises.readFile(path.join(PUBLIC_DIR, 'img', file)), config, fetchImpl });++ }++ async function toolImage(req) {++ const example = bodyOf(req).example;++ if (typeof example === 'string' && Object.prototype.hasOwnProperty.call(SAMPLE_IMAGES, example)) return await sampleImage(example);++ return await imageOf(req);++ }++ // A person gets a plain sentence, not a model filename or an npm command. Operators still have the API's++ // machine code and the server log.++ const friendly = (err, fallback) => {++ if (!(err instanceof VisionError)) return fallback;++ if (['vision.detector_unavailable', 'vision.clip_unavailable', 'vision.ocr_unavailable', 'vision.model_unavailable'].includes(err.code)) {++ return 'The local engine for this tool is not installed on this host yet. Nothing was stored.';++ }++ if (err.code === 'vision.previous_required') return 'Add a previous still to compare a scene change.';++ if (err.code === 'vision.invalid_classes') return 'Choose between one and twenty things to watch for.';++ if (err.code === 'vision.ai_unavailable') return 'The AI fallback is not configured on this host. The local reader still works.';++ return err.detail || fallback;++ };const viewportOf = (req) => {const b = bodyOf(req);return { w: Math.max(160, Math.min(10000, Number(b.viewport_w) || 1280)), h: Math.max(160, Math.min(10000, Number(b.viewport_h) || 800)) };@@@ -92,13 +126,111 @@ function createPageRoutes(ctx) {catch (err) { return page(req, res, { title: 'People', body: raw(views.formError(err.detail || err.message || 'Enrolment failed.')) }, err.status || 400); }});-- const toolPage = (req, res, title, intro, fields) => page(req, res, {-- title, cache: PUBLIC_CACHE,-- body: html`<main class="vi-wrap vi-page-pad vi-narrow"><header class="vi-section-head"><h1>${title}</h1><p class="vi-lede">${intro}</p></header><div class="vi-panel"><p class="vi-fine">These tools are available through Vision's JSON API. Sign in and send an image as multipart <code>image</code> or a data URL.</p>${fields}</div></main>`,++ // ── The three task pages ─────────────────────────────────++ // Each is one input and its result. /read works for anyone (local OCR); /find searches only the signed-in++ // person's own index; /watch is open object detection with no templates and no identity.++ const toolMeta = (title, pagePath, description) => ({++ title, cache: PUBLIC_CACHE, description,++ jsonLd: [seo.jsonLd.webPage({ name: title, url: `${base()}${pagePath}`, description, siteUrl: base() })],++ });++++ r.get('/find', async (req, res) => {++ const q = String(req.query.q || '').slice(0, 500);++ let result = null; let problem = null;++ if (q && signedIn(req)) {++ try { result = await networkTools.find(req.viewer.subject, q); }++ catch (err) { problem = friendly(err, 'Those pictures could not be searched just now.'); }++ }++ return page(req, res, {++ ...toolMeta('Find a picture by words', '/find', 'Search only your own pictures by describing them. Private results, owner-checked on every read.'),++ body: raw(toolViews.find({ signedIn: Boolean(signedIn(req)), q, result, problem })),++ });++ });++ r.post('/find', uploadMiddleware, async (req, res) => {++ if (!signedIn(req)) return page(req, res, { title: 'Sign in', body: raw(views.signIn({ title: 'Find a picture', text: 'Searching your own pictures needs an OpenVibe sign-in.', next: '/find' })) }, 401);++ if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);++ try {++ const out = await networkTools.find(req.viewer.subject, await imageOf(req));++ return page(req, res, {++ ...toolMeta('Find a picture by words', '/find', 'Search only your own pictures by describing them. Private results, owner-checked on every read.'),++ body: raw(toolViews.find({ signedIn: true, result: out, byImage: true })),++ });++ } catch (err) {++ return page(req, res, {++ ...toolMeta('Find a picture by words', '/find', 'Search only your own pictures by describing them. Private results, owner-checked on every read.'),++ body: raw(toolViews.find({ signedIn: true, problem: friendly(err, 'That picture could not be searched.') })),++ }, err instanceof VisionError ? err.status : 500);++ }++ });++++ r.get('/read', (req, res) => page(req, res, {++ ...toolMeta('Read text from a picture', '/read', 'Read receipts, signs, screenshots and labels with the local OCR engine. Free, and the picture is not stored.'),++ body: raw(toolViews.readView({ signedIn: Boolean(signedIn(req)), ai: ai.enabled })),++ }));++ r.post('/read', uploadMiddleware, async (req, res) => {++ if (signedIn(req) && !sameOrigin(req, config.baseUrl)) return notFromHere(req, res);++ const b = bodyOf(req);++ const fallback = b.fallback === '1' || b.fallback === 'true' || b.fallback === 'on';++ try {++ const image = await toolImage(req);++ const out = await vision.ocr({++ requester: signedIn(req) ? `user:${req.viewer.subject}` : 'anonymous',++ subject: signedIn(req) ? req.viewer.subject : null,++ image, lang: b.lang, fallback: Boolean(signedIn(req)) && fallback, model: b.model,++ });++ return page(req, res, {++ ...toolMeta('Read text from a picture', '/read', 'Read receipts, signs, screenshots and labels with the local OCR engine. Free, and the picture is not stored.'),++ body: raw(toolViews.readView({ signedIn: Boolean(signedIn(req)), ai: ai.enabled, result: out, values: { lang: b.lang } })),++ });++ } catch (err) {++ return page(req, res, {++ ...toolMeta('Read text from a picture', '/read', 'Read receipts, signs, screenshots and labels with the local OCR engine. Free, and the picture is not stored.'),++ body: raw(toolViews.readView({ signedIn: Boolean(signedIn(req)), ai: ai.enabled, problem: friendly(err, 'That picture could not be read.'), values: { lang: b.lang } })),++ }, err instanceof VisionError ? err.status : 500);++ }++ });++++ r.get('/watch', (req, res) => page(req, res, {++ ...toolMeta('Watch a camera still', '/watch', 'Drop a camera still and find a package, a delivery, a person or a car. Local detection; the picture is not stored.'),++ body: raw(toolViews.watchView({})),++ }));++ r.post('/watch', uploadWatchMiddleware, async (req, res) => {++ if (signedIn(req) && !sameOrigin(req, config.baseUrl)) return notFromHere(req, res);++ const b = bodyOf(req);++ const classes = [...new Set((Array.isArray(b.classes) ? b.classes : b.classes == null ? [] : [b.classes])++ .flatMap((x) => String(x).split(','))++ .map((x) => x.trim().toLowerCase())++ .filter(Boolean))].slice(0, 20);++ if (!classes.length) {++ return page(req, res, {++ ...toolMeta('Watch a camera still', '/watch', 'Drop a camera still and find a package, a delivery, a person or a car. Local detection; the picture is not stored.'),++ body: raw(toolViews.watchView({ problem: 'Choose at least one thing to watch for.', values: { classes } })),++ }, 400);++ }++ const files = req.files || {};++ const imageFile = files.image && files.image[0];++ if (!imageFile) {++ return page(req, res, {++ ...toolMeta('Watch a camera still', '/watch', 'Drop a camera still and find a package, a delivery, a person or a car. Local detection; the picture is not stored.'),++ body: raw(toolViews.watchView({ problem: 'Drop a camera still first.', values: { classes } })),++ }, 400);++ }++ try {++ const image = await loadImage({ file: imageFile.buffer, config, fetchImpl });++ const previousFile = files.previous_image && files.previous_image[0];++ const previous = previousFile ? await loadImage({ file: previousFile.buffer, config, fetchImpl }) : null;++ const out = await networkTools.watch(image, classes, previous);++ return page(req, res, {++ ...toolMeta('Watch a camera still', '/watch', 'Drop a camera still and find a package, a delivery, a person or a car. Local detection; the picture is not stored.'),++ body: raw(toolViews.watchView({ result: out, values: { classes } })),++ });++ } catch (err) {++ return page(req, res, {++ ...toolMeta('Watch a camera still', '/watch', 'Drop a camera still and find a package, a delivery, a person or a car. Local detection; the picture is not stored.'),++ body: raw(toolViews.watchView({ problem: friendly(err, 'That still could not be checked.'), values: { classes } })),++ }, err instanceof VisionError ? err.status : 500);++ }});-- r.get('/find', (req, res) => toolPage(req, res, 'Find in your images', 'Private visual search uses local CLIP embeddings and checks the owner on each read.', html`<form method="get" action="/find"><label class="vi-label" for="vi-find-q">Search words</label><input id="vi-find-q" name="q" value="${String(req.query.q || '').slice(0, 500)}"><button type="submit">Search</button></form><p class="vi-fine">Room call: GET /api/v1/find?q= with the owner token; POST /api/v1/index to add an image.</p>`));-- r.get('/read', (req, res) => toolPage(req, res, 'Read a receipt', 'Local Tesseract extracts store, line items and total; a routed fallback is optional.', html`<p class="vi-fine">Food calls POST /api/v1/receipt with the image and optional fallback=true. For plain text, use POST /api/v1/ocr.</p>`));-- r.get('/watch', (req, res) => toolPage(req, res, 'Watch a still', 'Detect selected objects or a scene change in an owner-supplied camera still.', html`<p class="vi-fine">Cam calls POST /api/v1/watch with the owner token, image and classes. Send previous_image for scene change.</p>`));// ── Ask ──────────────────────────────────────────────────r.get('/ask', (req, res) => page(req, res, {@@@ -21,9 +21,16 @@ function motif() {function home({ signedIn = false, ai = false } = {}) {const tryIt = `<section class="vi-section vi-tasks-wrap" id="vi-try" aria-labelledby="vi-try-h">--<div class="vi-head-row"><h2 id="vi-try-h">What do you want to do?</h2><span class="vi-count">${ai ? 'local engines first, then OpenVibe.AI' : 'local engines on this host'}</span></div>--<ul class="vi-tasks"><li><a class="vi-task" href="/ask"><b>Look</b><span>Ask anything about a picture: what it shows, how many, what it says.</span></a></li><li><a class="vi-task" href="/read"><b>Read</b><span>Receipts, signs, screenshots and documents, read on this host first, for nothing.</span></a></li><li><a class="vi-task" href="/ask"><b>Translate</b><span>A sign, a menu or a stream overlay, read and put into your language.</span></a></li><li><a class="vi-task" href="/find"><b>Find</b><span>Search your own pictures by words or by another picture. Only you see the results.</span></a></li><li><a class="vi-task" href="/watch"><b>Watch</b><span>Point it at a still from your camera and get told when a delivery, an animal or a change shows up.</span></a></li><li><a class="vi-task" href="/people"><b>People</b><span>Faces with your consent: verify yourself, find yourself, tag co-streamers who opted in, or just blur.</span></a></li><li><a class="vi-task" href="/compare"><b>Compare</b><span>One picture, several models, side by side, with what each answer cost and how long it took.</span></a></li><li><a class="vi-task" href="/ui-check"><b>Check a page</b><span>Overflow, clipped text, low contrast and overlap, found in HTML, a URL or a screenshot.</span></a></li></ul>--${signedIn ? '' : '<p class="vi-fine">Reading, blurring and the page check work without an account. Saving answers, your picture search and faces need <a href="/auth/login?next=%2F">an OpenVibe sign-in</a>.</p>'}++<div class="vi-head-row"><h2 id="vi-try-h">Pick one thing to do</h2><span class="vi-count">${ai ? 'local first, AI when it helps' : 'local engines on this host'}</span></div>++<ul class="vi-tasklist">++<li><a href="/ask"><span class="vi-task-txt"><b>Look at a picture</b><span>Ask what it shows, read it, translate it or count something.</span></span><span class="vi-go" aria-hidden="true">→</span></a></li>++<li><a href="/read"><span class="vi-task-txt"><b>Read a receipt or a sign</b><span>The words on this host, for nothing, with the picture not stored.</span></span><span class="vi-go" aria-hidden="true">→</span></a></li>++<li><a href="/find"><span class="vi-task-txt"><b>Find your own picture</b><span>Describe it and search only your pictures. Nobody else sees the results.</span></span><span class="vi-go" aria-hidden="true">→</span></a></li>++<li><a href="/watch"><span class="vi-task-txt"><b>Watch a camera still</b><span>Name a package, a delivery, a person or a car in a picture.</span></span><span class="vi-go" aria-hidden="true">→</span></a></li>++<li><a href="/people"><span class="vi-task-txt"><b>Choose what faces may do</b><span>Four separate choices, each off until you sign it. Never a stranger lookup.</span></span><span class="vi-go" aria-hidden="true">→</span></a></li>++</ul>++<div class="vi-more-row"><span>Need more?</span> <a href="/compare">Compare models</a> <span aria-hidden="true">·</span> <a href="/ui-check">Check a page</a> <span aria-hidden="true">·</span> <a href="/ocr">Raw OCR</a></div>++${signedIn ? '' : '<p class="vi-fine">Reading, watching and the page check work without an account. Saving answers, your picture search and faces need <a href="/auth/login?next=%2F">an OpenVibe sign-in</a>.</p>'}<p class="vi-fine"><a href="/people">Your face choices</a> · <a href="/people/privacy">How faces are kept</a></p></section><section class="vi-section vi-net-wrap" aria-labelledby="vi-net-h"><div class="vi-head-row"><h2 id="vi-net-h">Works across OpenVibe</h2><a class="vi-more" href="/developers">The API</a></div>@@@ -32,12 +39,12 @@ ${signedIn ? '' : '<p class="vi-fine">Reading, blurring and the page check workroom: 'vision',hero: {title: 'See it. Use it everywhere.',-- lede: 'Point Vision at a picture and do something with it: read a receipt into your pantry, find your own photos by words, get told when your camera sees a delivery, or check a page before it ships. Every answer says which model saw it, what it cost and what was kept.',++ lede: 'Point Vision at a picture and get something done: read a receipt, find your own photo by words, or get told when your camera sees a delivery. Local engines answer first, and every answer says what it cost.',start: { href: '/ask', label: 'Look at a picture' },-- chips: [{ label: 'Read', href: '/read' }, { label: 'Find', href: '/find' }, { label: 'Watch', href: '/watch' }, { label: 'People', href: '/people' }, { label: 'The API', href: '/developers' }],-- proof: [{ value: '0.0000', label: 'USD for a local read' }, { value: '8', label: 'tools, one API' }],++ chips: [{ label: 'Read', href: '/read' }, { label: 'Find', href: '/find' }, { label: 'Watch', href: '/watch' }, { label: 'People', href: '/people' }],motif: motif(),},++ proof: [{ value: '0.0000', label: 'USD for a local read' }, { value: '8', label: 'tools, one place' }],scenes: [{title: 'A receipt, read for nothing',@@@ -48,7 +55,7 @@ ${signedIn ? '' : '<p class="vi-fine">Reading, blurring and the page check work{title: 'The same image, side by side',text: 'A compare sends one image to the local reader and to OpenVibe.AI. AI\'s router is the authority: the provider and model shown are the ones that answered, never the hint. Cost comes from AI\'s own run, latency is measured here.',-- html: `<table class="vi-demo-table"><caption>one image, three answers</caption><thead><tr><th scope="col">engine</th><th scope="col">answer</th><th scope="col">cost</th><th scope="col">time</th></tr></thead><tbody><tr><td>local OCR</td><td>“TOTAL 5.50”</td><td>0.0000</td><td>38 ms</td></tr><tr><td>pool:fast</td><td>a receipt from a market</td><td>0.0004</td><td>1.1 s</td></tr><tr><td>pool:best</td><td>the total is 5.50</td><td>0.0012</td><td>2.4 s</td></tr></tbody></table>`,++ html: `<div class="vi-demo-scroll"><table class="vi-demo-table"><caption>one image, three answers</caption><thead><tr><th scope="col">engine</th><th scope="col">answer</th><th scope="col">cost</th><th scope="col">time</th></tr></thead><tbody><tr><td>local OCR</td><td>“TOTAL 5.50”</td><td>0.0000</td><td>38 ms</td></tr><tr><td>pool:fast</td><td>a receipt from a market</td><td>0.0004</td><td>1.1 s</td></tr><tr><td>pool:best</td><td>the total is 5.50</td><td>0.0012</td><td>2.4 s</td></tr></tbody></table></div>`,action: { href: '/compare', label: 'Run a compare' },},{@@@ -1,26 +1,59 @@'use strict';const { esc } = require('../render/html');--const LABELS = { verify: 'Verify yourself', find: 'Find yourself', cast: 'Cast tagging', library: "Your library's people" };++const LABELS = {++ verify: 'Verify yourself',++ find: 'Find yourself',++ cast: 'Cast tagging',++ library: "Your library's people",++};++const PLAIN = {++ verify: 'Compare your own face with your own verification attempt. Nothing else is unlocked.',++ find: 'Search your own pictures for your face. The results are private links.',++ cast: 'Match only inside a channel whose adult co-streamers opted in.',++ library: 'Group the faces in your own library. Unknown faces stay unlabelled.',++};++++/**++ * Four face purposes, each with its own signed consent. A person can read every purpose before signing in; the++ * form and its signature fields sit one tap down in <details>, so the page is a short list of choices instead of a++ * wall of forms. The ids (vi-verify …) and the form actions are part of the page contract.++ */function people({ signedIn, consents = [], texts, regions = [] }) {const active = (f) => consents.find((c) => c.feature === f && !c.revoked_at);-- return `<div class="vi-wrap vi-page-pad vi-narrow" id="vi-people"><header class="vi-page-h"><p class="vi-kicker">Your choice</p><h1>People in Vision</h1><p class="vi-lede">Each purpose has its own consent. Face matching runs locally. Turning a feature off erases its template at once.</p></header>--${signedIn ? '' : '<p class="vi-note">Sign in with your Network account to manage face features.</p>'}++ return `<div class="vi-wrap vi-page-pad vi-narrow" id="vi-people">++<header class="vi-page-h"><p class="vi-kicker">Your choice</p><h1>Your face choices</h1><p class="vi-lede">Four purposes, four separate switches. Matching runs on this host, each purpose is off until you sign it, and turning one off erases its template at once.</p></header>++${signedIn ? '' : `<div class="vi-signin" id="vi-people-signin"><p>Sign in with your Network account to see or change your choices. You can read all four below first.</p><a class="vi-btn" href="/auth/login?next=%2Fpeople">Sign in with OpenVibe</a></div>`}++<ul class="vi-purposes">${Object.entries(texts).map(([feature, item]) => {-- const on = active(feature), scope = on?.scope || '', needed = on ? '' : ' required';-- return `<section class="vi-card vi-person-feature" id="vi-${feature}"><h2>${esc(LABELS[feature])}</h2><p>${esc(item.text)}</p>++ const on = active(feature), scope = on?.scope || '', needed = on ? '' : ' required';++ const enrol = on && feature !== 'library';++ const action = on ? 'Manage or turn off' : 'Read and turn on';++ return `<li class="vi-purpose" id="vi-${feature}">++<div class="vi-purpose-head"><div><h2>${esc(LABELS[feature])}</h2><p>${esc(PLAIN[feature] || item.text)}</p>${on ? `<p class="vi-fine">On${scope ? ` for ${esc(scope)}` : ''} · signed ${esc(on.text_version)}</p>` : ''}</div><span class="vi-status${on ? ' is-on' : ''}">${on ? 'On' : 'Off'}</span></div>++${signedIn ? `<details class="vi-disclose"><summary>${action}</summary><form class="vi-form" method="post" action="/people/consent/${feature}"><input type="hidden" name="text_version" value="${esc(item.version)}">-- ${feature === 'cast' ? `<label class="vi-field"><span>Named channel ID</span><input name="channel"${needed} value="${esc(scope)}" placeholder="live:your-channel"></label><label class="vi-check"><input type="checkbox" name="adult" value="true"${needed}> I confirm I am an adult co-streamer</label>` : ''}-- ${feature === 'library' ? `<label class="vi-field"><span>Region</span><input name="region"${needed} placeholder="US-IL" value="${esc(regions[0] || '')}"></label><p class="vi-fine">Available regions: ${esc(regions.join(', ') || 'none configured')}</p>` : ''}-- <label class="vi-field"><span>Signature (your typed name)</span><input name="signed_name" autocomplete="name"${needed} maxlength="160"></label>--<label class="vi-check"><input type="checkbox" role="switch" name="enabled" value="yes" ${on ? 'checked' : ''}${signedIn ? '' : ' disabled'}> ${on ? 'On' : 'Off'}</label>--<button class="vi-btn" type="submit"${signedIn ? '' : ' disabled'}>Save this choice</button></form>--${on && feature !== 'library' ? `<form method="post" action="/people/enrol/${feature}" enctype="multipart/form-data" class="vi-form"><input type="hidden" name="channel" value="${esc(scope)}"><label class="vi-field"><span>One face image for enrolment</span><input type="file" name="image" accept="image/*" required></label><button class="vi-btn vi-btn-quiet" type="submit">Enrol my face</button></form>` : ''}--${on ? `<p class="vi-fine">Granted ${esc(String(on.granted_at))}${scope ? ` for ${esc(scope)}` : ''} · consent ${esc(on.text_version)}</p>` : ''}</section>`;--}).join('')}--<p class="vi-fine">A face match cannot unlock an account by itself. Liveness is not checked yet; use another factor and a non-biometric route. <a href="/people/privacy">Read the retention and privacy rules.</a></p></div>`;++<p class="vi-fine">${esc(item.text)}</p>++${feature === 'cast' ? `<label class="vi-field"><span>Named channel ID</span><input name="channel"${needed} value="${esc(scope)}" placeholder="live:your-channel"></label><label class="vi-check"><input type="checkbox" name="adult" value="true"${needed}> <span>I confirm I am an adult co-streamer</span></label>` : ''}++${feature === 'library' ? `<label class="vi-field"><span>Region</span><input name="region"${needed} placeholder="US-IL" value="${esc(regions[0] || '')}"></label><p class="vi-fine">Available regions: ${esc(regions.join(', ') || 'none configured')}</p>` : ''}++<label class="vi-field"><span>Signature (your typed name)</span><input name="signed_name" autocomplete="name"${needed} maxlength="160"></label>++<label class="vi-check"><input type="checkbox" role="switch" name="enabled" value="yes" ${on ? 'checked' : ''}> <span>${on ? 'On' : 'Off'}</span></label>++<div class="vi-actions"><button class="vi-btn" type="submit">Save this choice</button></div>++</form>++${enrol ? `<form method="post" action="/people/enrol/${feature}" enctype="multipart/form-data" class="vi-form vi-enrol">++<input type="hidden" name="channel" value="${esc(scope)}">++<label class="vi-field"><span>One face image for enrolment</span><input type="file" name="image" accept="image/*" required></label>++<div class="vi-actions"><button class="vi-btn vi-btn-quiet" type="submit">Enrol my face</button></div>++</form>` : ''}++</details>` : ''}++</li>`;++ }).join('')}++</ul>++<section class="vi-people-note"><h2>What never happens</h2><p>No stranger identification. No public face index. No face or template sent to a cloud model, Search, model prompts or Bench. A match alone never unlocks anything: liveness is not checked yet, so use another factor and a non-biometric route.</p><p><a href="/people/privacy">Read the retention and privacy rules</a></p></section>++</div>`;}++function privacy() {return `<div class="vi-wrap vi-page-pad vi-narrow" id="vi-privacy"><header class="vi-page-h"><p class="vi-kicker">Privacy</p><h1>Faces stay in your chosen purpose</h1></header><section class="vi-card"><h2>What happens</h2><p>Detect, count and blur use local YuNet without identity templates or tracking. Verification compares only your enrolled template with your own attempt. Find yourself searches only your own Vision uploads. Cast tagging checks a named channel roster of consenting adults. Library groups are private, region gated, and unknown faces remain unlabelled.</p></section>nnew file mode 100644@@@ -0,0 +1,216 @@++'use strict';++++/**++ * The three task pages: /find, /read and /watch (plan 2026-10-28 §5.8/§5.8b).++ *++ * Each page is one job: one input or drop zone, the result directly under it, and examples a person can tap.++ * Everything is a plain server-rendered form, so it works without JavaScript; vision.js only adds drag-and-drop and++ * a filename on the drop zone. Power options sit inside a <details> one tap down, and the API notes live at the++ * bottom of the page instead of above the tool.++ */++const { esc } = require('../render/html');++++const UPLOAD_MARK = `<svg class="vi-drop-mark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 16V4m0 0L7.5 8.5M12 4l4.5 4.5"/><path d="M4 15v3.5A1.5 1.5 0 0 0 5.5 20h13a1.5 1.5 0 0 0 1.5-1.5V15"/></svg>`;++++function n(value, fallback = 0) {++ const x = Number(value);++ return Number.isFinite(x) ? x : fallback;++}++++function money(value) {++ const x = n(value);++ return `${x.toFixed(4)} USD`;++}++++function when(ms) {++ const x = n(ms);++ return x < 1000 ? `${Math.round(x)} ms` : `${(x / 1000).toFixed(1)} s`;++}++++function confidence(value) {++ if (value == null) return null;++ const x = Number(value);++ if (!Number.isFinite(x)) return null;++ return `${(x <= 1 ? x * 100 : x).toFixed(1)}%`;++}++++function facts(items) {++ return `<dl class="vi-facts vi-tool-facts">${items.filter((x) => x && x[1] != null && x[1] !== '').map(([dt, dd]) => `<div><dt>${esc(dt)}</dt><dd>${dd}</dd></div>`).join('')}</dl>`;++}++++function header({ kicker, title, lede }) {++ return `<header class="vi-page-h vi-tool-h"><p class="vi-kicker">${esc(kicker)}</p><h1>${esc(title)}</h1><p class="vi-lede">${esc(lede)}</p></header>`;++}++++function dropZone({ id, name = 'image', label = 'Drop a picture here', hint = 'or choose a file' }) {++ return `<label class="vi-drop" for="${esc(id)}" data-vi-drop>++<span class="vi-drop-art">${UPLOAD_MARK}</span>++<b>${esc(label)}</b>++<span class="vi-drop-hint">${esc(hint)}</span>++<span class="vi-drop-file" data-vi-file></span>++<input class="vi-sr" id="${esc(id)}" type="file" name="${esc(name)}" accept="image/png,image/jpeg,image/webp,image/gif,image/bmp,image/tiff,image/avif">++</label>`;++}++++function examples(links) {++ return `<div class="vi-examples"><span class="vi-examples-label">Try an example</span>${links.join('')}</div>`;++}++++function apiNote({ children }) {++ return `<details class="vi-api"><summary>Use this from the API</summary><div class="vi-api-body">${children}</div></details>`;++}++++function problemNote(problem) {++ return problem ? `<p class="vi-note is-bad" role="alert">${esc(problem)}</p>` : '';++}++++// ── Find ─────────────────────────────────────────────────────++++function find({ signedIn = false, q = '', result = null, problem = null, byImage = false } = {}) {++ const examplesHtml = examples([++ ['red bike by the pier', 'a dog on the sofa', 'the receipt from the market'].map((text) => `<a class="vi-example" href="/find?q=${encodeURIComponent(text)}">${esc(text)}</a>`).join(''),++ ]);++ const form = `<form class="vi-tool-form" id="vi-find-form" method="get" action="/find" role="search">++<label class="vi-field" for="vi-find-q"><span>Describe a picture you have</span></label>++<div class="vi-searchrow">++<input id="vi-find-q" name="q" type="search" value="${esc(q)}" placeholder="red bike by the pier" autocomplete="off" enterkeyhint="search" maxlength="500">++<button class="vi-btn" type="submit">Find it</button>++</div>++<p class="vi-fine">Words, not a hashtag. Vision compares your words with the pictures you have already added.</p>++</form>`;++++ let resultHtml;++ if (!signedIn) {++ resultHtml = `<section class="vi-result-tool vi-result-empty" id="vi-find-empty">++<h2>Your own pictures, never a public index</h2>++<p>Sign in to search the pictures you have added to Vision from Pics, Media or Search. Nobody else can see the query or the matches.</p>++<p><a class="vi-btn" href="/auth/login?next=%2Ffind">Sign in with OpenVibe</a></p>++<p class="vi-fine">No account needed to <a href="/read">read a picture</a> or <a href="/watch">watch a still</a>.</p>++</section>`;++ } else if (result) {++ const rows = Array.isArray(result.results) ? result.results : [];++ const receipt = result.receipt || {};++ resultHtml = `<section class="vi-result-tool" id="vi-find-result" aria-labelledby="vi-find-result-h">++<div class="vi-result-head"><h2 id="vi-find-result-h">${rows.length ? `${rows.length} ${rows.length === 1 ? 'match' : 'matches'}` : 'No matches yet'}</h2><span class="vi-status${rows.length ? ' is-on' : ''}">${esc(byImage ? 'by picture' : 'by words')}</span></div>++${rows.length ? `<ol class="vi-matches">${rows.slice(0, 30).map((m, i) => `<li class="vi-match">++<span class="vi-match-rank" aria-hidden="true">${i + 1}</span>++<span class="vi-match-main">${m.result_url ? `<a href="${esc(m.result_url)}" rel="noopener">Open this picture</a>` : `<b>Picture ${esc(m.item_id)}</b>`}<small>${esc(m.item_id)}</small></span>++<span class="vi-match-score">${Math.round(n(m.score) * 100)}%</span>++</li>`).join('')}</ol>` : `<p>Nothing in your pictures matches those words yet. Add pictures from <a href="https://openvibe.media">Pics or Media</a>, then search again.</p><p class="vi-fine">Your matches stay private; index entries are tied to your account.</p>`}++${facts([['Engine', esc(receipt.model || receipt.engine || 'local')], ['Time', esc(when(receipt.ms))], ['Cost', esc(money(receipt.cost_usd))]])}++<p class="vi-fine">${esc(receipt.retention || 'Only your own indexed pictures are searched; the query is not stored.')}</p>++</section>`;++ } else {++ resultHtml = `<section class="vi-result-tool vi-result-empty" id="vi-find-empty">++<h2>Your matches appear here</h2>++<p>Start with a few words. Vision looks only at the pictures you have added and returns private links, with the score and the time it took.</p>++<p class="vi-fine">Searches are private. Vision keeps no public picture index and never identifies people in your photos.</p>++</section>`;++ }++++ const byImageForm = signedIn ? `<details class="vi-disclose"><summary>Or find by another picture</summary>++<form class="vi-form" method="post" action="/find" enctype="multipart/form-data">++${dropZone({ id: 'vi-find-file', label: 'Drop the picture to match', hint: 'or choose a file' })}++<div class="vi-actions"><button class="vi-btn vi-btn-quiet" type="submit">Find similar pictures</button></div>++</form></details>` : '';++++ return `<div class="vi-wrap vi-tool" id="vi-find">++${header({ kicker: 'Find · private', title: 'Find a picture by words', lede: 'Search only your own pictures. Describe what you remember; no public index, no stranger identification.' })}++${problemNote(problem)}++${form}++${examplesHtml}++${resultHtml}++${byImageForm}++${apiNote({ children: `<p class="vi-fine">Pics, Media and Search call <code>POST /api/v1/index</code> to add a picture, then <code>GET /api/v1/find?q=</code> or <code>POST /api/v1/find</code> with an image. A person needs their own Network token; the owner is checked on every read.</p>` })}++</div>`;++}++++// ── Read ─────────────────────────────────────────────────────++++function readView({ signedIn = false, ai = false, problem = null, result = null, values = {} } = {}) {++ const v = { lang: 'eng', ...values };++ let resultHtml;++ if (result) {++ const conf = confidence(result.confidence);++ resultHtml = `<section class="vi-result-tool" id="vi-read-result" aria-labelledby="vi-read-result-h">++<div class="vi-result-head"><h2 id="vi-read-result-h">What it says</h2><span class="vi-status is-on">${esc(result.engine === 'ai' ? 'AI fallback' : 'local OCR')}</span></div>++<pre class="vi-ocr-text">${esc(result.text || 'No text was found.')}</pre>++${facts([['Engine', esc(/tesseract/i.test(String(result.model || '')) ? 'Tesseract 5' : (result.model || result.engine || 'Tesseract 5'))], ['Confidence', conf ? esc(conf) : null], ['Time', esc(when(result.latency_ms || result.ms))], ['Cost', esc(money(result.cost_usd))]])}++<p class="vi-fine">${esc(/[.!?]$/.test(String(result.reason || '')) ? result.reason : `${result.reason || 'The local reader answered first'}.`)} ${signedIn && result.id ? `Saved to <a href="/a/${encodeURIComponent(result.id)}">your answers</a>.` : 'This reading is shown here and not saved.'}</p>++</section>`;++ } else {++ resultHtml = `<section class="vi-result-tool vi-result-empty" id="vi-read-empty">++<h2>Your reading appears here</h2>++<p>Choose a photo above and press <b>Read it</b>. The words come back in reading order, with the engine, the confidence and the time.</p>++<ul class="vi-steps"><li>Receipts: store, lines and total.</li><li>Signs, labels and screenshots: the words as written.</li><li>Nothing is sent to a model unless you ask.</li></ul>++</section>`;++ }++ return `<div class="vi-wrap vi-tool" id="vi-read">++${header({ kicker: 'Read · local first', title: 'Read the words in a picture', lede: 'Receipts, signs, screenshots and labels, read on this host. Free, and the picture is not stored.' })}++${problemNote(problem)}++<form class="vi-tool-form" id="vi-read-form" method="post" action="/read" enctype="multipart/form-data">++${dropZone({ id: 'vi-read-file', label: 'Drop a picture to read', hint: 'or choose a file' })}++${examples([`<button class="vi-example" type="submit" name="example" value="receipt">A shop receipt</button>`, `<button class="vi-example" type="submit" name="example" value="sign">A station sign</button>`])}++<div class="vi-actions"><button class="vi-btn" type="submit">Read it</button><a class="vi-btn vi-btn-quiet" href="/ask">Ask about it instead</a></div>++<details class="vi-options"><summary>Language and AI fallback</summary>++<div class="vi-field-grid">++<label class="vi-field"><span>Language</span><input name="lang" type="text" value="${esc(v.lang)}" maxlength="15" pattern="[a-z]{3}(\\+[a-z]{3}){0,3}"></label>++</div>++<label class="vi-check"><input type="checkbox" name="fallback" value="1"><span>If the local reader finds no text, ask OpenVibe.AI${signedIn ? '' : ' (needs a sign-in)'}</span></label>++<p class="vi-fine">${ai ? 'The fallback runs only after the local pass, and only for a signed-in account.' : 'The AI fallback is off on this host; the local reader still works.'}</p>++</details>++</form>++${resultHtml}++${apiNote({ children: `<p class="vi-fine">Food calls <code>POST /api/v1/receipt</code> for store, line items and total. Plain text is <code>POST /api/v1/ocr</code>, with <code>fallback=1</code> only when a signed-in person asks for it.</p>` })}++</div>`;++}++++// ── Watch ────────────────────────────────────────────────────++++const WATCH_CLASSES = [++ ['package', 'A package'], ['delivery', 'A delivery'], ['person', 'A person'],++ ['dog', 'A dog'], ['cat', 'A cat'], ['car', 'A car'],++];++++function watchView({ problem = null, result = null, values = {} } = {}) {++ const selected = new Set((values.classes && values.classes.length ? values.classes : ['package', 'delivery']).map(String));++ const choices = WATCH_CLASSES.map(([value, label]) => `<label class="vi-choice"><input type="checkbox" name="classes" value="${esc(value)}"${selected.has(value) ? ' checked' : ''}><span>${esc(label)}</span></label>`).join('');++ let resultHtml;++ if (result) {++ const list = Array.isArray(result.detections) ? result.detections : [];++ const receipt = result.receipt || {};++ resultHtml = `<section class="vi-result-tool" id="vi-watch-result" aria-labelledby="vi-watch-result-h">++<div class="vi-result-head"><h2 id="vi-watch-result-h">${list.length ? `${list.length} ${list.length === 1 ? 'thing' : 'things'} found` : 'Nothing from your list'}</h2><span class="vi-status${list.length ? ' is-on' : ''}">on this host</span></div>++${list.length ? `<ul class="vi-findings-list">${list.map((d) => `<li><b>${esc(d.class)}</b><span>${confidence(d.confidence) ? `${esc(confidence(d.confidence))} confidence` : 'detected'}</span>${d.box ? `<small>box ${Math.round(Number(d.box.xmin) || 0)}, ${Math.round(Number(d.box.ymin) || 0)} to ${Math.round(Number(d.box.xmax) || 0)}, ${Math.round(Number(d.box.ymax) || 0)}</small>` : ''}</li>`).join('')}</ul>` : '<p>No watched object was found in this still. Try a closer picture, or add another thing to watch for.</p>'}++${facts([['Engine', esc(receipt.model || receipt.engine || 'local')], ['Time', esc(when(receipt.ms))], ['Cost', esc(money(receipt.cost_usd))]])}++<p class="vi-fine">The still is not stored. Vision finds the classes you chose; it does not identify people.</p>++</section>`;++ } else {++ resultHtml = `<section class="vi-result-tool vi-result-empty" id="vi-watch-empty">++<h2>What Vision sees appears here</h2>++<p>Drop a still from your camera. Vision names the things on your list, with a confidence for each, and says how long it took.</p>++<p class="vi-fine">This is object detection, not face recognition. There is no template and no tracking identity.</p>++</section>`;++ }++ return `<div class="vi-wrap vi-tool" id="vi-watch">++${header({ kicker: 'Watch · local', title: 'Watch a camera still', lede: 'Drop a still and see what is there: a delivery, a package, a person or a car. Runs on this host, and the picture is not stored.' })}++${problemNote(problem)}++<form class="vi-tool-form" id="vi-watch-form" method="post" action="/watch" enctype="multipart/form-data">++${dropZone({ id: 'vi-watch-file', label: 'Drop a camera still', hint: 'a photo or screenshot' })}++<fieldset class="vi-choices"><legend>What are you watching for?</legend>${choices}</fieldset>++<div class="vi-actions"><button class="vi-btn" type="submit">Check this still</button><a class="vi-btn vi-btn-quiet" href="/read">Read it instead</a></div>++<details class="vi-options"><summary>Scene change and weather</summary>++<div class="vi-field-grid">++<label class="vi-field"><span>Previous still (for scene change)</span><input type="file" name="previous_image" accept="image/png,image/jpeg,image/webp,image/gif,image/bmp,image/tiff,image/avif"></label>++<label class="vi-check"><input type="checkbox" name="classes" value="snow"${selected.has('snow') ? ' checked' : ''}><span>Snow</span></label>++<label class="vi-check"><input type="checkbox" name="classes" value="scene change"${selected.has('scene change') ? ' checked' : ''}><span>Scene change</span></label>++</div>++<p class="vi-fine">A scene change compares this still with the previous one. Snow and delivery use a local scene check.</p>++</details>++</form>++${resultHtml}++${apiNote({ children: `<p class="vi-fine">Cam calls <code>POST /api/v1/watch</code> with an owner token, an image and up to 20 <code>classes</code>; send <code>previous_image</code> for a scene change. The API returns detections with class, confidence and box, plus a receipt for the engine, time and cost.</p>` })}++</div>`;++}++++module.exports = { find, readView, watchView };
@@ -11,33 +11,33 @@ html, body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--tex
@@ -11,33 +11,33 @@ html, body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--tex
.vi-wrap { max-width: 1180px; margin: 0 auto; padding: 0 16px 64px; }
.vi-wrap { max-width: 1180px; margin: 0 auto; padding: 0 16px 64px; }
.vi-narrow { max-width: 620px; }
.vi-narrow { max-width: 620px; }
.vi-page-pad { padding-top: clamp(28px, 6vw, 64px); }
.vi-page-pad { padding-top: clamp(28px, 6vw, 64px); }
-.vi h2 { margin: 0; font-size: 1.2rem; font-weight: 800; letter-spacing: -.01em; }
-.vi-kicker { margin: 0 0 4px; font-size: .74rem; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; color: var(--room-strong); }
+.vi h2 { margin: 0; font-size: 1.375rem; font-weight: 800; letter-spacing: -.01em; line-height: 1.2; }
+.vi-kicker { margin: 0 0 4px; font-size: .8125rem; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; color: var(--room-strong); }
.vi-page-h { padding-top: clamp(20px, 4vw, 40px); margin-bottom: 22px; }
.vi-page-h { padding-top: clamp(20px, 4vw, 40px); margin-bottom: 22px; }
-.vi-page-h h1, .vi-entry-h h1 { margin: 0; font-size: clamp(1.9rem, 5vw, 3rem); font-weight: 800; letter-spacing: -.03em; line-height: 1.06; overflow-wrap: anywhere; }
-.vi-lede, .vi-summary { margin: 10px 0 0; max-width: 700px; color: var(--text-secondary, #96a7c2); line-height: 1.55; }
-.vi-fine, .vi-muted { color: var(--text-muted, #7386a3); font-size: .88rem; line-height: 1.55; }
+.vi-page-h h1, .vi-entry-h h1 { margin: 0; font-size: clamp(1.45rem, 4.4vw, 1.75rem); font-weight: 800; letter-spacing: -.02em; line-height: 1.12; overflow-wrap: anywhere; }
+.vi-lede, .vi-summary { margin: 10px 0 0; max-width: 64ch; color: var(--text-secondary, #96a7c2); font-size: 1.0625rem; line-height: 1.55; }
+.vi-fine, .vi-muted { color: var(--text-muted, #7386a3); font-size: .8125rem; line-height: 1.55; }
.vi-section { margin-top: clamp(28px, 4vw, 44px); }
.vi-section { margin-top: clamp(28px, 4vw, 44px); }
.vi-head-row { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
.vi-head-row { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
.vi-count { color: var(--text-muted, #7386a3); font-size: .86rem; }
.vi-count { color: var(--text-muted, #7386a3); font-size: .86rem; }
.vi-more { display: inline-flex; align-items: center; gap: 2px; font-weight: 700; font-size: .9rem; text-decoration: none; }
.vi-more { display: inline-flex; align-items: center; gap: 2px; font-weight: 700; font-size: .9rem; text-decoration: none; }
.vi-actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 18px; }
.vi-actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 18px; }
-.vi-btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 42px; padding: 0 18px; border: 0; border-radius: 999px; background: var(--room-fill); color: var(--room-on) !important; font: inherit; font-weight: 700; font-size: .95rem; text-decoration: none; cursor: pointer; }
+.vi-btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 44px; padding: 0 18px; border: 0; border-radius: 999px; background: var(--room-fill); color: var(--room-on) !important; font: inherit; font-weight: 700; font-size: .9375rem; text-decoration: none; cursor: pointer; }
.vi-btn-quiet { background: transparent; color: var(--text-primary, #e6edf7) !important; border: 1px solid var(--border, #1f2d47); }
.vi-btn-quiet { background: transparent; color: var(--text-primary, #e6edf7) !important; border: 1px solid var(--border, #1f2d47); }
.vi-btn-small { min-height: 34px; padding: 0 14px; font-size: .86rem; }
.vi-btn-small { min-height: 34px; padding: 0 14px; font-size: .86rem; }
.vi-btn.is-mine { background: color-mix(in srgb, var(--room-accent) 22%, transparent); color: var(--room-strong) !important; }
.vi-btn.is-mine { background: color-mix(in srgb, var(--room-accent) 22%, transparent); color: var(--room-strong) !important; }
.vi-btn:focus-visible { outline: 2px solid var(--room-accent); outline-offset: 2px; }
.vi-btn:focus-visible { outline: 2px solid var(--room-accent); outline-offset: 2px; }
-.vi-note { margin: 14px 0; padding: 11px 14px; border-radius: 14px; font-weight: 600; background: color-mix(in srgb, var(--room-accent) 12%, transparent); border: 1px solid color-mix(in srgb, var(--room-accent) 30%, transparent); }
+.vi-note { margin: 14px 0; padding: 11px 14px; border-radius: 14px; font-weight: 600; font-size: .9375rem; background: color-mix(in srgb, var(--room-accent) 12%, transparent); border: 1px solid color-mix(in srgb, var(--room-accent) 30%, transparent); }
.vi-note.is-bad { background: color-mix(in srgb, var(--error, #ef4444) 12%, transparent); border-color: color-mix(in srgb, var(--error, #ef4444) 35%, transparent); }
.vi-note.is-bad { background: color-mix(in srgb, var(--error, #ef4444) 12%, transparent); border-color: color-mix(in srgb, var(--error, #ef4444) 35%, transparent); }
.vi-empty { display: grid; justify-items: start; gap: 10px; padding: 22px; border-radius: 18px; border: 1px dashed var(--border, #1f2d47); color: var(--text-secondary, #96a7c2); }
.vi-empty { display: grid; justify-items: start; gap: 10px; padding: 22px; border-radius: 18px; border: 1px dashed var(--border, #1f2d47); color: var(--text-secondary, #96a7c2); }
.vi-empty p { margin: 0; line-height: 1.5; }
.vi-empty p { margin: 0; line-height: 1.5; }
-.vi-empty h1 { margin: 0; font-size: 1.6rem; color: var(--text-primary, #e6edf7); }
+.vi-empty h1 { margin: 0; font-size: 1.375rem; color: var(--text-primary, #e6edf7); }
.vi-card { padding: 18px; border-radius: 18px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); }
.vi-card { padding: 18px; border-radius: 18px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); }
.vi-form { display: grid; gap: 14px; }
.vi-form { display: grid; gap: 14px; }
-.vi-field { display: grid; gap: 6px; font-size: .82rem; font-weight: 700; color: var(--text-secondary, #96a7c2); min-width: 0; }
-.vi-field input, .vi-field select, .vi-field textarea { width: 100%; min-height: 42px; padding: 9px 12px; border-radius: 12px; border: 1px solid var(--border, #1f2d47); background: var(--bg-input, var(--bg-primary, #0a0f1c)); color: var(--text-primary, #e6edf7); font: inherit; font-size: .95rem; font-weight: 500; }
+.vi-field { display: grid; gap: 6px; font-size: .8125rem; font-weight: 700; color: var(--text-secondary, #96a7c2); min-width: 0; }
+.vi-field input, .vi-field select, .vi-field textarea { width: 100%; min-height: 44px; padding: 9px 12px; border-radius: 12px; border: 1px solid var(--border, #1f2d47); background: var(--bg-input, var(--bg-primary, #0a0f1c)); color: var(--text-primary, #e6edf7); font: inherit; font-size: .9375rem; font-weight: 500; }
.vi-field input:focus, .vi-field select:focus, .vi-field textarea:focus { outline: 2px solid var(--room-accent); outline-offset: 1px; border-color: transparent; }
.vi-field input:focus, .vi-field select:focus, .vi-field textarea:focus { outline: 2px solid var(--room-accent); outline-offset: 1px; border-color: transparent; }
.vi-facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 10px; margin: 20px 0 0; }
.vi-facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 10px; margin: 20px 0 0; }
.vi-facts > div { padding: 12px 14px; border-radius: 14px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); min-width: 0; }
.vi-facts > div { padding: 12px 14px; border-radius: 14px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); min-width: 0; }
@@ -83,14 +83,14 @@ html.ov-motion .vi-race-box { animation: vi-pop .6s cubic-bezier(.2,.9,.3,1.2) b
@@ -83,14 +83,14 @@ html.ov-motion .vi-race-box { animation: vi-pop .6s cubic-bezier(.2,.9,.3,1.2) b
.vi-demo-check { display: grid; gap: 10px; }
.vi-demo-check { display: grid; gap: 10px; }
.vi-demo-page { position: relative; height: 120px; overflow: hidden; border-radius: 12px; border: 2px dashed color-mix(in srgb, var(--error, #ef4444) 55%, transparent); background: var(--bg-card, #0f1729); }
.vi-demo-page { position: relative; height: 120px; overflow: hidden; border-radius: 12px; border: 2px dashed color-mix(in srgb, var(--error, #ef4444) 55%, transparent); background: var(--bg-card, #0f1729); }
.vi-demo-overflow { position: absolute; left: 12px; top: 14px; width: 140%; padding: 10px; border-radius: 8px; background: color-mix(in srgb, var(--room-accent) 22%, transparent); font-size: .8rem; }
.vi-demo-overflow { position: absolute; left: 12px; top: 14px; width: 140%; padding: 10px; border-radius: 8px; background: color-mix(in srgb, var(--room-accent) 22%, transparent); font-size: .8rem; }
-.vi-demo-clip { position: absolute; left: 12px; bottom: 16px; width: 55%; white-space: nowrap; overflow: hidden; font-weight: 800; color: #8a8f98; }
+.vi-demo-clip { position: absolute; left: 12px; bottom: 16px; width: 55%; white-space: nowrap; overflow: hidden; font-weight: 800; color: var(--text-primary, #e6edf7); }
.vi-demo-check ul, .vi-warnings { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
.vi-demo-check ul, .vi-warnings { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
.vi-demo-check li, .vi-finding { display: flex; flex-wrap: wrap; gap: 4px 10px; align-items: baseline; padding: 8px 12px; border-radius: 10px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); font-size: .86rem; }
.vi-demo-check li, .vi-finding { display: flex; flex-wrap: wrap; gap: 4px 10px; align-items: baseline; padding: 8px 12px; border-radius: 10px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); font-size: .86rem; }
-.vi-demo-check li.is-bad b, .vi-finding b { color: var(--error-text, #f87171); }
+.vi-demo-check li.is-bad b, .vi-finding b { color: color-mix(in srgb, var(--error, #ef4444) 68%, var(--text-primary, #e6edf7)); }
.vi-demo-check li span, .vi-finding span { color: var(--text-secondary, #96a7c2); }
.vi-demo-check li span, .vi-finding span { color: var(--text-secondary, #96a7c2); }
/* Results */
/* Results */
-.vi-result, .vi-answer-card, .vi-check { display: grid; gap: 12px; margin-top: 18px; padding: 18px; border-radius: 18px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); border-top: 4px solid var(--room-accent); }
+.vi-result, .vi-answer-card { display: grid; gap: 12px; margin-top: 18px; padding: 18px; border-radius: 18px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); border-top: 4px solid var(--room-accent); }
.vi-question { margin: 0; font-weight: 800; font-size: 1.05rem; }
.vi-question { margin: 0; font-weight: 800; font-size: 1.05rem; }
.vi-answer-text { margin: 0; font-size: 1.05rem; line-height: 1.6; white-space: pre-wrap; overflow-wrap: anywhere; }
.vi-answer-text { margin: 0; font-size: 1.05rem; line-height: 1.6; white-space: pre-wrap; overflow-wrap: anywhere; }
.vi-ocr-text, .vi-sample-text, .vi-code { margin: 0; padding: 14px; border-radius: 12px; background: var(--bg-primary, #0a0f1c); border: 1px solid var(--border, #1f2d47); font: 500 .86rem/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre-wrap; overflow-wrap: anywhere; max-height: 420px; overflow: auto; }
.vi-ocr-text, .vi-sample-text, .vi-code { margin: 0; padding: 14px; border-radius: 12px; background: var(--bg-primary, #0a0f1c); border: 1px solid var(--border, #1f2d47); font: 500 .86rem/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre-wrap; overflow-wrap: anywhere; max-height: 420px; overflow: auto; }
@@ -133,3 +133,137 @@ html[data-ov-motion=reduced] .vi-race-img path, html[data-ov-motion=reduced] .vi
@@ -133,3 +133,137 @@ html[data-ov-motion=reduced] .vi-race-img path, html[data-ov-motion=reduced] .vi
.vi-check > input[role=switch]:focus-visible { outline: 2px solid var(--room-accent); outline-offset: 2px; }
.vi-check > input[role=switch]:focus-visible { outline: 2px solid var(--room-accent); outline-offset: 2px; }
.vi-check > input[role=switch]:disabled { opacity: .5; cursor: not-allowed; }
.vi-check > input[role=switch]:disabled { opacity: .5; cursor: not-allowed; }
@media (prefers-reduced-motion: reduce) { .vi-check > input[role=switch], .vi-check > input[role=switch]::after { transition: none; } }
@media (prefers-reduced-motion: reduce) { .vi-check > input[role=switch], .vi-check > input[role=switch]::after { transition: none; } }
+
+/* ── Clean by default (§9b): the task pages ─────────────────────────────────────────────────────────────────────
+ One input, one result under it, examples a tap away. The tool column is deliberately narrow so the page reads
+ down the middle on a desk and fills a phone without a horizontal scroll. */
+.vi-tool { max-width: 760px; }
+.vi-tool-h { padding-top: clamp(18px, 4vw, 32px); margin-bottom: 20px; }
+.vi-tool-h .vi-lede { max-width: 58ch; }
+.vi-tool-form { display: grid; gap: 16px; margin: 0; min-width: 0; }
+.vi-drop { position: relative; display: grid; justify-items: center; align-content: center; gap: 6px; min-height: 170px; padding: 24px 20px; border: 2px dashed var(--border, #1f2d47); border-radius: 18px; background: color-mix(in srgb, var(--bg-card, #0f1729) 72%, transparent); color: var(--text-secondary, #96a7c2); text-align: center; cursor: pointer; transition: border-color .15s ease, background .15s ease; }
+.vi-drop:hover { border-color: var(--room-accent); background: color-mix(in srgb, var(--room-accent) 6%, var(--bg-card, #0f1729)); }
+.vi-drop:has(> input:focus-visible) { outline: 3px solid var(--room-accent); outline-offset: 3px; }
+.vi-drop.is-drag { border-color: var(--room-accent); background: color-mix(in srgb, var(--room-accent) 12%, var(--bg-card, #0f1729)); }
+.vi-drop-art { display: grid; place-items: center; width: 44px; height: 44px; margin-bottom: 4px; border-radius: 50%; color: var(--room-strong); background: color-mix(in srgb, var(--room-accent) 14%, transparent); }
+.vi-drop-mark { width: 24px; height: 24px; }
+.vi-drop b { font-size: 1.0625rem; font-weight: 700; color: var(--text-primary, #e6edf7); overflow-wrap: anywhere; }
+.vi-drop-hint { font-size: .8125rem; color: var(--text-muted, #7386a3); }
+.vi-drop-file { display: none; max-width: 100%; padding: 3px 10px; border-radius: 999px; font-size: .8125rem; font-weight: 700; color: var(--room-strong); background: color-mix(in srgb, var(--room-accent) 12%, transparent); overflow-wrap: anywhere; }
+.vi-drop.is-chosen .vi-drop-file { display: inline-block; }
+.vi-drop.is-chosen .vi-drop-hint { display: none; }
+.vi-sr { position: absolute !important; width: 1px !important; height: 1px !important; padding: 0 !important; margin: -1px !important; overflow: hidden !important; clip: rect(0 0 0 0) !important; white-space: nowrap !important; border: 0 !important; }
+.vi-examples { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; min-width: 0; }
+.vi-examples-label { flex-basis: 100%; font-size: .8125rem; font-weight: 700; color: var(--text-muted, #7386a3); }
+.vi-example { display: inline-flex; align-items: center; gap: 7px; min-height: 44px; max-width: 100%; padding: 0 14px; border: 1px solid var(--border, #1f2d47); border-radius: 999px; background: transparent; color: var(--text-primary, #e6edf7); font: inherit; font-size: .8125rem; font-weight: 600; text-decoration: none; cursor: pointer; overflow-wrap: anywhere; }
+.vi-example:hover { border-color: var(--room-accent); }
+.vi-example:focus-visible { outline: 2px solid var(--room-accent); outline-offset: 2px; }
+.vi-example img { flex: none; width: auto; height: 26px; border-radius: 3px; object-fit: cover; }
+.vi-searchrow { display: flex; gap: 10px; align-items: stretch; }
+.vi-searchrow input { flex: 1; min-width: 0; min-height: 52px; padding: 10px 14px; border: 1px solid var(--border, #1f2d47); border-radius: 14px; background: var(--bg-input, var(--bg-primary, #0a0f1c)); color: var(--text-primary, #e6edf7); font: inherit; font-size: 1.0625rem; }
+.vi-searchrow input:focus { outline: 2px solid var(--room-accent); outline-offset: 1px; }
+.vi-searchrow .vi-btn { flex: none; min-width: 96px; }
+.vi-tool-facts { margin-top: 16px; }
+.vi-result-tool { margin-top: 24px; padding: 20px; border-radius: 18px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #0f1729); }
+.vi-result-empty { border-style: dashed; background: color-mix(in srgb, var(--bg-card, #0f1729) 55%, transparent); }
+.vi-result-tool h2 { margin: 0 0 8px; }
+.vi-result-tool p { margin: 8px 0 0; color: var(--text-secondary, #96a7c2); line-height: 1.55; max-width: 62ch; }
+.vi-result-tool .vi-ocr-text { margin-top: 12px; }
+.vi-result-head { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px 12px; }
+.vi-result-head h2 { margin: 0; }
+.vi-status { display: inline-flex; align-items: center; min-height: 28px; padding: 0 10px; border-radius: 999px; font-size: .8125rem; font-weight: 700; color: var(--text-muted, #7386a3); background: color-mix(in srgb, var(--text-muted, #7386a3) 12%, transparent); }
+.vi-status.is-on { color: var(--room-strong); background: color-mix(in srgb, var(--room-accent) 15%, transparent); }
+.vi-matches, .vi-findings-list, .vi-steps { list-style: none; margin: 16px 0 0; padding: 0; display: grid; gap: 0; }
+.vi-match, .vi-findings-list li { display: flex; align-items: baseline; gap: 12px; padding: 12px 0; border-top: 1px solid var(--border, #1f2d47); min-width: 0; }
+.vi-match:first-child, .vi-findings-list li:first-child { border-top: 0; }
+.vi-match-rank { flex: none; width: 22px; font-size: .8125rem; font-weight: 800; color: var(--text-muted, #7386a3); }
+.vi-match-main { display: grid; gap: 2px; flex: 1; min-width: 0; }
+.vi-match-main a, .vi-match-main b { font-weight: 700; color: var(--text-primary, #e6edf7); text-decoration: none; overflow-wrap: anywhere; }
+.vi-match-main a:hover { text-decoration: underline; }
+.vi-match-main small, .vi-findings-list small { color: var(--text-muted, #7386a3); font-size: .75rem; overflow-wrap: anywhere; }
+.vi-match-score { flex: none; font-size: .8125rem; font-weight: 800; color: var(--room-strong); font-variant-numeric: tabular-nums; }
+.vi-findings-list b { min-width: 7ch; font-size: .9375rem; }
+.vi-findings-list span { flex: 1; color: var(--text-secondary, #96a7c2); font-size: .8125rem; }
+.vi-steps { gap: 6px; }
+.vi-steps li { position: relative; padding-left: 18px; color: var(--text-secondary, #96a7c2); font-size: .9375rem; line-height: 1.5; }
+.vi-steps li::before { content: ''; position: absolute; left: 2px; top: .55em; width: 6px; height: 6px; border-radius: 50%; background: var(--room-accent); }
+.vi-choices { display: flex; flex-wrap: wrap; gap: 8px; margin: 0; padding: 0; border: 0; min-width: 0; }
+.vi-choices legend { width: 100%; margin-bottom: 2px; padding: 0; font-size: .9375rem; font-weight: 700; color: var(--text-primary, #e6edf7); }
+.vi-choice { position: relative; display: inline-flex; min-width: 0; }
+.vi-choice input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: pointer; }
+.vi-choice span { display: inline-flex; align-items: center; min-height: 44px; padding: 0 14px; border: 1px solid var(--border, #1f2d47); border-radius: 999px; background: transparent; color: var(--text-primary, #e6edf7); font-size: .9375rem; font-weight: 600; }
+.vi-choice input:checked + span { border-color: transparent; background: var(--room-fill); color: var(--room-on); }
+.vi-choice input:focus-visible + span { outline: 2px solid var(--room-accent); outline-offset: 2px; }
+.vi-options, .vi-disclose, .vi-api { margin-top: 4px; border-top: 1px solid var(--border, #1f2d47); padding-top: 10px; min-width: 0; }
+.vi-options summary, .vi-disclose summary, .vi-api summary { display: flex; align-items: center; min-height: 44px; font-size: .9375rem; font-weight: 700; color: var(--text-primary, #e6edf7); cursor: pointer; }
+.vi-options summary:focus-visible, .vi-disclose summary:focus-visible, .vi-api summary:focus-visible { outline: 2px solid var(--room-accent); outline-offset: 2px; }
+.vi-options[open] summary, .vi-disclose[open] summary, .vi-api[open] summary { margin-bottom: 8px; }
+.vi-options .vi-field-grid, .vi-options .vi-check { margin-top: 10px; }
+.vi-api { margin-top: 32px; }
+.vi-api-body { padding: 2px 0 6px; }
+.vi-api-body p { margin: 0; }
+.vi-more-row { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; margin-top: 16px; font-size: .9375rem; color: var(--text-muted, #7386a3); }
+.vi-more-row a { font-weight: 700; }
+.vi-tasklist { list-style: none; margin: 12px 0 0; padding: 0; border-top: 1px solid var(--border, #1f2d47); }
+.vi-tasklist li { border-bottom: 1px solid var(--border, #1f2d47); }
+.vi-tasklist a { display: flex; align-items: center; gap: 12px; min-height: 68px; padding: 12px 2px; color: inherit; text-decoration: none; }
+.vi-tasklist a:focus-visible { outline: 2px solid var(--room-accent); outline-offset: 2px; }
+.vi-task-txt { display: grid; gap: 2px; flex: 1; min-width: 0; }
+.vi-task-txt b { font-size: 1.0625rem; font-weight: 700; color: var(--room-strong); overflow-wrap: anywhere; }
+.vi-task-txt span { font-size: .9375rem; line-height: 1.45; color: var(--text-secondary, #96a7c2); overflow-wrap: anywhere; }
+.vi-go { flex: none; color: var(--text-muted, #7386a3); font-size: 1.125rem; }
+.vi-tasklist a:hover .vi-go { color: var(--room-strong); }
+.vi-home .sc-room-hero h1 { font-size: clamp(1.6rem, 5vw, 1.75rem); line-height: 1.14; letter-spacing: -.02em; }
+.vi-home .sc-room-lede { font-size: 1.0625rem; max-width: 58ch; }
+.vi-home .sc-room-chips a { min-height: 44px; }
+.vi-net li { background: transparent; border: 0; padding: 4px 0; }
+.vi-net { gap: 10px 18px; }
+.vi-net b { color: var(--text-primary, #e6edf7); }
+.vi-signin { display: grid; gap: 12px; justify-items: start; margin: 0 0 20px; padding: 18px; border-radius: 18px; background: color-mix(in srgb, var(--room-accent) 8%, var(--bg-card, #0f1729)); border: 1px solid color-mix(in srgb, var(--room-accent) 24%, transparent); }
+.vi-signin p { margin: 0; font-size: .9375rem; line-height: 1.5; }
+.vi-purposes { list-style: none; margin: 0; padding: 0; border-top: 1px solid var(--border, #1f2d47); }
+.vi-purpose { padding: 18px 0; border-bottom: 1px solid var(--border, #1f2d47); }
+.vi-purpose-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
+.vi-purpose-head > div { min-width: 0; }
+.vi-purpose h2 { margin: 0 0 4px; font-size: 1.0625rem; }
+.vi-purpose p { margin: 0; color: var(--text-secondary, #96a7c2); font-size: .9375rem; line-height: 1.5; }
+.vi-purpose .vi-fine { margin-top: 6px; }
+.vi-purpose .vi-disclose { margin-top: 8px; }
+.vi-people-note { margin-top: 28px; padding-top: 20px; border-top: 1px solid var(--border, #1f2d47); }
+.vi-people-note h2 { margin-bottom: 6px; }
+.vi-people-note p { color: var(--text-secondary, #96a7c2); line-height: 1.55; }
+.vi-check { display: flex; align-items: center; gap: 9px; min-height: 44px; font-size: .9375rem; font-weight: 600; color: var(--text-secondary, #96a7c2); }
+.vi-check input[type=checkbox]:not([role=switch]) { flex: none; width: 20px; height: 20px; margin: 0; accent-color: var(--room-accent); }
+.vi-enrol { margin-top: 14px; padding-top: 14px; border-top: 1px dashed var(--border, #1f2d47); }
+
+@media (max-width: 520px) {
+ .vi-searchrow { flex-direction: column; }
+ .vi-searchrow .vi-btn { width: 100%; }
+ .vi-drop { min-height: 154px; padding: 20px 14px; }
+ .vi-result-tool { padding: 16px; }
+ .vi-match, .vi-findings-list li { gap: 8px; }
+ .vi-findings-list b { min-width: 0; }
+}
+@media (prefers-reduced-motion: reduce) { .vi-drop { transition: none; } }
+
+/* The task list stays a readable column on a desk; the scene table scrolls inside its stage on a phone. */
+.vi-tasklist, .vi-more-row { max-width: 820px; }
+.vi-demo-scroll { overflow-x: auto; -webkit-overflow-scrolling: touch; }
+.vi-demo-table td, .vi-demo-table th { overflow-wrap: normal; }
+.vi-demo-table th:nth-child(3), .vi-demo-table td:nth-child(3), .vi-demo-table th:nth-child(4), .vi-demo-table td:nth-child(4) { white-space: nowrap; }
+@media (max-width: 520px) { .vi-tasks-wrap .vi-count { display: none; } }
+
+/* Disclosure rows: a clear chevron, and no second rule inside a purpose row. */
+.vi-options summary, .vi-disclose summary, .vi-api summary { gap: 8px; }
+.vi-options summary::after, .vi-disclose summary::after, .vi-api summary::after { content: ''; flex: none; width: 8px; height: 8px; margin-left: auto; border-right: 1.6px solid currentColor; border-bottom: 1.6px solid currentColor; transform: rotate(45deg) translateY(-2px); transition: transform .15s ease; }
+.vi-options[open] summary::after, .vi-disclose[open] summary::after, .vi-api[open] summary::after { transform: rotate(-135deg) translateY(-2px); }
+.vi-purpose .vi-disclose { border-top: 0; padding-top: 0; margin-top: 2px; }
+.vi-purpose .vi-disclose summary { color: var(--room-strong); font-size: .8125rem; min-height: 44px; }
+@media (prefers-reduced-motion: reduce) { .vi-options summary::after, .vi-disclose summary::after, .vi-api summary::after { transition: none; } }
+.vi a.vi-example { color: var(--text-primary, #e6edf7); }
+.vi a.vi-example:hover { color: var(--room-strong); }
+.vi input::placeholder, .vi textarea::placeholder { color: var(--text-muted, #8c99b0); opacity: 1; }
+.vi-status { color: var(--text-secondary, #a9b4c8); background: color-mix(in srgb, var(--text-secondary, #a9b4c8) 12%, transparent); }
+.vi-home .sc-room-chips a { color: var(--text-primary, #eef1f6); }
+.vi-home .sc-room-chips a:hover, .vi-home .sc-room-chips a:focus-visible { color: var(--text-primary, #eef1f6); border-color: var(--room-accent); }
+.vi-status { color: var(--text-primary, #eef1f6); background: color-mix(in srgb, var(--text-primary, #eef1f6) 8%, transparent); }
new file mode 100644
new file mode 100644
Binary files /dev/null and b/public/img/sample-receipt.png differ
Binary files /dev/null and b/public/img/sample-receipt.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/public/img/sample-sign.png differ
Binary files /dev/null and b/public/img/sample-sign.png differ
@@ -1 +1,49 @@
@@ -1 +1,49 @@
-/* OpenVibe.Vision enhancement layer (main session). The v1 forms and results are complete without JavaScript. */
+/* OpenVibe.Vision enhancement layer. Every form is complete without JavaScript; this only makes the drop zone
+ accept a dragged file and shows the chosen filename. No result is fetched or rendered here. */
+(function () {
+ 'use strict';
+ if (!document.querySelectorAll) return;
+ var zones = document.querySelectorAll('[data-vi-drop]');
+ for (var i = 0; i < zones.length; i++) {
+ (function (zone) {
+ var input = zone.querySelector('input[type=file]');
+ var name = zone.querySelector('[data-vi-file]');
+ if (!input) return;
+ function show() {
+ var has = input.files && input.files.length;
+ if (has) {
+ if (name) name.textContent = input.files[0].name;
+ zone.classList.add('is-chosen');
+ } else {
+ if (name) name.textContent = '';
+ zone.classList.remove('is-chosen');
+ }
+ }
+ input.addEventListener('change', show);
+ function over(event) {
+ event.preventDefault();
+ event.stopPropagation();
+ zone.classList.add('is-drag');
+ }
+ function out(event) {
+ event.preventDefault();
+ event.stopPropagation();
+ zone.classList.remove('is-drag');
+ }
+ zone.addEventListener('dragenter', over);
+ zone.addEventListener('dragover', over);
+ zone.addEventListener('dragleave', out);
+ zone.addEventListener('dragend', out);
+ zone.addEventListener('drop', function (event) {
+ over(event);
+ zone.classList.remove('is-drag');
+ var transfer = event.dataTransfer;
+ if (!transfer || !transfer.files || !transfer.files.length) return;
+ try {
+ input.files = transfer.files;
+ show();
+ } catch (e) { /* older browsers keep the picker as the only way in */ }
+ });
+ })(zones[i]);
+ }
+})();
@@ -37,6 +37,9 @@ function publicPages() {
@@ -37,6 +37,9 @@ function publicPages() {
return [
return [
{ path: '/', changefreq: 'daily', priority: 1.0, title: 'OpenVibe.Vision home', text: 'Ask a picture, compare models, read text with the local OCR engine, and check a page for UI faults.' },
{ path: '/', changefreq: 'daily', priority: 1.0, title: 'OpenVibe.Vision home', text: 'Ask a picture, compare models, read text with the local OCR engine, and check a page for UI faults.' },
{ path: '/ask', changefreq: 'weekly', priority: 0.9, title: 'Ask a picture', text: 'Upload an image or paste a public URL and ask what it shows.' },
{ path: '/ask', changefreq: 'weekly', priority: 0.9, title: 'Ask a picture', text: 'Upload an image or paste a public URL and ask what it shows.' },
+ { path: '/read', changefreq: 'weekly', priority: 0.9, title: 'Read text from a picture', text: 'Read a receipt, sign, label or screenshot with the local OCR engine; free, and the picture is not stored.' },
+ { path: '/find', changefreq: 'weekly', priority: 0.8, title: 'Find a picture by words', text: 'Search only your own pictures by describing them; owner-checked on every read and never a public index.' },
+ { path: '/watch', changefreq: 'weekly', priority: 0.8, title: 'Watch a camera still', text: 'Find a package, a delivery, a person or a car in a camera still with local object detection.' },
{ path: '/ocr', changefreq: 'weekly', priority: 0.8, title: 'Read text from a picture', text: 'Local Tesseract OCR first, with an optional AI fallback for a signed-in person.' },
{ path: '/ocr', changefreq: 'weekly', priority: 0.8, title: 'Read text from a picture', text: 'Local Tesseract OCR first, with an optional AI fallback for a signed-in person.' },
{ path: '/compare', changefreq: 'weekly', priority: 0.8, title: 'Compare models side by side', text: 'The same image through local OCR and through OpenVibe.AI, with the answer, cost and latency side by side.' },
{ path: '/compare', changefreq: 'weekly', priority: 0.8, title: 'Compare models side by side', text: 'The same image through local OCR and through OpenVibe.AI, with the answer, cost and latency side by side.' },
{ path: '/ui-check', changefreq: 'weekly', priority: 0.9, title: 'Check a page for UI faults', text: 'Overflow, cut-off text, low contrast, overlap and layering faults from HTML, a URL or a screenshot.' },
{ path: '/ui-check', changefreq: 'weekly', priority: 0.9, title: 'Check a page for UI faults', text: 'Overflow, cut-off text, low contrast, overlap and layering faults from HTML, a URL or a screenshot.' },
@@ -70,7 +73,9 @@ function createDiscoveryRoutes(ctx) {
@@ -70,7 +73,9 @@ function createDiscoveryRoutes(ctx) {
{ title: 'Start here', links: [
{ title: 'Start here', links: [
{ title: 'OpenVibe.Vision', url: abs('/') },
{ title: 'OpenVibe.Vision', url: abs('/') },
{ title: 'Ask a picture', url: abs('/ask') },
{ title: 'Ask a picture', url: abs('/ask') },
- { title: 'Read text from a picture', url: abs('/ocr') },
+ { title: 'Read text from a picture', url: abs('/read') },
+ { title: 'Find your own picture', url: abs('/find') },
+ { title: 'Watch a camera still', url: abs('/watch') },
{ title: 'Compare models', url: abs('/compare') },
{ title: 'Compare models', url: abs('/compare') },
{ title: 'The UI check', url: abs('/ui-check') },
{ title: 'The UI check', url: abs('/ui-check') },
{ title: 'People and consent', url: abs('/people') },
{ title: 'People and consent', url: abs('/people') },
@@ -18,6 +18,8 @@
@@ -18,6 +18,8 @@
* /developers the API
* /developers the API
* 404
* 404
*/
*/
+const fs = require('fs');
+const path = require('path');
const express = require('express');
const express = require('express');
const multer = require('multer');
const multer = require('multer');
const seo = require('openvibe-shared/seo');
const seo = require('openvibe-shared/seo');
@@ -32,13 +34,16 @@ const { send } = require('../render/layout');
@@ -32,13 +34,16 @@ const { send } = require('../render/layout');
const { isStaff } = require('../identity/staff');
const { isStaff } = require('../identity/staff');
const views = require('../views');
const views = require('../views');
const peopleViews = require('../views/people');
const peopleViews = require('../views/people');
+const toolViews = require('../views/tools');
const PUBLIC_CACHE = 'public, max-age=60';
const PUBLIC_CACHE = 'public, max-age=60';
+const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
+const SAMPLE_IMAGES = { receipt: 'sample-receipt.png', sign: 'sample-sign.png' };
function createPageRoutes(ctx) {
function createPageRoutes(ctx) {
- const { config, s, store, vision, ai, ocr, faces, fetchImpl } = ctx;
+ const { config, s, store, vision, ai, ocr, faces, networkTools, fetchImpl } = ctx;
const r = asyncRouter();
const r = asyncRouter();
- const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: config.images.maxMb * 1024 * 1024, files: 1 } });
+ const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: config.images.maxMb * 1024 * 1024, files: 2 } });
r.use(express.urlencoded({ extended: false, limit: '2mb' }));
r.use(express.urlencoded({ extended: false, limit: '2mb' }));
const base = () => String(config.baseUrl).replace(/\/+$/, '');
const base = () => String(config.baseUrl).replace(/\/+$/, '');
const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });
const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });
@@ -50,10 +55,39 @@ function createPageRoutes(ctx) {
@@ -50,10 +55,39 @@ function createPageRoutes(ctx) {
if (err.code === 'LIMIT_FILE_SIZE') return page(req, res, { title: 'That image is too large', body: raw(views.formError(`The image is larger than ${config.images.maxMb} MB.`)) }, 413);
if (err.code === 'LIMIT_FILE_SIZE') return page(req, res, { title: 'That image is too large', body: raw(views.formError(`The image is larger than ${config.images.maxMb} MB.`)) }, 413);
return next(err);
return next(err);
});
});
+ const uploadWatchMiddleware = (req, res, next) => upload.fields([{ name: 'image', maxCount: 1 }, { name: 'previous_image', maxCount: 1 }])(req, res, (err) => {
+ if (!err) return next();
+ if (err.code === 'LIMIT_FILE_SIZE') return page(req, res, { title: 'That image is too large', body: raw(toolViews.watchView({ problem: `The image is larger than ${config.images.maxMb} MB.` })) }, 413);
+ return next(err);
+ });
const bodyOf = (req) => (req.body && typeof req.body === 'object' ? req.body : {});
const bodyOf = (req) => (req.body && typeof req.body === 'object' ? req.body : {});
async function imageOf(req) {
async function imageOf(req) {
return await loadImage({ file: req.file && req.file.buffer, imageUrl: bodyOf(req).image_url, image: bodyOf(req).image, config, fetchImpl });
return await loadImage({ file: req.file && req.file.buffer, imageUrl: bodyOf(req).image_url, image: bodyOf(req).image, config, fetchImpl });
}
}
+ // The two sample pictures are served from public/img and read here on the server, so a person can tap a real
+ // example without an upload and without the image URL fetch (which refuses private hosts).
+ async function sampleImage(name) {
+ if (!Object.prototype.hasOwnProperty.call(SAMPLE_IMAGES, name)) return null;
+ const file = SAMPLE_IMAGES[name];
+ return await loadImage({ file: await fs.promises.readFile(path.join(PUBLIC_DIR, 'img', file)), config, fetchImpl });
+ }
+ async function toolImage(req) {
+ const example = bodyOf(req).example;
+ if (typeof example === 'string' && Object.prototype.hasOwnProperty.call(SAMPLE_IMAGES, example)) return await sampleImage(example);
+ return await imageOf(req);
+ }
+ // A person gets a plain sentence, not a model filename or an npm command. Operators still have the API's
+ // machine code and the server log.
+ const friendly = (err, fallback) => {
+ if (!(err instanceof VisionError)) return fallback;
+ if (['vision.detector_unavailable', 'vision.clip_unavailable', 'vision.ocr_unavailable', 'vision.model_unavailable'].includes(err.code)) {
+ return 'The local engine for this tool is not installed on this host yet. Nothing was stored.';
+ }
+ if (err.code === 'vision.previous_required') return 'Add a previous still to compare a scene change.';
+ if (err.code === 'vision.invalid_classes') return 'Choose between one and twenty things to watch for.';
+ if (err.code === 'vision.ai_unavailable') return 'The AI fallback is not configured on this host. The local reader still works.';
+ return err.detail || fallback;
+ };
const viewportOf = (req) => {
const viewportOf = (req) => {
const b = bodyOf(req);
const b = bodyOf(req);
return { w: Math.max(160, Math.min(10000, Number(b.viewport_w) || 1280)), h: Math.max(160, Math.min(10000, Number(b.viewport_h) || 800)) };
return { w: Math.max(160, Math.min(10000, Number(b.viewport_w) || 1280)), h: Math.max(160, Math.min(10000, Number(b.viewport_h) || 800)) };
@@ -92,13 +126,111 @@ function createPageRoutes(ctx) {
@@ -92,13 +126,111 @@ function createPageRoutes(ctx) {
catch (err) { return page(req, res, { title: 'People', body: raw(views.formError(err.detail || err.message || 'Enrolment failed.')) }, err.status || 400); }
catch (err) { return page(req, res, { title: 'People', body: raw(views.formError(err.detail || err.message || 'Enrolment failed.')) }, err.status || 400); }
});
});
- const toolPage = (req, res, title, intro, fields) => page(req, res, {
- title, cache: PUBLIC_CACHE,
- body: html`<main class="vi-wrap vi-page-pad vi-narrow"><header class="vi-section-head"><h1>${title}</h1><p class="vi-lede">${intro}</p></header><div class="vi-panel"><p class="vi-fine">These tools are available through Vision's JSON API. Sign in and send an image as multipart <code>image</code> or a data URL.</p>${fields}</div></main>`,
+ // ── The three task pages ─────────────────────────────────
+ // Each is one input and its result. /read works for anyone (local OCR); /find searches only the signed-in
+ // person's own index; /watch is open object detection with no templates and no identity.
+ const toolMeta = (title, pagePath, description) => ({
+ title, cache: PUBLIC_CACHE, description,
+ jsonLd: [seo.jsonLd.webPage({ name: title, url: `${base()}${pagePath}`, description, siteUrl: base() })],
+ });
+
+ r.get('/find', async (req, res) => {
+ const q = String(req.query.q || '').slice(0, 500);
+ let result = null; let problem = null;
+ if (q && signedIn(req)) {
+ try { result = await networkTools.find(req.viewer.subject, q); }
+ catch (err) { problem = friendly(err, 'Those pictures could not be searched just now.'); }
+ }
+ return page(req, res, {
+ ...toolMeta('Find a picture by words', '/find', 'Search only your own pictures by describing them. Private results, owner-checked on every read.'),
+ body: raw(toolViews.find({ signedIn: Boolean(signedIn(req)), q, result, problem })),
+ });
+ });
+ r.post('/find', uploadMiddleware, async (req, res) => {
+ if (!signedIn(req)) return page(req, res, { title: 'Sign in', body: raw(views.signIn({ title: 'Find a picture', text: 'Searching your own pictures needs an OpenVibe sign-in.', next: '/find' })) }, 401);
+ if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);
+ try {
+ const out = await networkTools.find(req.viewer.subject, await imageOf(req));
+ return page(req, res, {
+ ...toolMeta('Find a picture by words', '/find', 'Search only your own pictures by describing them. Private results, owner-checked on every read.'),
+ body: raw(toolViews.find({ signedIn: true, result: out, byImage: true })),
+ });
+ } catch (err) {
+ return page(req, res, {
+ ...toolMeta('Find a picture by words', '/find', 'Search only your own pictures by describing them. Private results, owner-checked on every read.'),
+ body: raw(toolViews.find({ signedIn: true, problem: friendly(err, 'That picture could not be searched.') })),
+ }, err instanceof VisionError ? err.status : 500);
+ }
+ });
+
+ r.get('/read', (req, res) => page(req, res, {
+ ...toolMeta('Read text from a picture', '/read', 'Read receipts, signs, screenshots and labels with the local OCR engine. Free, and the picture is not stored.'),
+ body: raw(toolViews.readView({ signedIn: Boolean(signedIn(req)), ai: ai.enabled })),
+ }));
+ r.post('/read', uploadMiddleware, async (req, res) => {
+ if (signedIn(req) && !sameOrigin(req, config.baseUrl)) return notFromHere(req, res);
+ const b = bodyOf(req);
+ const fallback = b.fallback === '1' || b.fallback === 'true' || b.fallback === 'on';
+ try {
+ const image = await toolImage(req);
+ const out = await vision.ocr({
+ requester: signedIn(req) ? `user:${req.viewer.subject}` : 'anonymous',
+ subject: signedIn(req) ? req.viewer.subject : null,
+ image, lang: b.lang, fallback: Boolean(signedIn(req)) && fallback, model: b.model,
+ });
+ return page(req, res, {
+ ...toolMeta('Read text from a picture', '/read', 'Read receipts, signs, screenshots and labels with the local OCR engine. Free, and the picture is not stored.'),
+ body: raw(toolViews.readView({ signedIn: Boolean(signedIn(req)), ai: ai.enabled, result: out, values: { lang: b.lang } })),
+ });
+ } catch (err) {
+ return page(req, res, {
+ ...toolMeta('Read text from a picture', '/read', 'Read receipts, signs, screenshots and labels with the local OCR engine. Free, and the picture is not stored.'),
+ body: raw(toolViews.readView({ signedIn: Boolean(signedIn(req)), ai: ai.enabled, problem: friendly(err, 'That picture could not be read.'), values: { lang: b.lang } })),
+ }, err instanceof VisionError ? err.status : 500);
+ }
+ });
+
+ r.get('/watch', (req, res) => page(req, res, {
+ ...toolMeta('Watch a camera still', '/watch', 'Drop a camera still and find a package, a delivery, a person or a car. Local detection; the picture is not stored.'),
+ body: raw(toolViews.watchView({})),
+ }));
+ r.post('/watch', uploadWatchMiddleware, async (req, res) => {
+ if (signedIn(req) && !sameOrigin(req, config.baseUrl)) return notFromHere(req, res);
+ const b = bodyOf(req);
+ const classes = [...new Set((Array.isArray(b.classes) ? b.classes : b.classes == null ? [] : [b.classes])
+ .flatMap((x) => String(x).split(','))
+ .map((x) => x.trim().toLowerCase())
+ .filter(Boolean))].slice(0, 20);
+ if (!classes.length) {
+ return page(req, res, {
+ ...toolMeta('Watch a camera still', '/watch', 'Drop a camera still and find a package, a delivery, a person or a car. Local detection; the picture is not stored.'),
+ body: raw(toolViews.watchView({ problem: 'Choose at least one thing to watch for.', values: { classes } })),
+ }, 400);
+ }
+ const files = req.files || {};
+ const imageFile = files.image && files.image[0];
+ if (!imageFile) {
+ return page(req, res, {
+ ...toolMeta('Watch a camera still', '/watch', 'Drop a camera still and find a package, a delivery, a person or a car. Local detection; the picture is not stored.'),
+ body: raw(toolViews.watchView({ problem: 'Drop a camera still first.', values: { classes } })),
+ }, 400);
+ }
+ try {
+ const image = await loadImage({ file: imageFile.buffer, config, fetchImpl });
+ const previousFile = files.previous_image && files.previous_image[0];
+ const previous = previousFile ? await loadImage({ file: previousFile.buffer, config, fetchImpl }) : null;
+ const out = await networkTools.watch(image, classes, previous);
+ return page(req, res, {
+ ...toolMeta('Watch a camera still', '/watch', 'Drop a camera still and find a package, a delivery, a person or a car. Local detection; the picture is not stored.'),
+ body: raw(toolViews.watchView({ result: out, values: { classes } })),
+ });
+ } catch (err) {
+ return page(req, res, {
+ ...toolMeta('Watch a camera still', '/watch', 'Drop a camera still and find a package, a delivery, a person or a car. Local detection; the picture is not stored.'),
+ body: raw(toolViews.watchView({ problem: friendly(err, 'That still could not be checked.'), values: { classes } })),
+ }, err instanceof VisionError ? err.status : 500);
+ }
});
});
- r.get('/find', (req, res) => toolPage(req, res, 'Find in your images', 'Private visual search uses local CLIP embeddings and checks the owner on each read.', html`<form method="get" action="/find"><label class="vi-label" for="vi-find-q">Search words</label><input id="vi-find-q" name="q" value="${String(req.query.q || '').slice(0, 500)}"><button type="submit">Search</button></form><p class="vi-fine">Room call: GET /api/v1/find?q= with the owner token; POST /api/v1/index to add an image.</p>`));
- r.get('/read', (req, res) => toolPage(req, res, 'Read a receipt', 'Local Tesseract extracts store, line items and total; a routed fallback is optional.', html`<p class="vi-fine">Food calls POST /api/v1/receipt with the image and optional fallback=true. For plain text, use POST /api/v1/ocr.</p>`));
- r.get('/watch', (req, res) => toolPage(req, res, 'Watch a still', 'Detect selected objects or a scene change in an owner-supplied camera still.', html`<p class="vi-fine">Cam calls POST /api/v1/watch with the owner token, image and classes. Send previous_image for scene change.</p>`));
// ── Ask ──────────────────────────────────────────────────
// ── Ask ──────────────────────────────────────────────────
r.get('/ask', (req, res) => page(req, res, {
r.get('/ask', (req, res) => page(req, res, {
@@ -21,9 +21,16 @@ function motif() {
@@ -21,9 +21,16 @@ function motif() {
function home({ signedIn = false, ai = false } = {}) {
function home({ signedIn = false, ai = false } = {}) {
const tryIt = `<section class="vi-section vi-tasks-wrap" id="vi-try" aria-labelledby="vi-try-h">
const tryIt = `<section class="vi-section vi-tasks-wrap" id="vi-try" aria-labelledby="vi-try-h">
-<div class="vi-head-row"><h2 id="vi-try-h">What do you want to do?</h2><span class="vi-count">${ai ? 'local engines first, then OpenVibe.AI' : 'local engines on this host'}</span></div>
-<ul class="vi-tasks"><li><a class="vi-task" href="/ask"><b>Look</b><span>Ask anything about a picture: what it shows, how many, what it says.</span></a></li><li><a class="vi-task" href="/read"><b>Read</b><span>Receipts, signs, screenshots and documents, read on this host first, for nothing.</span></a></li><li><a class="vi-task" href="/ask"><b>Translate</b><span>A sign, a menu or a stream overlay, read and put into your language.</span></a></li><li><a class="vi-task" href="/find"><b>Find</b><span>Search your own pictures by words or by another picture. Only you see the results.</span></a></li><li><a class="vi-task" href="/watch"><b>Watch</b><span>Point it at a still from your camera and get told when a delivery, an animal or a change shows up.</span></a></li><li><a class="vi-task" href="/people"><b>People</b><span>Faces with your consent: verify yourself, find yourself, tag co-streamers who opted in, or just blur.</span></a></li><li><a class="vi-task" href="/compare"><b>Compare</b><span>One picture, several models, side by side, with what each answer cost and how long it took.</span></a></li><li><a class="vi-task" href="/ui-check"><b>Check a page</b><span>Overflow, clipped text, low contrast and overlap, found in HTML, a URL or a screenshot.</span></a></li></ul>
-${signedIn ? '' : '<p class="vi-fine">Reading, blurring and the page check work without an account. Saving answers, your picture search and faces need <a href="/auth/login?next=%2F">an OpenVibe sign-in</a>.</p>'}
+<div class="vi-head-row"><h2 id="vi-try-h">Pick one thing to do</h2><span class="vi-count">${ai ? 'local first, AI when it helps' : 'local engines on this host'}</span></div>
+<ul class="vi-tasklist">
+<li><a href="/ask"><span class="vi-task-txt"><b>Look at a picture</b><span>Ask what it shows, read it, translate it or count something.</span></span><span class="vi-go" aria-hidden="true">→</span></a></li>
+<li><a href="/read"><span class="vi-task-txt"><b>Read a receipt or a sign</b><span>The words on this host, for nothing, with the picture not stored.</span></span><span class="vi-go" aria-hidden="true">→</span></a></li>
+<li><a href="/find"><span class="vi-task-txt"><b>Find your own picture</b><span>Describe it and search only your pictures. Nobody else sees the results.</span></span><span class="vi-go" aria-hidden="true">→</span></a></li>
+<li><a href="/watch"><span class="vi-task-txt"><b>Watch a camera still</b><span>Name a package, a delivery, a person or a car in a picture.</span></span><span class="vi-go" aria-hidden="true">→</span></a></li>
+<li><a href="/people"><span class="vi-task-txt"><b>Choose what faces may do</b><span>Four separate choices, each off until you sign it. Never a stranger lookup.</span></span><span class="vi-go" aria-hidden="true">→</span></a></li>
+</ul>
+<div class="vi-more-row"><span>Need more?</span> <a href="/compare">Compare models</a> <span aria-hidden="true">·</span> <a href="/ui-check">Check a page</a> <span aria-hidden="true">·</span> <a href="/ocr">Raw OCR</a></div>
+${signedIn ? '' : '<p class="vi-fine">Reading, watching and the page check work without an account. Saving answers, your picture search and faces need <a href="/auth/login?next=%2F">an OpenVibe sign-in</a>.</p>'}
<p class="vi-fine"><a href="/people">Your face choices</a> · <a href="/people/privacy">How faces are kept</a></p>
<p class="vi-fine"><a href="/people">Your face choices</a> · <a href="/people/privacy">How faces are kept</a></p>
</section>
</section>
<section class="vi-section vi-net-wrap" aria-labelledby="vi-net-h"><div class="vi-head-row"><h2 id="vi-net-h">Works across OpenVibe</h2><a class="vi-more" href="/developers">The API</a></div>
<section class="vi-section vi-net-wrap" aria-labelledby="vi-net-h"><div class="vi-head-row"><h2 id="vi-net-h">Works across OpenVibe</h2><a class="vi-more" href="/developers">The API</a></div>
@@ -32,12 +39,12 @@ ${signedIn ? '' : '<p class="vi-fine">Reading, blurring and the page check work
@@ -32,12 +39,12 @@ ${signedIn ? '' : '<p class="vi-fine">Reading, blurring and the page check work
room: 'vision',
room: 'vision',
hero: {
hero: {
title: 'See it. Use it everywhere.',
title: 'See it. Use it everywhere.',
- lede: 'Point Vision at a picture and do something with it: read a receipt into your pantry, find your own photos by words, get told when your camera sees a delivery, or check a page before it ships. Every answer says which model saw it, what it cost and what was kept.',
+ lede: 'Point Vision at a picture and get something done: read a receipt, find your own photo by words, or get told when your camera sees a delivery. Local engines answer first, and every answer says what it cost.',
start: { href: '/ask', label: 'Look at a picture' },
start: { href: '/ask', label: 'Look at a picture' },
- chips: [{ label: 'Read', href: '/read' }, { label: 'Find', href: '/find' }, { label: 'Watch', href: '/watch' }, { label: 'People', href: '/people' }, { label: 'The API', href: '/developers' }],
- proof: [{ value: '0.0000', label: 'USD for a local read' }, { value: '8', label: 'tools, one API' }],
+ chips: [{ label: 'Read', href: '/read' }, { label: 'Find', href: '/find' }, { label: 'Watch', href: '/watch' }, { label: 'People', href: '/people' }],
motif: motif(),
motif: motif(),
},
},
+ proof: [{ value: '0.0000', label: 'USD for a local read' }, { value: '8', label: 'tools, one place' }],
scenes: [
scenes: [
{
{
title: 'A receipt, read for nothing',
title: 'A receipt, read for nothing',
@@ -48,7 +55,7 @@ ${signedIn ? '' : '<p class="vi-fine">Reading, blurring and the page check work
@@ -48,7 +55,7 @@ ${signedIn ? '' : '<p class="vi-fine">Reading, blurring and the page check work
{
{
title: 'The same image, side by side',
title: 'The same image, side by side',
text: 'A compare sends one image to the local reader and to OpenVibe.AI. AI\'s router is the authority: the provider and model shown are the ones that answered, never the hint. Cost comes from AI\'s own run, latency is measured here.',
text: 'A compare sends one image to the local reader and to OpenVibe.AI. AI\'s router is the authority: the provider and model shown are the ones that answered, never the hint. Cost comes from AI\'s own run, latency is measured here.',
- html: `<table class="vi-demo-table"><caption>one image, three answers</caption><thead><tr><th scope="col">engine</th><th scope="col">answer</th><th scope="col">cost</th><th scope="col">time</th></tr></thead><tbody><tr><td>local OCR</td><td>“TOTAL 5.50”</td><td>0.0000</td><td>38 ms</td></tr><tr><td>pool:fast</td><td>a receipt from a market</td><td>0.0004</td><td>1.1 s</td></tr><tr><td>pool:best</td><td>the total is 5.50</td><td>0.0012</td><td>2.4 s</td></tr></tbody></table>`,
+ html: `<div class="vi-demo-scroll"><table class="vi-demo-table"><caption>one image, three answers</caption><thead><tr><th scope="col">engine</th><th scope="col">answer</th><th scope="col">cost</th><th scope="col">time</th></tr></thead><tbody><tr><td>local OCR</td><td>“TOTAL 5.50”</td><td>0.0000</td><td>38 ms</td></tr><tr><td>pool:fast</td><td>a receipt from a market</td><td>0.0004</td><td>1.1 s</td></tr><tr><td>pool:best</td><td>the total is 5.50</td><td>0.0012</td><td>2.4 s</td></tr></tbody></table></div>`,
action: { href: '/compare', label: 'Run a compare' },
action: { href: '/compare', label: 'Run a compare' },
},
},
{
{
@@ -1,26 +1,59 @@
@@ -1,26 +1,59 @@
'use strict';
'use strict';
const { esc } = require('../render/html');
const { esc } = require('../render/html');
-const LABELS = { verify: 'Verify yourself', find: 'Find yourself', cast: 'Cast tagging', library: "Your library's people" };
+const LABELS = {
+ verify: 'Verify yourself',
+ find: 'Find yourself',
+ cast: 'Cast tagging',
+ library: "Your library's people",
+};
+const PLAIN = {
+ verify: 'Compare your own face with your own verification attempt. Nothing else is unlocked.',
+ find: 'Search your own pictures for your face. The results are private links.',
+ cast: 'Match only inside a channel whose adult co-streamers opted in.',
+ library: 'Group the faces in your own library. Unknown faces stay unlabelled.',
+};
+
+/**
+ * Four face purposes, each with its own signed consent. A person can read every purpose before signing in; the
+ * form and its signature fields sit one tap down in <details>, so the page is a short list of choices instead of a
+ * wall of forms. The ids (vi-verify …) and the form actions are part of the page contract.
+ */
function people({ signedIn, consents = [], texts, regions = [] }) {
function people({ signedIn, consents = [], texts, regions = [] }) {
const active = (f) => consents.find((c) => c.feature === f && !c.revoked_at);
const active = (f) => consents.find((c) => c.feature === f && !c.revoked_at);
- return `<div class="vi-wrap vi-page-pad vi-narrow" id="vi-people"><header class="vi-page-h"><p class="vi-kicker">Your choice</p><h1>People in Vision</h1><p class="vi-lede">Each purpose has its own consent. Face matching runs locally. Turning a feature off erases its template at once.</p></header>
-${signedIn ? '' : '<p class="vi-note">Sign in with your Network account to manage face features.</p>'}
+ return `<div class="vi-wrap vi-page-pad vi-narrow" id="vi-people">
+<header class="vi-page-h"><p class="vi-kicker">Your choice</p><h1>Your face choices</h1><p class="vi-lede">Four purposes, four separate switches. Matching runs on this host, each purpose is off until you sign it, and turning one off erases its template at once.</p></header>
+${signedIn ? '' : `<div class="vi-signin" id="vi-people-signin"><p>Sign in with your Network account to see or change your choices. You can read all four below first.</p><a class="vi-btn" href="/auth/login?next=%2Fpeople">Sign in with OpenVibe</a></div>`}
+<ul class="vi-purposes">
${Object.entries(texts).map(([feature, item]) => {
${Object.entries(texts).map(([feature, item]) => {
- const on = active(feature), scope = on?.scope || '', needed = on ? '' : ' required';
- return `<section class="vi-card vi-person-feature" id="vi-${feature}"><h2>${esc(LABELS[feature])}</h2><p>${esc(item.text)}</p>
+ const on = active(feature), scope = on?.scope || '', needed = on ? '' : ' required';
+ const enrol = on && feature !== 'library';
+ const action = on ? 'Manage or turn off' : 'Read and turn on';
+ return `<li class="vi-purpose" id="vi-${feature}">
+<div class="vi-purpose-head"><div><h2>${esc(LABELS[feature])}</h2><p>${esc(PLAIN[feature] || item.text)}</p>${on ? `<p class="vi-fine">On${scope ? ` for ${esc(scope)}` : ''} · signed ${esc(on.text_version)}</p>` : ''}</div><span class="vi-status${on ? ' is-on' : ''}">${on ? 'On' : 'Off'}</span></div>
+${signedIn ? `<details class="vi-disclose"><summary>${action}</summary>
<form class="vi-form" method="post" action="/people/consent/${feature}">
<form class="vi-form" method="post" action="/people/consent/${feature}">
<input type="hidden" name="text_version" value="${esc(item.version)}">
<input type="hidden" name="text_version" value="${esc(item.version)}">
- ${feature === 'cast' ? `<label class="vi-field"><span>Named channel ID</span><input name="channel"${needed} value="${esc(scope)}" placeholder="live:your-channel"></label><label class="vi-check"><input type="checkbox" name="adult" value="true"${needed}> I confirm I am an adult co-streamer</label>` : ''}
- ${feature === 'library' ? `<label class="vi-field"><span>Region</span><input name="region"${needed} placeholder="US-IL" value="${esc(regions[0] || '')}"></label><p class="vi-fine">Available regions: ${esc(regions.join(', ') || 'none configured')}</p>` : ''}
- <label class="vi-field"><span>Signature (your typed name)</span><input name="signed_name" autocomplete="name"${needed} maxlength="160"></label>
-<label class="vi-check"><input type="checkbox" role="switch" name="enabled" value="yes" ${on ? 'checked' : ''}${signedIn ? '' : ' disabled'}> ${on ? 'On' : 'Off'}</label>
-<button class="vi-btn" type="submit"${signedIn ? '' : ' disabled'}>Save this choice</button></form>
-${on && feature !== 'library' ? `<form method="post" action="/people/enrol/${feature}" enctype="multipart/form-data" class="vi-form"><input type="hidden" name="channel" value="${esc(scope)}"><label class="vi-field"><span>One face image for enrolment</span><input type="file" name="image" accept="image/*" required></label><button class="vi-btn vi-btn-quiet" type="submit">Enrol my face</button></form>` : ''}
-${on ? `<p class="vi-fine">Granted ${esc(String(on.granted_at))}${scope ? ` for ${esc(scope)}` : ''} · consent ${esc(on.text_version)}</p>` : ''}</section>`;
-}).join('')}
-<p class="vi-fine">A face match cannot unlock an account by itself. Liveness is not checked yet; use another factor and a non-biometric route. <a href="/people/privacy">Read the retention and privacy rules.</a></p></div>`;
+<p class="vi-fine">${esc(item.text)}</p>
+${feature === 'cast' ? `<label class="vi-field"><span>Named channel ID</span><input name="channel"${needed} value="${esc(scope)}" placeholder="live:your-channel"></label><label class="vi-check"><input type="checkbox" name="adult" value="true"${needed}> <span>I confirm I am an adult co-streamer</span></label>` : ''}
+${feature === 'library' ? `<label class="vi-field"><span>Region</span><input name="region"${needed} placeholder="US-IL" value="${esc(regions[0] || '')}"></label><p class="vi-fine">Available regions: ${esc(regions.join(', ') || 'none configured')}</p>` : ''}
+<label class="vi-field"><span>Signature (your typed name)</span><input name="signed_name" autocomplete="name"${needed} maxlength="160"></label>
+<label class="vi-check"><input type="checkbox" role="switch" name="enabled" value="yes" ${on ? 'checked' : ''}> <span>${on ? 'On' : 'Off'}</span></label>
+<div class="vi-actions"><button class="vi-btn" type="submit">Save this choice</button></div>
+</form>
+${enrol ? `<form method="post" action="/people/enrol/${feature}" enctype="multipart/form-data" class="vi-form vi-enrol">
+<input type="hidden" name="channel" value="${esc(scope)}">
+<label class="vi-field"><span>One face image for enrolment</span><input type="file" name="image" accept="image/*" required></label>
+<div class="vi-actions"><button class="vi-btn vi-btn-quiet" type="submit">Enrol my face</button></div>
+</form>` : ''}
+</details>` : ''}
+</li>`;
+ }).join('')}
+</ul>
+<section class="vi-people-note"><h2>What never happens</h2><p>No stranger identification. No public face index. No face or template sent to a cloud model, Search, model prompts or Bench. A match alone never unlocks anything: liveness is not checked yet, so use another factor and a non-biometric route.</p><p><a href="/people/privacy">Read the retention and privacy rules</a></p></section>
+</div>`;
}
}
+
function privacy() {
function privacy() {
return `<div class="vi-wrap vi-page-pad vi-narrow" id="vi-privacy"><header class="vi-page-h"><p class="vi-kicker">Privacy</p><h1>Faces stay in your chosen purpose</h1></header>
return `<div class="vi-wrap vi-page-pad vi-narrow" id="vi-privacy"><header class="vi-page-h"><p class="vi-kicker">Privacy</p><h1>Faces stay in your chosen purpose</h1></header>
<section class="vi-card"><h2>What happens</h2><p>Detect, count and blur use local YuNet without identity templates or tracking. Verification compares only your enrolled template with your own attempt. Find yourself searches only your own Vision uploads. Cast tagging checks a named channel roster of consenting adults. Library groups are private, region gated, and unknown faces remain unlabelled.</p></section>
<section class="vi-card"><h2>What happens</h2><p>Detect, count and blur use local YuNet without identity templates or tracking. Verification compares only your enrolled template with your own attempt. Find yourself searches only your own Vision uploads. Cast tagging checks a named channel roster of consenting adults. Library groups are private, region gated, and unknown faces remain unlabelled.</p></section>
new file mode 100644
new file mode 100644
@@ -0,0 +1,216 @@
@@ -0,0 +1,216 @@
+'use strict';
+
+/**
+ * The three task pages: /find, /read and /watch (plan 2026-10-28 §5.8/§5.8b).
+ *
+ * Each page is one job: one input or drop zone, the result directly under it, and examples a person can tap.
+ * Everything is a plain server-rendered form, so it works without JavaScript; vision.js only adds drag-and-drop and
+ * a filename on the drop zone. Power options sit inside a <details> one tap down, and the API notes live at the
+ * bottom of the page instead of above the tool.
+ */
+const { esc } = require('../render/html');
+
+const UPLOAD_MARK = `<svg class="vi-drop-mark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 16V4m0 0L7.5 8.5M12 4l4.5 4.5"/><path d="M4 15v3.5A1.5 1.5 0 0 0 5.5 20h13a1.5 1.5 0 0 0 1.5-1.5V15"/></svg>`;
+
+function n(value, fallback = 0) {
+ const x = Number(value);
+ return Number.isFinite(x) ? x : fallback;
+}
+
+function money(value) {
+ const x = n(value);
+ return `${x.toFixed(4)} USD`;
+}
+
+function when(ms) {
+ const x = n(ms);
+ return x < 1000 ? `${Math.round(x)} ms` : `${(x / 1000).toFixed(1)} s`;
+}
+
+function confidence(value) {
+ if (value == null) return null;
+ const x = Number(value);
+ if (!Number.isFinite(x)) return null;
+ return `${(x <= 1 ? x * 100 : x).toFixed(1)}%`;
+}
+
+function facts(items) {
+ return `<dl class="vi-facts vi-tool-facts">${items.filter((x) => x && x[1] != null && x[1] !== '').map(([dt, dd]) => `<div><dt>${esc(dt)}</dt><dd>${dd}</dd></div>`).join('')}</dl>`;
+}
+
+function header({ kicker, title, lede }) {
+ return `<header class="vi-page-h vi-tool-h"><p class="vi-kicker">${esc(kicker)}</p><h1>${esc(title)}</h1><p class="vi-lede">${esc(lede)}</p></header>`;
+}
+
+function dropZone({ id, name = 'image', label = 'Drop a picture here', hint = 'or choose a file' }) {
+ return `<label class="vi-drop" for="${esc(id)}" data-vi-drop>
+<span class="vi-drop-art">${UPLOAD_MARK}</span>
+<b>${esc(label)}</b>
+<span class="vi-drop-hint">${esc(hint)}</span>
+<span class="vi-drop-file" data-vi-file></span>
+<input class="vi-sr" id="${esc(id)}" type="file" name="${esc(name)}" accept="image/png,image/jpeg,image/webp,image/gif,image/bmp,image/tiff,image/avif">
+</label>`;
+}
+
+function examples(links) {
+ return `<div class="vi-examples"><span class="vi-examples-label">Try an example</span>${links.join('')}</div>`;
+}
+
+function apiNote({ children }) {
+ return `<details class="vi-api"><summary>Use this from the API</summary><div class="vi-api-body">${children}</div></details>`;
+}
+
+function problemNote(problem) {
+ return problem ? `<p class="vi-note is-bad" role="alert">${esc(problem)}</p>` : '';
+}
+
+// ── Find ─────────────────────────────────────────────────────
+
+function find({ signedIn = false, q = '', result = null, problem = null, byImage = false } = {}) {
+ const examplesHtml = examples([
+ ['red bike by the pier', 'a dog on the sofa', 'the receipt from the market'].map((text) => `<a class="vi-example" href="/find?q=${encodeURIComponent(text)}">${esc(text)}</a>`).join(''),
+ ]);
+ const form = `<form class="vi-tool-form" id="vi-find-form" method="get" action="/find" role="search">
+<label class="vi-field" for="vi-find-q"><span>Describe a picture you have</span></label>
+<div class="vi-searchrow">
+<input id="vi-find-q" name="q" type="search" value="${esc(q)}" placeholder="red bike by the pier" autocomplete="off" enterkeyhint="search" maxlength="500">
+<button class="vi-btn" type="submit">Find it</button>
+</div>
+<p class="vi-fine">Words, not a hashtag. Vision compares your words with the pictures you have already added.</p>
+</form>`;
+
+ let resultHtml;
+ if (!signedIn) {
+ resultHtml = `<section class="vi-result-tool vi-result-empty" id="vi-find-empty">
+<h2>Your own pictures, never a public index</h2>
+<p>Sign in to search the pictures you have added to Vision from Pics, Media or Search. Nobody else can see the query or the matches.</p>
+<p><a class="vi-btn" href="/auth/login?next=%2Ffind">Sign in with OpenVibe</a></p>
+<p class="vi-fine">No account needed to <a href="/read">read a picture</a> or <a href="/watch">watch a still</a>.</p>
+</section>`;
+ } else if (result) {
+ const rows = Array.isArray(result.results) ? result.results : [];
+ const receipt = result.receipt || {};
+ resultHtml = `<section class="vi-result-tool" id="vi-find-result" aria-labelledby="vi-find-result-h">
+<div class="vi-result-head"><h2 id="vi-find-result-h">${rows.length ? `${rows.length} ${rows.length === 1 ? 'match' : 'matches'}` : 'No matches yet'}</h2><span class="vi-status${rows.length ? ' is-on' : ''}">${esc(byImage ? 'by picture' : 'by words')}</span></div>
+${rows.length ? `<ol class="vi-matches">${rows.slice(0, 30).map((m, i) => `<li class="vi-match">
+<span class="vi-match-rank" aria-hidden="true">${i + 1}</span>
+<span class="vi-match-main">${m.result_url ? `<a href="${esc(m.result_url)}" rel="noopener">Open this picture</a>` : `<b>Picture ${esc(m.item_id)}</b>`}<small>${esc(m.item_id)}</small></span>
+<span class="vi-match-score">${Math.round(n(m.score) * 100)}%</span>
+</li>`).join('')}</ol>` : `<p>Nothing in your pictures matches those words yet. Add pictures from <a href="https://openvibe.media">Pics or Media</a>, then search again.</p><p class="vi-fine">Your matches stay private; index entries are tied to your account.</p>`}
+${facts([['Engine', esc(receipt.model || receipt.engine || 'local')], ['Time', esc(when(receipt.ms))], ['Cost', esc(money(receipt.cost_usd))]])}
+<p class="vi-fine">${esc(receipt.retention || 'Only your own indexed pictures are searched; the query is not stored.')}</p>
+</section>`;
+ } else {
+ resultHtml = `<section class="vi-result-tool vi-result-empty" id="vi-find-empty">
+<h2>Your matches appear here</h2>
+<p>Start with a few words. Vision looks only at the pictures you have added and returns private links, with the score and the time it took.</p>
+<p class="vi-fine">Searches are private. Vision keeps no public picture index and never identifies people in your photos.</p>
+</section>`;
+ }
+
+ const byImageForm = signedIn ? `<details class="vi-disclose"><summary>Or find by another picture</summary>
+<form class="vi-form" method="post" action="/find" enctype="multipart/form-data">
+${dropZone({ id: 'vi-find-file', label: 'Drop the picture to match', hint: 'or choose a file' })}
+<div class="vi-actions"><button class="vi-btn vi-btn-quiet" type="submit">Find similar pictures</button></div>
+</form></details>` : '';
+
+ return `<div class="vi-wrap vi-tool" id="vi-find">
+${header({ kicker: 'Find · private', title: 'Find a picture by words', lede: 'Search only your own pictures. Describe what you remember; no public index, no stranger identification.' })}
+${problemNote(problem)}
+${form}
+${examplesHtml}
+${resultHtml}
+${byImageForm}
+${apiNote({ children: `<p class="vi-fine">Pics, Media and Search call <code>POST /api/v1/index</code> to add a picture, then <code>GET /api/v1/find?q=</code> or <code>POST /api/v1/find</code> with an image. A person needs their own Network token; the owner is checked on every read.</p>` })}
+</div>`;
+}
+
+// ── Read ─────────────────────────────────────────────────────
+
+function readView({ signedIn = false, ai = false, problem = null, result = null, values = {} } = {}) {
+ const v = { lang: 'eng', ...values };
+ let resultHtml;
+ if (result) {
+ const conf = confidence(result.confidence);
+ resultHtml = `<section class="vi-result-tool" id="vi-read-result" aria-labelledby="vi-read-result-h">
+<div class="vi-result-head"><h2 id="vi-read-result-h">What it says</h2><span class="vi-status is-on">${esc(result.engine === 'ai' ? 'AI fallback' : 'local OCR')}</span></div>
+<pre class="vi-ocr-text">${esc(result.text || 'No text was found.')}</pre>
+${facts([['Engine', esc(/tesseract/i.test(String(result.model || '')) ? 'Tesseract 5' : (result.model || result.engine || 'Tesseract 5'))], ['Confidence', conf ? esc(conf) : null], ['Time', esc(when(result.latency_ms || result.ms))], ['Cost', esc(money(result.cost_usd))]])}
+<p class="vi-fine">${esc(/[.!?]$/.test(String(result.reason || '')) ? result.reason : `${result.reason || 'The local reader answered first'}.`)} ${signedIn && result.id ? `Saved to <a href="/a/${encodeURIComponent(result.id)}">your answers</a>.` : 'This reading is shown here and not saved.'}</p>
+</section>`;
+ } else {
+ resultHtml = `<section class="vi-result-tool vi-result-empty" id="vi-read-empty">
+<h2>Your reading appears here</h2>
+<p>Choose a photo above and press <b>Read it</b>. The words come back in reading order, with the engine, the confidence and the time.</p>
+<ul class="vi-steps"><li>Receipts: store, lines and total.</li><li>Signs, labels and screenshots: the words as written.</li><li>Nothing is sent to a model unless you ask.</li></ul>
+</section>`;
+ }
+ return `<div class="vi-wrap vi-tool" id="vi-read">
+${header({ kicker: 'Read · local first', title: 'Read the words in a picture', lede: 'Receipts, signs, screenshots and labels, read on this host. Free, and the picture is not stored.' })}
+${problemNote(problem)}
+<form class="vi-tool-form" id="vi-read-form" method="post" action="/read" enctype="multipart/form-data">
+${dropZone({ id: 'vi-read-file', label: 'Drop a picture to read', hint: 'or choose a file' })}
+${examples([`<button class="vi-example" type="submit" name="example" value="receipt">A shop receipt</button>`, `<button class="vi-example" type="submit" name="example" value="sign">A station sign</button>`])}
+<div class="vi-actions"><button class="vi-btn" type="submit">Read it</button><a class="vi-btn vi-btn-quiet" href="/ask">Ask about it instead</a></div>
+<details class="vi-options"><summary>Language and AI fallback</summary>
+<div class="vi-field-grid">
+<label class="vi-field"><span>Language</span><input name="lang" type="text" value="${esc(v.lang)}" maxlength="15" pattern="[a-z]{3}(\\+[a-z]{3}){0,3}"></label>
+</div>
+<label class="vi-check"><input type="checkbox" name="fallback" value="1"><span>If the local reader finds no text, ask OpenVibe.AI${signedIn ? '' : ' (needs a sign-in)'}</span></label>
+<p class="vi-fine">${ai ? 'The fallback runs only after the local pass, and only for a signed-in account.' : 'The AI fallback is off on this host; the local reader still works.'}</p>
+</details>
+</form>
+${resultHtml}
+${apiNote({ children: `<p class="vi-fine">Food calls <code>POST /api/v1/receipt</code> for store, line items and total. Plain text is <code>POST /api/v1/ocr</code>, with <code>fallback=1</code> only when a signed-in person asks for it.</p>` })}
+</div>`;
+}
+
+// ── Watch ────────────────────────────────────────────────────
+
+const WATCH_CLASSES = [
+ ['package', 'A package'], ['delivery', 'A delivery'], ['person', 'A person'],
+ ['dog', 'A dog'], ['cat', 'A cat'], ['car', 'A car'],
+];
+
+function watchView({ problem = null, result = null, values = {} } = {}) {
+ const selected = new Set((values.classes && values.classes.length ? values.classes : ['package', 'delivery']).map(String));
+ const choices = WATCH_CLASSES.map(([value, label]) => `<label class="vi-choice"><input type="checkbox" name="classes" value="${esc(value)}"${selected.has(value) ? ' checked' : ''}><span>${esc(label)}</span></label>`).join('');
+ let resultHtml;
+ if (result) {
+ const list = Array.isArray(result.detections) ? result.detections : [];
+ const receipt = result.receipt || {};
+ resultHtml = `<section class="vi-result-tool" id="vi-watch-result" aria-labelledby="vi-watch-result-h">
+<div class="vi-result-head"><h2 id="vi-watch-result-h">${list.length ? `${list.length} ${list.length === 1 ? 'thing' : 'things'} found` : 'Nothing from your list'}</h2><span class="vi-status${list.length ? ' is-on' : ''}">on this host</span></div>
+${list.length ? `<ul class="vi-findings-list">${list.map((d) => `<li><b>${esc(d.class)}</b><span>${confidence(d.confidence) ? `${esc(confidence(d.confidence))} confidence` : 'detected'}</span>${d.box ? `<small>box ${Math.round(Number(d.box.xmin) || 0)}, ${Math.round(Number(d.box.ymin) || 0)} to ${Math.round(Number(d.box.xmax) || 0)}, ${Math.round(Number(d.box.ymax) || 0)}</small>` : ''}</li>`).join('')}</ul>` : '<p>No watched object was found in this still. Try a closer picture, or add another thing to watch for.</p>'}
+${facts([['Engine', esc(receipt.model || receipt.engine || 'local')], ['Time', esc(when(receipt.ms))], ['Cost', esc(money(receipt.cost_usd))]])}
+<p class="vi-fine">The still is not stored. Vision finds the classes you chose; it does not identify people.</p>
+</section>`;
+ } else {
+ resultHtml = `<section class="vi-result-tool vi-result-empty" id="vi-watch-empty">
+<h2>What Vision sees appears here</h2>
+<p>Drop a still from your camera. Vision names the things on your list, with a confidence for each, and says how long it took.</p>
+<p class="vi-fine">This is object detection, not face recognition. There is no template and no tracking identity.</p>
+</section>`;
+ }
+ return `<div class="vi-wrap vi-tool" id="vi-watch">
+${header({ kicker: 'Watch · local', title: 'Watch a camera still', lede: 'Drop a still and see what is there: a delivery, a package, a person or a car. Runs on this host, and the picture is not stored.' })}
+${problemNote(problem)}
+<form class="vi-tool-form" id="vi-watch-form" method="post" action="/watch" enctype="multipart/form-data">
+${dropZone({ id: 'vi-watch-file', label: 'Drop a camera still', hint: 'a photo or screenshot' })}
+<fieldset class="vi-choices"><legend>What are you watching for?</legend>${choices}</fieldset>
+<div class="vi-actions"><button class="vi-btn" type="submit">Check this still</button><a class="vi-btn vi-btn-quiet" href="/read">Read it instead</a></div>
+<details class="vi-options"><summary>Scene change and weather</summary>
+<div class="vi-field-grid">
+<label class="vi-field"><span>Previous still (for scene change)</span><input type="file" name="previous_image" accept="image/png,image/jpeg,image/webp,image/gif,image/bmp,image/tiff,image/avif"></label>
+<label class="vi-check"><input type="checkbox" name="classes" value="snow"${selected.has('snow') ? ' checked' : ''}><span>Snow</span></label>
+<label class="vi-check"><input type="checkbox" name="classes" value="scene change"${selected.has('scene change') ? ' checked' : ''}><span>Scene change</span></label>
+</div>
+<p class="vi-fine">A scene change compares this still with the previous one. Snow and delivery use a local scene check.</p>
+</details>
+</form>
+${resultHtml}
+${apiNote({ children: `<p class="vi-fine">Cam calls <code>POST /api/v1/watch</code> with an owner token, an image and up to 20 <code>classes</code>; send <code>previous_image</code> for a scene change. The API returns detections with class, confidence and box, plus a receipt for the engine, time and cost.</p>` })}
+</div>`;
+}
+
+module.exports = { find, readView, watchView };