openvibers/openvibe.vision Commits

Read, Find and Watch on Kit 2: take a photo on a phone, a reading the moment it is chosen, and receipts (#6)

OP OpenVibers committed 15 days ago · b1d19e5a72 · 9 files, +351 −107

The picture's way in is kit.drop: choose, drop, paste, or "Take a photo" on a phone; /read sends the form as soon as a
picture is chosen and lands on the answer (#vi-read-result). Each result is a kit.receipt: the answer, then one line of
what it took (engine, confidence meter, time, cost or free, kept or not stored), with a copy button on a reading. The
screen-long "Your reading appears here" placeholder is gone; the lede says what comes back. kit-web.js replaces
vision.js. Vision's own .vi .ovk-i rule is :where(.vi) now, so it no longer shrinks kit icons. Shared v4.36.1.

Changes

ddiff --git a/README.md b/README.md
iindex 5b472de..b717f7e 100644
---- a/README.md
++++ b/README.md
@@@ -243,3 +243,9 @@ For private index, watch and moments, a service capability alone does not assert
Private embeddings are JSON vectors in PostgreSQL, scored in JavaScript after a mandatory `owner` filter. Vision has no pgvector migration or extension requirement. The v1 search cap is 5,000 items per owner.
Offer discovery is configured by `VISION_DEALS_SEARCH_URL` and `VISION_SHOES_SEARCH_URL`, both fixed operator URLs returning JSON `{offers:[{title,url,price?}]}` or `{results:[...]}`. Deals currently exposes public `GET /api/v1/offers` without a query filter, and its `/search?q=` is an HTML page; open.shoes has no implemented API in this workspace yet. Until those rooms expose a structured search endpoint, `/product` returns the description and embedding with an empty `offers` list and a warning. It does not fabricate matches.
++
++<!-- versions:start -->
++- openvibe-contracts: v0.235.0
++- openvibe-sdk: v0.70.0
++- openvibe-shared: v4.36.1
++<!-- versions:end -->
ddiff --git a/STATUS.json b/STATUS.json
iindex a79cda2..3f27419 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -8,13 +8,13 @@
"runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9, Tesseract 5, sharp, onnxruntime-node (YuNet/SFace), Transformers.js (CLIP/DETR)",
"port": "5130",
"domain": "openvibe.vision",
-- "contracts": "openvibe-contracts v0.229.0",
++ "contracts": "openvibe-contracts v0.235.0",
"sdk": "openvibe-sdk v0.70.0",
-- "shared": "openvibe-shared v4.31.0",
++ "shared": "openvibe-shared v4.36.1",
"packages": [
-- "openvibe-contracts v0.229.0",
++ "openvibe-contracts v0.235.0",
"openvibe-sdk v0.70.0",
-- "openvibe-shared v4.31.0"
++ "openvibe-shared v4.36.1"
],
"capabilities": [
"vision.ask",
@@@ -77,5 +77,5 @@
"deploy files"
],
"deployment": "not deployed: the unit and nginx vhost are deploy/systemd/openvibe-vision.service and deploy/nginx/openvibe.vision.conf",
-- "updated": "2026-10-28"
++ "updated": "2026-10-29"
}
ddiff --git a/package-lock.json b/package-lock.json
iindex b781f8d..b3f52f4 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -20,7 +20,7 @@
"onnxruntime-node": "^1.31.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.235.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.70.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.31.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.36.1",
"pg": "^8.23.0",
"sharp": "^0.33.5"
},
@@@ -31,6 +31,12 @@
"node": ">=22.0.0"
}
},
++ "node_modules/@acemir/cssom": {
++ "version": "0.9.32",
++ "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.32.tgz",
++ "integrity": "sha512-OijrAUu7rrJhSIoHNg6LMPS5e344nwUHA+1EFYYjrh64D+GmytUsfBQjci45TjT+rd5Spsp8B5xsZDT+TgyXXQ==",
++ "license": "MIT"
++ },
"node_modules/@electric-sql/pglite": {
"version": "0.5.13",
"resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.13.tgz",
@@@ -1277,6 +1283,19 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
++ "node_modules/boolbase": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-2.0.0.tgz",
++ "integrity": "sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA==",
++ "license": "ISC",
++ "engines": {
++ "node": ">=20.19.0"
++ },
++ "funding": {
++ "type": "github",
++ "url": "https://github.com/sponsors/fb55"
++ }
++ },
"node_modules/boolean": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz",
@@@ -1447,6 +1466,39 @@
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"license": "MIT"
},
++ "node_modules/css-select": {
++ "version": "7.0.0",
++ "resolved": "https://registry.npmjs.org/css-select/-/css-select-7.0.0.tgz",
++ "integrity": "sha512-snmjEVXy+1LnwXdxhYvTMj1d9tOh4HxkA1YmoayVBeeyR2C14Pum7fcxJIm4SswYspVy866eYNwlH6xC3/VH5g==",
++ "license": "BSD-2-Clause",
++ "dependencies": {
++ "boolbase": "^2.0.0",
++ "css-what": "^8.0.0",
++ "domhandler": "^6.0.1",
++ "domutils": "^4.0.2",
++ "nth-check": "^3.0.1"
++ },
++ "engines": {
++ "node": ">=20.19.0"
++ },
++ "funding": {
++ "type": "github",
++ "url": "https://github.com/sponsors/fb55"
++ }
++ },
++ "node_modules/css-what": {
++ "version": "8.0.0",
++ "resolved": "https://registry.npmjs.org/css-what/-/css-what-8.0.0.tgz",
++ "integrity": "sha512-DH0Bqq3DNp5tdOReuNyAA+Ev4Y2GS5FMbZpeTLP6C4CDi0h5nL0BmUPChXw3o/qbHLDWHl49sbNqQVY7bMSDdw==",
++ "license": "BSD-2-Clause",
++ "engines": {
++ "node": ">=20.19.0"
++ },
++ "funding": {
++ "type": "github",
++ "url": "https://github.com/sponsors/fb55"
++ }
++ },
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@@@ -1533,6 +1585,73 @@
"integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==",
"license": "MIT"
},
++ "node_modules/dom-serializer": {
++ "version": "3.1.1",
++ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz",
++ "integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==",
++ "license": "MIT",
++ "dependencies": {
++ "domelementtype": "^3.0.0",
++ "domhandler": "^6.0.0",
++ "entities": "^8.0.0"
++ },
++ "engines": {
++ "node": ">=20.19.0"
++ },
++ "funding": {
++ "type": "github",
++ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
++ }
++ },
++ "node_modules/domelementtype": {
++ "version": "3.0.0",
++ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-3.0.0.tgz",
++ "integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/fb55"
++ }
++ ],
++ "license": "BSD-2-Clause",
++ "engines": {
++ "node": ">=20.19.0"
++ }
++ },
++ "node_modules/domhandler": {
++ "version": "6.0.1",
++ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-6.0.1.tgz",
++ "integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==",
++ "license": "BSD-2-Clause",
++ "dependencies": {
++ "domelementtype": "^3.0.0"
++ },
++ "engines": {
++ "node": ">=20.19.0"
++ },
++ "funding": {
++ "type": "github",
++ "url": "https://github.com/fb55/domhandler?sponsor=1"
++ }
++ },
++ "node_modules/domutils": {
++ "version": "4.0.2",
++ "resolved": "https://registry.npmjs.org/domutils/-/domutils-4.0.2.tgz",
++ "integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==",
++ "license": "BSD-2-Clause",
++ "dependencies": {
++ "dom-serializer": "^3.0.0",
++ "domelementtype": "^3.0.0",
++ "domhandler": "^6.0.0"
++ },
++ "engines": {
++ "node": ">=20.19.0"
++ },
++ "funding": {
++ "type": "github",
++ "url": "https://github.com/fb55/domutils?sponsor=1"
++ }
++ },
"node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
@@@ -1583,6 +1702,18 @@
"node": ">= 0.8"
}
},
++ "node_modules/entities": {
++ "version": "8.1.0",
++ "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz",
++ "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==",
++ "license": "BSD-2-Clause",
++ "engines": {
++ "node": ">=20.19.0"
++ },
++ "funding": {
++ "url": "https://github.com/fb55/entities?sponsor=1"
++ }
++ },
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@@@ -1914,6 +2045,110 @@
"url": "https://github.com/sponsors/EvanHahn"
}
},
++ "node_modules/html-escaper": {
++ "version": "3.0.3",
++ "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz",
++ "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==",
++ "license": "MIT"
++ },
++ "node_modules/htmlparser2": {
++ "version": "10.1.0",
++ "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
++ "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
++ "funding": [
++ "https://github.com/fb55/htmlparser2?sponsor=1",
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/fb55"
++ }
++ ],
++ "license": "MIT",
++ "dependencies": {
++ "domelementtype": "^2.3.0",
++ "domhandler": "^5.0.3",
++ "domutils": "^3.2.2",
++ "entities": "^7.0.1"
++ }
++ },
++ "node_modules/htmlparser2/node_modules/dom-serializer": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
++ "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
++ "license": "MIT",
++ "dependencies": {
++ "domelementtype": "^2.3.0",
++ "domhandler": "^5.0.2",
++ "entities": "^4.2.0"
++ },
++ "funding": {
++ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
++ }
++ },
++ "node_modules/htmlparser2/node_modules/dom-serializer/node_modules/entities": {
++ "version": "4.5.0",
++ "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
++ "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
++ "license": "BSD-2-Clause",
++ "engines": {
++ "node": ">=0.12"
++ },
++ "funding": {
++ "url": "https://github.com/fb55/entities?sponsor=1"
++ }
++ },
++ "node_modules/htmlparser2/node_modules/domelementtype": {
++ "version": "2.3.0",
++ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
++ "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/fb55"
++ }
++ ],
++ "license": "BSD-2-Clause"
++ },
++ "node_modules/htmlparser2/node_modules/domhandler": {
++ "version": "5.0.3",
++ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
++ "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
++ "license": "BSD-2-Clause",
++ "dependencies": {
++ "domelementtype": "^2.3.0"
++ },
++ "engines": {
++ "node": ">= 4"
++ },
++ "funding": {
++ "url": "https://github.com/fb55/domhandler?sponsor=1"
++ }
++ },
++ "node_modules/htmlparser2/node_modules/domutils": {
++ "version": "3.2.2",
++ "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
++ "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
++ "license": "BSD-2-Clause",
++ "dependencies": {
++ "dom-serializer": "^2.0.0",
++ "domelementtype": "^2.3.0",
++ "domhandler": "^5.0.3"
++ },
++ "funding": {
++ "url": "https://github.com/fb55/domutils?sponsor=1"
++ }
++ },
++ "node_modules/htmlparser2/node_modules/entities": {
++ "version": "7.0.1",
++ "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
++ "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
++ "license": "BSD-2-Clause",
++ "engines": {
++ "node": ">=0.12"
++ },
++ "funding": {
++ "url": "https://github.com/fb55/entities?sponsor=1"
++ }
++ },
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@@ -2071,6 +2306,30 @@
"safe-buffer": "^5.0.1"
}
},
++ "node_modules/linkedom": {
++ "version": "0.18.14",
++ "resolved": "https://registry.npmjs.org/linkedom/-/linkedom-0.18.14.tgz",
++ "integrity": "sha512-BRUnkOm4YR5vo+qI3j0EbungB0vZPSCue5AdnTQRaxAGSbsOZiBFRO0rh4zlo8qXUhli8pxvnpEXDklMnlq2Rg==",
++ "license": "ISC",
++ "dependencies": {
++ "@acemir/cssom": "^0.9.32",
++ "css-select": "^7.0.0",
++ "html-escaper": "^3.0.3",
++ "htmlparser2": "^10.1.0",
++ "uhyphen": "^0.2.0"
++ },
++ "engines": {
++ "node": ">=16"
++ },
++ "peerDependencies": {
++ "canvas": ">= 2"
++ },
++ "peerDependenciesMeta": {
++ "canvas": {
++ "optional": true
++ }
++ }
++ },
"node_modules/lodash.defaults": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
@@@ -2269,6 +2528,22 @@
"node": ">= 0.6"
}
},
++ "node_modules/nth-check": {
++ "version": "3.0.1",
++ "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-3.0.1.tgz",
++ "integrity": "sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ==",
++ "license": "BSD-2-Clause",
++ "dependencies": {
++ "boolbase": "^2.0.0"
++ },
++ "engines": {
++ "node": ">=20.19.0"
++ },
++ "funding": {
++ "type": "github",
++ "url": "https://github.com/fb55/nth-check?sponsor=1"
++ }
++ },
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@@ -2394,12 +2669,13 @@
}
},
"node_modules/openvibe-shared": {
-- "version": "4.31.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.31.0",
-- "integrity": "sha512-Zk1t1RGsiszXJEyW3x/cVCYC6U1qyLXID43l/Ehv2Py5gGglz5CfgYlPcDGI7n3QACsCWoOIGZuMBvlyfxyXEA==",
++ "version": "4.36.1",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.36.1",
++ "integrity": "sha512-XlEoj5JkYbDAMdtmPAPQMf2ygGbXq9NqY3XwoqQzUiZ9mpX0xdC4LuEKEDB967x5auW9CZzy3Ssa/XoKdFEBzA==",
"license": "MIT",
"dependencies": {
-- "jsonwebtoken": "^9.0.0"
++ "jsonwebtoken": "^9.0.0",
++ "linkedom": "^0.18.13"
},
"bin": {
"openvibe-build-assets": "scripts/build-assets.js"
@@@ -3006,6 +3282,12 @@
"node": ">= 0.6"
}
},
++ "node_modules/uhyphen": {
++ "version": "0.2.0",
++ "resolved": "https://registry.npmjs.org/uhyphen/-/uhyphen-0.2.0.tgz",
++ "integrity": "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==",
++ "license": "ISC"
++ },
"node_modules/undici-types": {
"version": "8.9.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
ddiff --git a/package.json b/package.json
iindex c6b29d1..ac43f36 100644
---- a/package.json
++++ b/package.json
@@@ -30,7 +30,7 @@
"onnxruntime-node": "^1.31.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.235.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.70.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.31.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.36.1",
"pg": "^8.23.0",
"sharp": "^0.33.5"
},
ddiff --git a/public/css/vision.css b/public/css/vision.css
iindex 6c47b05..542f7a4 100644
---- a/public/css/vision.css
++++ b/public/css/vision.css
@@@ -7,7 +7,7 @@ html, body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--tex
.skip:focus { left: 12px; top: 12px; z-index: 99; padding: 8px 12px; border-radius: 10px; background: var(--bg-card, #0f1729); color: inherit; }
.vi { display: block; }
.vi a { color: var(--room-strong); }
--.vi .ovk-i { width: 1em; height: 1em; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; flex: none; }
++:where(.vi) .ovk-i { width: 1em; height: 1em; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; flex: none; }
.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); }
ddiff --git a/public/js/vision.js b/public/js/vision.js
ddeleted file mode 100644
iindex 3c3dfd7..0000000
---- a/public/js/vision.js
++++ /dev/null
@@@ -1,49 +0,0 @@
--/* 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]);
-- }
--})();
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex 4cd0198..b6af8a1 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -90,7 +90,7 @@ ${appIcon.headTags({ site: 'vision' })}
<link rel="stylesheet" href="${ovServe.url('showcase.css')}">
<link rel="stylesheet" href="${asset('css/vision.css')}">
<script src="${ovServe.url('motion.js')}" defer></script>
--<script src="${asset('js/vision.js')}" defer></script>
++<script src="${ovServe.url('kit-web.js')}" defer></script>
${render(scripts)}
<meta name="ov-boost" content="vision@${require('./html').esc(RELEASE)}">
<script src="${require('openvibe-shared/serve').url('boost.js')}" data-main="#main" defer></script>`,
ddiff --git a/server/views/index.js b/server/views/index.js
iindex b8de493..bea32ab 100644
---- a/server/views/index.js
++++ b/server/views/index.js
@@@ -2,7 +2,7 @@
/**
* The Vision room's pages (plan 2026-10-28 §5.8). Server-rendered and whole without JavaScript: every action is
-- * a plain form or link. public/css/vision.css and public/js/vision.js are one-comment placeholders the main
++ * a plain form or link. public/css/vision.css is the site's own stylesheet; the kit (kit.css, kit-web.js) does the rest, and the main
* session replaces; the forms and results work without them. Ids and classes start with `vi-`.
*/
const { esc, time } = require('../render/html');
ddiff --git a/server/views/tools.js b/server/views/tools.js
iindex 8c95f83..74803f3 100644
---- a/server/views/tools.js
++++ b/server/views/tools.js
@@@ -4,13 +4,12 @@
* 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
++ * Everything is a plain server-rendered form, so it works without JavaScript. The drop zone is the kit's (kit.drop):
++ * kit-web.js adds drag and drop, paste and, on a phone, "Take a photo"; /read reads a picture as soon as it is chosen. 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>`;
++const kit = require('openvibe-shared/kit');
function n(value, fallback = 0) {
const x = Number(value);
@@@ -34,6 +33,12 @@ function confidence(value) {
return `${(x <= 1 ? x * 100 : x).toFixed(1)}%`;
}
++/** What an answer took, for kit.receipt: the engine, the time, the cost (free when it was), and what is kept. */
++function took(receipt, kept) {
++ const cost = n(receipt.cost_usd);
++ return [['Read by', receipt.model || receipt.engine || 'local'], ['Time', receipt.ms != null ? kit.num(receipt.ms, 'ms') : null], ['Cost', cost > 0 ? kit.num(cost, 'money') : 'free'], ['Kept', kept]];
++}
++
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>`;
}
@@@ -42,15 +47,10 @@ 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>`;
--}
++const ACCEPT = 'image/png,image/jpeg,image/webp,image/gif,image/bmp,image/tiff,image/avif';
++/** The picture's way in (kit.drop): choose, drop, paste, or take a photo on a phone. auto: send the form at once. */
++const dropZone = ({ id, name = 'image', label = 'Drop a picture here', hint = 'or choose a file', auto = false }) =>
++ kit.drop({ id, name, accept: ACCEPT, label, hint, camera: true, auto });
function examples(links) {
return `<div class="vi-examples"><span class="vi-examples-label">Try an example</span>${links.join('')}</div>`;
@@@ -90,16 +90,15 @@ function find({ signedIn = false, q = '', result = null, problem = null, byImage
} 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>`;
++ resultHtml = `<div id="vi-find-result">${kit.receipt({
++ title: rows.length ? `${rows.length} ${rows.length === 1 ? 'match' : 'matches'} ${byImage ? 'by picture' : 'by words'}` : 'No matches yet',
++ body: `\${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>\`}<p class="vi-fine">${esc(receipt.retention || 'Only your own indexed pictures are searched; the query is not stored.')}</p>`,
++ facts: took(receipt, 'not stored'),
++ })}</div>`;
} else {
resultHtml = `<section class="vi-result-tool vi-result-empty" id="vi-find-empty">
<h2>Your matches appear here</h2>
@@@ -132,24 +131,31 @@ function readView({ signedIn = false, ai = false, problem = null, result = null,
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>`;
++ // The reading first, then one quiet line of what it took (kit.receipt); a copy button when there is text.
++ const pct = result.confidence == null ? null : Number(result.confidence) <= 1 ? Number(result.confidence) * 100 : Number(result.confidence);
++ const cost = n(result.cost_usd);
++ const said = String(result.reason || 'The local reader answered first');
++ const reason = `${said.charAt(0).toUpperCase()}${said.slice(1)}${/[.!?]$/.test(said) ? '' : '.'}`;
++ const copy = result.text ? `<button type="button" class="ovk-icon-btn" data-ovk-copy="#vi-ocr-text" aria-label="Copy the text" title="Copy the text" hidden>${kit.icon('copy')}</button>` : '';
++ resultHtml = `<div id="vi-read-result">${kit.receipt({
++ title: 'What it says', actions: [copy],
++ body: `<pre class="vi-ocr-text" id="vi-ocr-text" tabindex="0">${esc(result.text || 'No text was found in this picture.')}</pre><p class="vi-fine">${esc(reason)}</p>`,
++ facts: [
++ ['Read by', `${/tesseract/i.test(String(result.model || '')) ? 'Tesseract 5' : (result.model || result.engine || 'Tesseract 5')}${result.engine === 'ai' ? ' (AI fallback)' : ''}`],
++ ['Confidence', conf && Number.isFinite(pct) ? { html: `${kit.meter({ value: pct, label: 'Confidence', text: conf })}${esc(conf)}` } : null],
++ ['Time', kit.num(result.latency_ms || result.ms, 'ms')],
++ ['Cost', cost > 0 ? kit.num(cost, 'money') : 'free'],
++ ['Kept', signedIn && result.id ? { html: `<a href="/a/${encodeURIComponent(result.id)}">in your answers</a>` } : 'not stored'],
++ ],
++ })}</div>`;
} 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>`;
++ resultHtml = '';
}
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.' })}
++${header({ kicker: 'Read · local first', title: 'Read the words in a picture', lede: 'Receipts, signs, screenshots and labels: the words come back in reading order. 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' })}
++<form class="vi-tool-form" id="vi-read-form" method="post" action="/read#vi-read-result" enctype="multipart/form-data">
++${dropZone({ id: 'vi-read-file', label: 'Choose a picture to read', hint: 'or drop or paste one · read as soon as it is chosen', auto: true })}
${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>
@@@ -179,12 +185,11 @@ function watchView({ problem = null, result = null, values = {} } = {}) {
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>`;
++ resultHtml = `<div id="vi-watch-result">${kit.receipt({
++ title: list.length ? `${list.length} ${list.length === 1 ? 'thing' : 'things'} found` : 'Nothing from your list',
++ body: `\${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>'}<p class="vi-fine">Vision finds the classes you chose; it does not identify people.</p>`,
++ facts: took(receipt, 'not stored'),
++ })}</div>`;
} else {
resultHtml = `<section class="vi-result-tool vi-result-empty" id="vi-watch-empty">
<h2>What Vision sees appears here</h2>
@@@ -195,7 +200,7 @@ ${facts([['Engine', esc(receipt.model || receipt.engine || 'local')], ['Time', e
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">
++<form class="vi-tool-form" id="vi-watch-form" method="post" action="/watch#vi-watch-result" 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>